@duckcodeailabs/dql-cli 1.12.2 → 1.12.4
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.
- package/dist/assets/dql-notebook/assets/AgentAnswerCard-BJAnjaLA.js +5 -0
- package/dist/assets/dql-notebook/assets/AgentLogPage-J72WR6hk.js +1 -0
- package/dist/assets/dql-notebook/assets/AiBuildDialog-CHJZfDmp.js +1 -0
- package/dist/assets/dql-notebook/assets/AiBuildResult-Da7_Cn12.js +33 -0
- package/dist/assets/dql-notebook/assets/AiSidePanel-BY6W_5aC.js +6 -0
- package/dist/assets/dql-notebook/assets/AnalyticsHome-u-buXgdv.js +6 -0
- package/dist/assets/dql-notebook/assets/AppsView-3k7ftDpd.js +4471 -0
- package/dist/assets/dql-notebook/assets/BlockStudio-CLX4EUyO.js +60 -0
- package/dist/assets/dql-notebook/assets/BusinessArtifactView-3THMNeyO.js +1 -0
- package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-BwBPwtdA.js +22 -0
- package/dist/assets/dql-notebook/assets/GitPage-CvEki0pe.js +7 -0
- package/dist/assets/dql-notebook/assets/GlobalAiRail-NXdk9kNy.js +1 -0
- package/dist/assets/dql-notebook/assets/GovernedContextPage-DILbw-Vr.js +7 -0
- package/dist/assets/dql-notebook/assets/HelpDocsPage-DgCu4bvY.js +22 -0
- package/dist/assets/dql-notebook/assets/HomePage-CK3fXTQo.js +117 -0
- package/dist/assets/dql-notebook/assets/LineageDAG-DW_NO3P2.js +1 -0
- package/dist/assets/dql-notebook/assets/LineageDetailView-VT7DLJOB.js +1 -0
- package/dist/assets/dql-notebook/assets/LineageDrawer-DSTm8aXw.js +1 -0
- package/dist/assets/dql-notebook/assets/LineagePathBreadcrumb-DRlAYasJ.js +14 -0
- package/dist/assets/dql-notebook/assets/MiniLineageGraph-CR_nhwhZ.js +1 -0
- package/dist/assets/dql-notebook/assets/NewBlockModal-CJvjYiBF.js +1 -0
- package/dist/assets/dql-notebook/assets/NewNotebookModal-BEpPH93n.js +46 -0
- package/dist/assets/dql-notebook/assets/NotebookEditor-C7GDFwhF.js +202 -0
- package/dist/assets/dql-notebook/assets/ReadinessPage-rZiOp2xr.js +1 -0
- package/dist/assets/dql-notebook/assets/SetupOnboarding-BWjhTjPB.js +6 -0
- package/dist/assets/dql-notebook/assets/TrustBadge-RvZ9s6OQ.js +1 -0
- package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-CGPcpY1B.js +83 -0
- package/dist/assets/dql-notebook/assets/ai-build-bus-1BR770pY.js +1 -0
- package/dist/assets/dql-notebook/assets/answer-to-notebook-Di227Cd4.js +2 -0
- package/dist/assets/dql-notebook/assets/arrow-left-BZHDcWHR.js +6 -0
- package/dist/assets/dql-notebook/assets/arrow-right-DlM1pC3_.js +6 -0
- package/dist/assets/dql-notebook/assets/book-open-text-DBcXFQAu.js +6 -0
- package/dist/assets/dql-notebook/assets/bot-BAywbvPm.js +6 -0
- package/dist/assets/dql-notebook/assets/chevron-up-B_GfKE_F.js +6 -0
- package/dist/assets/dql-notebook/assets/circle-x-BcxtZ-ym.js +6 -0
- package/dist/assets/dql-notebook/assets/{codemirror-Co0dCEDr.js → codemirror-1_I9gm6Z.js} +1 -1
- package/dist/assets/dql-notebook/assets/dagre-BZV40eAE.css +1 -0
- package/dist/assets/dql-notebook/assets/dagre.esm-Bqs0lsEz.js +23 -0
- package/dist/assets/dql-notebook/assets/external-link-B24Dk9_t.js +6 -0
- package/dist/assets/dql-notebook/assets/grip-vertical-D9g31N3j.js +6 -0
- package/dist/assets/dql-notebook/assets/{index-DvLT6Kls.css → index-DKu4CZt3.css} +1 -1
- package/dist/assets/dql-notebook/assets/index-DU7j3Sqk.js +644 -0
- package/dist/assets/dql-notebook/assets/link-2-B36oR5Bj.js +6 -0
- package/dist/assets/dql-notebook/assets/list-tree-B9ZzGc4U.js +6 -0
- package/dist/assets/dql-notebook/assets/maximize-2-BrvQLPoj.js +6 -0
- package/dist/assets/dql-notebook/assets/minimize-2-BrpFmsxS.js +6 -0
- package/dist/assets/dql-notebook/assets/panel-right-open-B1g1qhh5.js +6 -0
- package/dist/assets/dql-notebook/assets/pencil-DwywbTqQ.js +6 -0
- package/dist/assets/dql-notebook/assets/{react-BRY8hXab.js → react-CRB3T2We.js} +2 -2
- package/dist/assets/dql-notebook/assets/rotate-ccw-Db_KVs3J.js +6 -0
- package/dist/assets/dql-notebook/assets/semantic-fields-BIVuvP2l.js +3 -0
- package/dist/assets/dql-notebook/assets/triangle-alert-Cne6C2aG.js +6 -0
- package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-D6mtrJ-_.js +18 -0
- package/dist/assets/dql-notebook/assets/user-round-DkzxN1xD.js +11 -0
- package/dist/assets/dql-notebook/assets/wand-sparkles-CJUvFtur.js +6 -0
- package/dist/assets/dql-notebook/assets/workflow-BOvVTg7R.js +6 -0
- package/dist/assets/dql-notebook/assets/wrench-YeSpzZRt.js +6 -0
- package/dist/assets/dql-notebook/assets/x-C6tsHUR7.js +6 -0
- package/dist/assets/dql-notebook/index.html +3 -4
- package/dist/block-invocation.js +4 -0
- package/dist/block-invocation.js.map +1 -1
- package/dist/llm/answer-loop-tools.d.ts +8 -2
- package/dist/llm/answer-loop-tools.d.ts.map +1 -1
- package/dist/llm/answer-loop-tools.js +29 -2
- package/dist/llm/answer-loop-tools.js.map +1 -1
- package/dist/llm/providers/dql-agent-provider.d.ts +27 -1
- package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
- package/dist/llm/providers/dql-agent-provider.js +79 -15
- package/dist/llm/providers/dql-agent-provider.js.map +1 -1
- package/dist/llm/types.d.ts +8 -1
- package/dist/llm/types.d.ts.map +1 -1
- package/dist/local-runtime.d.ts +56 -2
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +735 -104
- package/dist/local-runtime.js.map +1 -1
- package/dist/package.json +10 -10
- package/dist/sql-result-filter.d.ts +39 -0
- package/dist/sql-result-filter.d.ts.map +1 -0
- package/dist/sql-result-filter.js +256 -0
- package/dist/sql-result-filter.js.map +1 -0
- package/dist/warehouse-metadata.d.ts +1 -1
- package/dist/warehouse-metadata.d.ts.map +1 -1
- package/dist/warehouse-metadata.js +2 -2
- package/dist/warehouse-metadata.js.map +1 -1
- package/package.json +10 -10
- package/dist/assets/dql-notebook/assets/index-BRCtmVPa.js +0 -5952
package/dist/local-runtime.js
CHANGED
|
@@ -20,7 +20,8 @@ import { fetchLatestPublishedDqlVersion, resolveDqlRuntimeVersionStatus } from '
|
|
|
20
20
|
import { resolveRetrievalHealthStatus } from './retrieval-health.js';
|
|
21
21
|
import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
|
|
22
22
|
import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
|
|
23
|
-
import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, projectEmbeddingProvider, isHashedEmbeddingProvider, clearProjectEmbeddingCache, upgradeVectorIndexForProject, openMetadataCatalog, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, } from '@duckcodeailabs/dql-agent';
|
|
23
|
+
import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, projectEmbeddingProvider, isHashedEmbeddingProvider, clearProjectEmbeddingCache, upgradeVectorIndexForProject, openMetadataCatalog, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, isTrustedConversationTurn, analyticalError, withAnalyticalErrorOrigin, withAnalyticalErrorOriginSync, } from '@duckcodeailabs/dql-agent';
|
|
24
|
+
import { addSqlResultFilter, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
|
|
24
25
|
import { gatherProposeEnrichment } from './propose-enrich.js';
|
|
25
26
|
import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
|
|
26
27
|
import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
|
|
@@ -435,6 +436,9 @@ async function conversationContextFromThread(store, threadId, clientContext, que
|
|
|
435
436
|
trustLabel: turn.trustLabel,
|
|
436
437
|
runStatus: turn.runStatus,
|
|
437
438
|
stopReason: turn.stopReason,
|
|
439
|
+
// Carried so every consumer can compute trust from the same fields.
|
|
440
|
+
refusalCode: turn.refusalCode,
|
|
441
|
+
executionError: turn.executionError,
|
|
438
442
|
certification: turn.certification,
|
|
439
443
|
contextPackId: turn.contextPackId,
|
|
440
444
|
dqlArtifact: turn.dqlArtifact,
|
|
@@ -473,9 +477,30 @@ async function conversationContextFromThread(store, threadId, clientContext, que
|
|
|
473
477
|
...(serverSnapshot ? { conversationEnvelope: serverSnapshot } : {}),
|
|
474
478
|
...(serverSnapshot ? { serverSnapshot } : {}),
|
|
475
479
|
...(thread?.rollingSummary ? { conversationSummary: thread.rollingSummary } : {}),
|
|
476
|
-
|
|
480
|
+
// `activeTurnId` is the FOLLOW-UP ANCHOR: the turn whose filters, prior DQL
|
|
481
|
+
// artifact, and source SQL the next question builds on. Anchoring it to the
|
|
482
|
+
// last turn unconditionally meant a failed turn became the anchor, and its
|
|
483
|
+
// broken artifact and failing SQL were handed to the next question as
|
|
484
|
+
// authoritative context — the sticky-error loop. `latestTurnId` keeps the
|
|
485
|
+
// real tail for presentation.
|
|
486
|
+
...(turns.length > 0
|
|
487
|
+
? {
|
|
488
|
+
turns,
|
|
489
|
+
activeTurnId: lastTrustedTurnId(turns) ?? undefined,
|
|
490
|
+
latestTurnId: turns[turns.length - 1].id,
|
|
491
|
+
}
|
|
492
|
+
: {}),
|
|
477
493
|
};
|
|
478
494
|
}
|
|
495
|
+
/** The most recent turn a follow-up may safely build on. */
|
|
496
|
+
function lastTrustedTurnId(turns) {
|
|
497
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
498
|
+
const turn = turns[index];
|
|
499
|
+
if (isTrustedConversationTurn(turn))
|
|
500
|
+
return turn.id;
|
|
501
|
+
}
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
479
504
|
/** Best-effort: persist a completed run as a conversation turn (never throws). */
|
|
480
505
|
function recordConversationTurn(store, threadId, run) {
|
|
481
506
|
if (!store || !threadId)
|
|
@@ -489,8 +514,12 @@ function recordConversationTurn(store, threadId, run) {
|
|
|
489
514
|
// turn folding its (possibly misparsed) filters and measures into working
|
|
490
515
|
// state poisoned every subsequent ask: the SAME question that worked once
|
|
491
516
|
// then failed forever because the bad turn's phantom state carried forward.
|
|
492
|
-
|
|
493
|
-
|
|
517
|
+
//
|
|
518
|
+
// The previous guard tested `run.status`, which cannot express this: a
|
|
519
|
+
// grounding-gap refusal and a perfectly good uncertified answer are BOTH
|
|
520
|
+
// `needs_review`, so the guard admitted exactly the failures its comment
|
|
521
|
+
// was written to exclude. Trust is now derived from answer state.
|
|
522
|
+
if (isTrustedConversationTurn(turn))
|
|
494
523
|
advanceThreadState(store, threadId, turn);
|
|
495
524
|
}
|
|
496
525
|
catch {
|
|
@@ -529,6 +558,11 @@ export function conversationTurnInputFromRun(run) {
|
|
|
529
558
|
trustLabel: agentRunString(payload?.trustLabel) ?? run.trustState,
|
|
530
559
|
runStatus: run.status,
|
|
531
560
|
stopReason: run.stopReason,
|
|
561
|
+
// Persisted so a later turn can tell a refusal from an answer. `runStatus`
|
|
562
|
+
// alone cannot: a grounding gap and a good uncertified answer are both
|
|
563
|
+
// `needs_review`.
|
|
564
|
+
refusalCode: agentRunString(payload?.refusalCode),
|
|
565
|
+
executionError: agentRunString(payload?.executionError),
|
|
532
566
|
certification: agentRunString(payload?.certification),
|
|
533
567
|
sourceCertifiedBlock: agentRunString(payload?.sourceCertifiedBlock)
|
|
534
568
|
?? (artifact?.kind === 'answer' ? agentRunString(artifact.ref) : undefined),
|
|
@@ -968,7 +1002,10 @@ export async function startLocalServer(opts) {
|
|
|
968
1002
|
return result;
|
|
969
1003
|
};
|
|
970
1004
|
await reloadSemanticLayer();
|
|
971
|
-
|
|
1005
|
+
// Normal startup reuses the fingerprinted local catalog when its inputs are
|
|
1006
|
+
// unchanged. Explicit refresh, save, and certification paths still request
|
|
1007
|
+
// the default forced rebuild below.
|
|
1008
|
+
await refreshLocalMetadataCatalog(projectRoot, undefined, undefined, false);
|
|
972
1009
|
const startupDbtManifestPath = resolveDbtManifestPath(projectRoot, projectConfig);
|
|
973
1010
|
if (startupDbtManifestPath && !isAgentProjectIndexReady(projectRoot)) {
|
|
974
1011
|
try {
|
|
@@ -1338,8 +1375,14 @@ export async function startLocalServer(opts) {
|
|
|
1338
1375
|
throw error;
|
|
1339
1376
|
}
|
|
1340
1377
|
const semanticDriver = semanticConnection?.driver;
|
|
1378
|
+
const defaultExecutionTarget = executionTargetDescriptor({});
|
|
1379
|
+
const semanticConnectionName = request.executionTarget?.target === 'connection'
|
|
1380
|
+
? request.executionTarget.connectionName
|
|
1381
|
+
: defaultExecutionTarget.target === 'connection'
|
|
1382
|
+
? defaultExecutionTarget.connectionName
|
|
1383
|
+
: undefined;
|
|
1341
1384
|
const semanticTableMapping = semanticLayer && semanticConnection
|
|
1342
|
-
? await resolveSemanticTableMapping(executor, semanticConnection, semanticLayer, projectRoot)
|
|
1385
|
+
? await resolveSemanticTableMapping(executor, semanticConnection, semanticLayer, projectRoot, semanticConnectionName)
|
|
1343
1386
|
: undefined;
|
|
1344
1387
|
const requestedDomain = normalizeAgentRunDomain(agentRunWorkspaceValue(request, 'domain'));
|
|
1345
1388
|
const requestedPurpose = agentRunWorkspaceValue(request, 'purpose');
|
|
@@ -1527,12 +1570,13 @@ export async function startLocalServer(opts) {
|
|
|
1527
1570
|
? { resolvedAnalyticalPlan: routeDecision.resolvedAnalyticalPlan }
|
|
1528
1571
|
: {}),
|
|
1529
1572
|
analyticalReferenceInstant: new Date().toISOString(),
|
|
1530
|
-
executeCertifiedBlock: (node, invocation) => executeCertifiedBlockForAgent(node, invocation, semanticConnection),
|
|
1531
|
-
executeGeneratedSql: (sql, artifact) => executeGeneratedArtifactForAgent(request.question, sql, artifact, semanticConnection),
|
|
1532
|
-
executeDqlArtifact: (artifact) => executeArtifactReferenceForAgent(artifact, request.question, semanticConnection),
|
|
1573
|
+
executeCertifiedBlock: (node, invocation) => executeCertifiedBlockForAgent(node, invocation, semanticConnection, semanticConnectionName),
|
|
1574
|
+
executeGeneratedSql: (sql, artifact) => executeGeneratedArtifactForAgent(request.question, sql, artifact, semanticConnection, semanticConnectionName),
|
|
1575
|
+
executeDqlArtifact: (artifact) => executeArtifactReferenceForAgent(artifact, request.question, semanticConnection, semanticConnectionName),
|
|
1533
1576
|
getSchemaContext: (question, preparedContextPack) => getSchemaContextForAgent(question, preparedContextPack, semanticConnection, request.executionTarget?.target === 'connection'
|
|
1534
1577
|
? request.executionTarget.connectionName
|
|
1535
1578
|
: undefined),
|
|
1579
|
+
probeNamedRelations: (relations) => probeNamedRelationsForAgent(relations, semanticConnection),
|
|
1536
1580
|
}, (turn) => {
|
|
1537
1581
|
if (turn.kind === 'thinking')
|
|
1538
1582
|
onProgress?.(turn.text);
|
|
@@ -1918,7 +1962,7 @@ export async function startLocalServer(opts) {
|
|
|
1918
1962
|
],
|
|
1919
1963
|
};
|
|
1920
1964
|
}
|
|
1921
|
-
const
|
|
1965
|
+
const answeredRoute = resolvedRunRouteFromAnswer(governedAnswer) ?? route;
|
|
1922
1966
|
const isCertified = governedAnswer.certification === 'certified' || governedAnswer.kind === 'certified';
|
|
1923
1967
|
const isSemantic = governedAnswer.route?.tier === 'semantic_metric';
|
|
1924
1968
|
const requestedNotebookDataset = findMentionedNotebookDataset(request.question, datasetWorkspace.list());
|
|
@@ -1949,6 +1993,12 @@ export async function startLocalServer(opts) {
|
|
|
1949
1993
|
// provider outages are blocked so the UI can offer an explicit retry.
|
|
1950
1994
|
const needsClarification = governedAnswer.kind === 'no_answer'
|
|
1951
1995
|
&& !isGroundingGap && !isProviderError && !isModelDeclined && !isPolicyBlocked;
|
|
1996
|
+
// A run that ASKED THE USER A QUESTION is a clarify turn, whatever route it
|
|
1997
|
+
// would have taken had it answered. Persisting the resolved analytical route
|
|
1998
|
+
// instead (`generated_answer`) meant the engine's clarification-continuation
|
|
1999
|
+
// lookup — which matches on `route === 'clarify'` — never found it, so the
|
|
2000
|
+
// reply ran context-free and was clarified again: the reported clarify loop.
|
|
2001
|
+
const resolvedRoute = needsClarification ? 'clarify' : answeredRoute;
|
|
1952
2002
|
const sql = governedAnswer.proposedSql ?? governedAnswer.sql;
|
|
1953
2003
|
const runnableSql = governedAnswer.kind === 'no_answer' || isExecutionFailure || (isExploratory && !governedAnswer.result)
|
|
1954
2004
|
? undefined
|
|
@@ -1993,31 +2043,45 @@ export async function startLocalServer(opts) {
|
|
|
1993
2043
|
synthesizedAnswer = undefined;
|
|
1994
2044
|
}
|
|
1995
2045
|
}
|
|
2046
|
+
// A grounding/modeling gap is a REFUSAL. It was computed above but never
|
|
2047
|
+
// reached this ternary, so it fell through to `needs_review` — the same
|
|
2048
|
+
// status as a perfectly good uncertified answer. Every downstream trust
|
|
2049
|
+
// check then treated the refusal as a usable turn, which is how one failed
|
|
2050
|
+
// answer poisoned every later question in the thread.
|
|
1996
2051
|
const status = isTerminalFailure
|
|
1997
2052
|
? 'blocked'
|
|
1998
2053
|
: needsClarification
|
|
1999
2054
|
? 'needs_clarification'
|
|
2000
|
-
:
|
|
2001
|
-
? '
|
|
2002
|
-
:
|
|
2055
|
+
: isGroundingGap || isModelDeclined
|
|
2056
|
+
? 'blocked'
|
|
2057
|
+
: isCertified || isSemantic
|
|
2058
|
+
? 'completed'
|
|
2059
|
+
: 'needs_review';
|
|
2003
2060
|
const trustState = isTerminalFailure
|
|
2004
2061
|
? 'blocked'
|
|
2005
2062
|
: needsClarification
|
|
2006
2063
|
? 'not_applicable'
|
|
2007
|
-
:
|
|
2008
|
-
? '
|
|
2009
|
-
:
|
|
2010
|
-
? '
|
|
2011
|
-
:
|
|
2064
|
+
: isGroundingGap || isModelDeclined
|
|
2065
|
+
? 'blocked'
|
|
2066
|
+
: isCertified
|
|
2067
|
+
? 'certified'
|
|
2068
|
+
: isSemantic
|
|
2069
|
+
? 'governed'
|
|
2070
|
+
: 'review_required';
|
|
2012
2071
|
const stopReason = isTerminalFailure
|
|
2013
2072
|
? 'blocked'
|
|
2014
2073
|
: needsClarification
|
|
2015
2074
|
? 'needs_clarification'
|
|
2016
|
-
:
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2075
|
+
// A gap is terminal, but it is NOT a provider outage: it still owes the
|
|
2076
|
+
// user its evidence trace, and its next-actions below stay the
|
|
2077
|
+
// grounding-gap set rather than "retry the provider".
|
|
2078
|
+
: isGroundingGap || isModelDeclined
|
|
2079
|
+
? 'human_review_required'
|
|
2080
|
+
: isCertified
|
|
2081
|
+
? 'certified_answer_found'
|
|
2082
|
+
: isSemantic
|
|
2083
|
+
? 'governed_semantic_answer'
|
|
2084
|
+
: 'human_review_required';
|
|
2021
2085
|
const terminalFailureActions = governedAnswer.analyticalFailure
|
|
2022
2086
|
? governedAnswer.analyticalFailure.code === 'PERMISSION_DENIED'
|
|
2023
2087
|
? [{ id: 'repair-access', label: 'Change connection or request access', route: 'blocked' }]
|
|
@@ -2163,33 +2227,43 @@ export async function startLocalServer(opts) {
|
|
|
2163
2227
|
const kind = routeDecision?.conversationalKind ?? 'smalltalk';
|
|
2164
2228
|
const isGeneralKnowledge = routeDecision?.category === 'general_knowledge';
|
|
2165
2229
|
const answerKind = isGeneralKnowledge ? 'general_knowledge' : 'conversational';
|
|
2166
|
-
const catalogContext = buildAgentRunCatalogContext();
|
|
2230
|
+
const catalogContext = kind === 'answer_explanation' ? '' : buildAgentRunCatalogContext();
|
|
2167
2231
|
const suggestions = buildConversationSuggestions(projectRoot, kind);
|
|
2168
2232
|
const nextActions = suggestions.map((prompt, index) => ({
|
|
2169
2233
|
id: `suggest-question-${index + 1}`,
|
|
2170
2234
|
label: prompt,
|
|
2171
2235
|
}));
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
...(conversationMemory ? [{ role: 'system', content: conversationMemory }] : []),
|
|
2181
|
-
...(request.history ?? []).slice(-6).map((message) => ({ role: message.role, content: message.text })),
|
|
2182
|
-
{ role: 'user', content: request.question },
|
|
2183
|
-
];
|
|
2184
|
-
text = (await provider.generate(messages, { maxTokens: 320, temperature: 0.6 })).trim();
|
|
2185
|
-
// Perceived-latency: surface the reply as one delta for surfaces wired to stream.
|
|
2186
|
-
if (text)
|
|
2187
|
-
emitAnswerDelta?.(text);
|
|
2188
|
-
}
|
|
2236
|
+
// A question about the latest answer is an evidence lookup, not another
|
|
2237
|
+
// warehouse run. Resolve it from the persisted artifact contract before
|
|
2238
|
+
// considering a provider-generated conversational reply.
|
|
2239
|
+
let text = kind === 'answer_explanation'
|
|
2240
|
+
? buildPriorAnswerExplanation(request.question, request.conversationContext)
|
|
2241
|
+
: undefined;
|
|
2242
|
+
if (text) {
|
|
2243
|
+
emitAnswerDelta?.(text);
|
|
2189
2244
|
}
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2245
|
+
else {
|
|
2246
|
+
try {
|
|
2247
|
+
const provider = await createBlockStudioAssistProvider(projectRoot);
|
|
2248
|
+
if (provider) {
|
|
2249
|
+
const system = buildConversationSystemPrompt(kind, isGeneralKnowledge, catalogContext, request.audience ?? 'analyst');
|
|
2250
|
+
const conversationMemory = renderConversationMemoryForPrompt(request.conversationContext);
|
|
2251
|
+
const messages = [
|
|
2252
|
+
{ role: 'system', content: system },
|
|
2253
|
+
...(conversationMemory ? [{ role: 'system', content: conversationMemory }] : []),
|
|
2254
|
+
...(request.history ?? []).slice(-6).map((message) => ({ role: message.role, content: message.text })),
|
|
2255
|
+
{ role: 'user', content: request.question },
|
|
2256
|
+
];
|
|
2257
|
+
text = (await provider.generate(messages, { maxTokens: 320, temperature: 0.6 })).trim();
|
|
2258
|
+
// Perceived-latency: surface the reply as one delta for surfaces wired to stream.
|
|
2259
|
+
if (text)
|
|
2260
|
+
emitAnswerDelta?.(text);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
catch {
|
|
2264
|
+
// Provider unavailable / errored — fall through to the deterministic reply.
|
|
2265
|
+
text = undefined;
|
|
2266
|
+
}
|
|
2193
2267
|
}
|
|
2194
2268
|
if (!text) {
|
|
2195
2269
|
text = buildConversationContextRecap(request.conversationContext)
|
|
@@ -2197,7 +2271,7 @@ export async function startLocalServer(opts) {
|
|
|
2197
2271
|
emitAnswerDelta?.(text);
|
|
2198
2272
|
}
|
|
2199
2273
|
return {
|
|
2200
|
-
summary: 'Replied conversationally.',
|
|
2274
|
+
summary: kind === 'answer_explanation' ? 'Explained the previous answer.' : 'Replied conversationally.',
|
|
2201
2275
|
answer: text,
|
|
2202
2276
|
answerKind,
|
|
2203
2277
|
status: 'completed',
|
|
@@ -2706,6 +2780,9 @@ export async function startLocalServer(opts) {
|
|
|
2706
2780
|
const serverSnapshot = agentRunRecord(request.conversationContext?.conversationEnvelope
|
|
2707
2781
|
?? request.conversationContext?.serverSnapshot);
|
|
2708
2782
|
const topicRelation = agentRunString(serverSnapshot?.topicRelation);
|
|
2783
|
+
const workspaceSurface = agentRunWorkspaceValue(request, 'surface');
|
|
2784
|
+
const blockAuthoringContext = request.requestedMode === 'block'
|
|
2785
|
+
|| workspaceSurface === 'block-studio';
|
|
2709
2786
|
// The readiness marker is source-versioned. When it matches, pass the
|
|
2710
2787
|
// already-built metadata identity into retrieval so buildLocalContextPack
|
|
2711
2788
|
// opens the immutable snapshot directly instead of rebuilding all metadata
|
|
@@ -2713,9 +2790,23 @@ export async function startLocalServer(opts) {
|
|
|
2713
2790
|
const preparedMetadataFingerprint = isAgentProjectIndexReady(projectRoot)
|
|
2714
2791
|
? currentMetadataFingerprint(projectRoot)
|
|
2715
2792
|
: undefined;
|
|
2793
|
+
const targetDescriptor = executionTargetDescriptor(request);
|
|
2794
|
+
const targetConnectionName = targetDescriptor.target === 'connection'
|
|
2795
|
+
? targetDescriptor.connectionName
|
|
2796
|
+
: undefined;
|
|
2797
|
+
let runtimeSchemaSnapshot;
|
|
2798
|
+
if (targetConnectionName) {
|
|
2799
|
+
const targetConnection = resolveNamedConnection(targetConnectionName);
|
|
2800
|
+
const activeScope = targetConnection
|
|
2801
|
+
? optionalActiveConnectionMetadataScope(projectRoot, projectConfig, targetConnection, targetConnectionName)
|
|
2802
|
+
: null;
|
|
2803
|
+
runtimeSchemaSnapshot = runtimeSchemaSnapshotForAgentConnection(projectRoot, targetConnectionName, activeScope?.scopeFingerprint);
|
|
2804
|
+
}
|
|
2716
2805
|
const task = buildLocalContextPack(projectRoot, {
|
|
2717
2806
|
question: request.question,
|
|
2718
2807
|
focusObjectKey: request.selectedEvidenceId,
|
|
2808
|
+
mode: blockAuthoringContext ? 'build' : 'question',
|
|
2809
|
+
includeDraftBlocks: blockAuthoringContext,
|
|
2719
2810
|
followUp,
|
|
2720
2811
|
priorContextPackId: agentRunString(request.conversationContext?.contextPackId),
|
|
2721
2812
|
conversationTopicRelation: topicRelation === 'continuation'
|
|
@@ -2724,8 +2815,14 @@ export async function startLocalServer(opts) {
|
|
|
2724
2815
|
|| topicRelation === 'shift'
|
|
2725
2816
|
? topicRelation
|
|
2726
2817
|
: undefined,
|
|
2818
|
+
// Reuse remains available for fast follow-ups; the context-pack freshness
|
|
2819
|
+
// contract now binds it to this runtime snapshot's connection/generation.
|
|
2820
|
+
reusePolicy: 'seed',
|
|
2727
2821
|
preparedMetadataFingerprint,
|
|
2728
|
-
|
|
2822
|
+
...(runtimeSchemaSnapshot ? { runtimeSchemaSnapshot } : {}),
|
|
2823
|
+
surface: blockAuthoringContext
|
|
2824
|
+
? 'block'
|
|
2825
|
+
: workspaceSurface ?? 'notebook',
|
|
2729
2826
|
selectedContext: {
|
|
2730
2827
|
selectedObject: request.selectedObject,
|
|
2731
2828
|
workspaceContext: request.workspaceContext,
|
|
@@ -2995,44 +3092,56 @@ export async function startLocalServer(opts) {
|
|
|
2995
3092
|
cells: snapshotCells,
|
|
2996
3093
|
});
|
|
2997
3094
|
};
|
|
2998
|
-
const executeDqlArtifactSourceForAgent = async (source, metadata = {}, invocationInput, executionConnection) => {
|
|
2999
|
-
|
|
3095
|
+
const executeDqlArtifactSourceForAgent = async (source, metadata = {}, invocationInput, executionConnection, executionConnectionName) => {
|
|
3096
|
+
// Everything from here to the executor call is DQL's OWN block compiler:
|
|
3097
|
+
// parse, parameter resolution, plan build, semantic compose. A failure in
|
|
3098
|
+
// any of it means DQL could not rebuild its artifact — the warehouse was
|
|
3099
|
+
// never contacted. Tagging the origin here stops the answer loop from
|
|
3100
|
+
// reporting these as "the generated SQL failed to execute".
|
|
3101
|
+
const invocation = withAnalyticalCompilationOrigin('bind', () => prepareBlockInvocation({
|
|
3000
3102
|
source,
|
|
3001
3103
|
parameters: invocationInput?.parameters,
|
|
3002
3104
|
parameterSources: invocationInput?.parameterSources,
|
|
3003
3105
|
question: invocationInput?.question,
|
|
3004
3106
|
surface: 'ask_ai',
|
|
3005
|
-
});
|
|
3006
|
-
if (invocation.errors.length > 0)
|
|
3007
|
-
throw
|
|
3107
|
+
}));
|
|
3108
|
+
if (invocation.errors.length > 0) {
|
|
3109
|
+
throw analyticalError(invocation.errors.join(' '), { origin: 'dql_compilation', stage: 'compile' });
|
|
3110
|
+
}
|
|
3008
3111
|
if (invocation.unresolvedParameters.length > 0) {
|
|
3009
|
-
throw
|
|
3112
|
+
throw analyticalError(`I need values for: ${invocation.unresolvedParameters.join(', ')}.`, {
|
|
3113
|
+
origin: 'dql_compilation',
|
|
3114
|
+
stage: 'bind',
|
|
3115
|
+
code: 'unresolved_parameter',
|
|
3116
|
+
offending: { parameter: invocation.unresolvedParameters[0] },
|
|
3117
|
+
});
|
|
3010
3118
|
}
|
|
3011
3119
|
const activeConnection = requireActiveConnection(executionConnection);
|
|
3012
3120
|
const precompiledSemanticQuery = Boolean(extractBlockStudioSql(source));
|
|
3013
3121
|
const tableMapping = !precompiledSemanticQuery
|
|
3014
|
-
? await resolveSemanticTableMapping(executor, activeConnection, semanticLayer, projectRoot)
|
|
3122
|
+
? await resolveSemanticTableMapping(executor, activeConnection, semanticLayer, projectRoot, executionConnectionName)
|
|
3015
3123
|
: undefined;
|
|
3016
|
-
const
|
|
3017
|
-
|
|
3124
|
+
const composeLayer = semanticLayer;
|
|
3125
|
+
const semanticCompose = composeLayer
|
|
3126
|
+
? await withAnalyticalErrorOrigin({ origin: 'dql_compilation', stage: 'compile' }, () => composeSemanticBlockSqlForRuntime(source, composeLayer, {
|
|
3018
3127
|
driver: activeConnection.driver,
|
|
3019
3128
|
tableMapping,
|
|
3020
3129
|
projectRoot,
|
|
3021
3130
|
projectConfig,
|
|
3022
3131
|
detectedProvider: semanticDetectedProvider,
|
|
3023
3132
|
parameters: invocation.values,
|
|
3024
|
-
})
|
|
3133
|
+
}))
|
|
3025
3134
|
: null;
|
|
3026
|
-
const plan = buildExecutionPlan({ id: `agent-${metadata.name ?? 'dql-artifact'}`, type: 'dql', source, title: metadata.name ?? 'DQL artifact' }, {
|
|
3135
|
+
const plan = withAnalyticalCompilationOrigin('compile', () => buildExecutionPlan({ id: `agent-${metadata.name ?? 'dql-artifact'}`, type: 'dql', source, title: metadata.name ?? 'DQL artifact' }, {
|
|
3027
3136
|
semanticLayer,
|
|
3028
3137
|
driver: activeConnection.driver,
|
|
3029
3138
|
tableMapping,
|
|
3030
3139
|
parameters: invocation.values,
|
|
3031
3140
|
semanticSql: semanticCompose?.sql ?? undefined,
|
|
3032
|
-
});
|
|
3141
|
+
}));
|
|
3033
3142
|
if (!plan && !semanticCompose?.sql) {
|
|
3034
3143
|
const semanticError = semanticCompose?.diagnostics.find((diagnostic) => diagnostic.severity === 'error')?.message;
|
|
3035
|
-
throw
|
|
3144
|
+
throw analyticalError(semanticError ?? `DQL artifact "${metadata.name ?? 'draft'}" produced no executable SQL.`, { origin: 'dql_compilation', stage: 'compile' });
|
|
3036
3145
|
}
|
|
3037
3146
|
const prepared = prepareLocalExecution(semanticCompose?.sql ?? plan.sql, activeConnection, projectRoot, projectConfig);
|
|
3038
3147
|
const executionSql = invocationInput?.rowLimit
|
|
@@ -3080,7 +3189,7 @@ export async function startLocalServer(opts) {
|
|
|
3080
3189
|
} : {}),
|
|
3081
3190
|
};
|
|
3082
3191
|
};
|
|
3083
|
-
const executeCertifiedBlockByNameForAgent = async (blockName, invocationInput, requireCertified = false, executionConnection) => {
|
|
3192
|
+
const executeCertifiedBlockByNameForAgent = async (blockName, invocationInput, requireCertified = false, executionConnection, executionConnectionName) => {
|
|
3084
3193
|
const manifest = buildManifest({ projectRoot });
|
|
3085
3194
|
const block = manifest.blocks[blockName];
|
|
3086
3195
|
if (!block) {
|
|
@@ -3095,7 +3204,7 @@ export async function startLocalServer(opts) {
|
|
|
3095
3204
|
path: block.filePath,
|
|
3096
3205
|
domain: block.domain,
|
|
3097
3206
|
chartType: block.chartType,
|
|
3098
|
-
}, invocationInput, executionConnection);
|
|
3207
|
+
}, invocationInput, executionConnection, executionConnectionName);
|
|
3099
3208
|
return {
|
|
3100
3209
|
...result,
|
|
3101
3210
|
dqlArtifact: {
|
|
@@ -3114,17 +3223,17 @@ export async function startLocalServer(opts) {
|
|
|
3114
3223
|
},
|
|
3115
3224
|
};
|
|
3116
3225
|
};
|
|
3117
|
-
const executeCertifiedBlockForAgent = async (node, invocationInput, executionConnection) => {
|
|
3226
|
+
const executeCertifiedBlockForAgent = async (node, invocationInput, executionConnection, executionConnectionName) => {
|
|
3118
3227
|
if (node.kind !== 'block') {
|
|
3119
3228
|
throw new Error(`Certified ${node.kind} "${node.name}" is a navigation artifact and cannot be executed as a block.`);
|
|
3120
3229
|
}
|
|
3121
|
-
return executeCertifiedBlockByNameForAgent(node.name || node.nodeId.replace(/^block:/, ''), invocationInput, false, executionConnection);
|
|
3230
|
+
return executeCertifiedBlockByNameForAgent(node.name || node.nodeId.replace(/^block:/, ''), invocationInput, false, executionConnection, executionConnectionName);
|
|
3122
3231
|
};
|
|
3123
3232
|
// Secondary agent surfaces use this compatibility callback. Delegate to the
|
|
3124
3233
|
// same artifact-first path as Ask so research/app/notebook execution cannot
|
|
3125
3234
|
// silently reintroduce a raw-SQL result followed by a different DQL artifact.
|
|
3126
3235
|
const executeGeneratedSqlForAgent = async (sql, artifact) => executeGeneratedArtifactForAgent(artifact?.name ?? 'AI-generated query', sql, artifact);
|
|
3127
|
-
const executeArtifactReferenceForAgent = async (artifact, question, executionConnection) => {
|
|
3236
|
+
const executeArtifactReferenceForAgent = async (artifact, question, executionConnection, executionConnectionName) => {
|
|
3128
3237
|
const invocation = prepareBlockInvocation({
|
|
3129
3238
|
source: artifact.source,
|
|
3130
3239
|
parameters: artifact.parameterValues,
|
|
@@ -3139,7 +3248,7 @@ export async function startLocalServer(opts) {
|
|
|
3139
3248
|
question,
|
|
3140
3249
|
parameters: invocation.values,
|
|
3141
3250
|
rowLimit: artifact.limit ?? 200,
|
|
3142
|
-
}, executionConnection);
|
|
3251
|
+
}, executionConnection, executionConnectionName);
|
|
3143
3252
|
return {
|
|
3144
3253
|
...result,
|
|
3145
3254
|
dqlArtifact: {
|
|
@@ -3151,9 +3260,123 @@ export async function startLocalServer(opts) {
|
|
|
3151
3260
|
},
|
|
3152
3261
|
};
|
|
3153
3262
|
};
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3263
|
+
/**
|
|
3264
|
+
* Build the reusable DQL artifact for an answer that HAS ALREADY EXECUTED,
|
|
3265
|
+
* and check that it recompiles.
|
|
3266
|
+
*
|
|
3267
|
+
* AGT-010 requires the attached artifact to be the one used for execution, so
|
|
3268
|
+
* that "Apply" cannot diverge from what Ask displayed. The old code satisfied
|
|
3269
|
+
* that invariant backwards: it made EXECUTION follow the artifact, routing the
|
|
3270
|
+
* model's SQL through `parameterizeSqlForDqlImport` → `candidateToDqlSource` →
|
|
3271
|
+
* the DQL parser → `processSQL` before running anything. Every one of those
|
|
3272
|
+
* stages could fail on SQL the warehouse would have accepted, and each failure
|
|
3273
|
+
* destroyed the whole answer.
|
|
3274
|
+
*
|
|
3275
|
+
* Now the executed string is the source of truth and the artifact is derived
|
|
3276
|
+
* from it. Building the artifact is a SAVE concern, so a failure here degrades
|
|
3277
|
+
* the artifact to review-required and never throws: the user still gets their
|
|
3278
|
+
* answer, and only "Save as block" is affected.
|
|
3279
|
+
*/
|
|
3280
|
+
const buildVerifiedGeneratedArtifact = (question, sql, executedSql, seed, warnings, resultColumns = [], dialect = 'duckdb') => {
|
|
3281
|
+
// Which result columns the user may promote to a filter input. Decided here
|
|
3282
|
+
// because it needs a real dialect parse of the SQL that actually ran.
|
|
3283
|
+
const filterableColumns = resultColumns.length > 0
|
|
3284
|
+
? filterableResultColumns(executedSql, resultColumns, dialect)
|
|
3285
|
+
: [];
|
|
3286
|
+
const withCandidates = (artifact) => (filterableColumns.length > 0 ? { ...artifact, filterableColumns } : artifact);
|
|
3287
|
+
let artifact;
|
|
3288
|
+
try {
|
|
3289
|
+
artifact = generatedSqlArtifactContract(question, sql, seed);
|
|
3290
|
+
}
|
|
3291
|
+
catch (error) {
|
|
3292
|
+
warnings.push(`This answer could not be captured as a reusable DQL block: ${apiErrorMessage(error)}`);
|
|
3293
|
+
return undefined;
|
|
3294
|
+
}
|
|
3295
|
+
try {
|
|
3296
|
+
const invocation = prepareBlockInvocation({ source: artifact.source, question, surface: 'ask_ai' });
|
|
3297
|
+
const divergence = invocation.errors.length > 0
|
|
3298
|
+
? invocation.errors.join(' ')
|
|
3299
|
+
: invocation.unresolvedParameters.length > 0
|
|
3300
|
+
? `it still needs values for ${invocation.unresolvedParameters.join(', ')}`
|
|
3301
|
+
: undefined;
|
|
3302
|
+
if (divergence) {
|
|
3303
|
+
warnings.push(`The reusable DQL block for this answer needs review before reuse: ${divergence}`);
|
|
3304
|
+
// Keep the declared parameter contract even when the block cannot run
|
|
3305
|
+
// as-is. This branch is precisely "it still needs values for X", so the
|
|
3306
|
+
// parameter-input surface needs the list in order to ASK for them —
|
|
3307
|
+
// dropping it would leave the user a review-required block with no way
|
|
3308
|
+
// to see, or supply, what it wants.
|
|
3309
|
+
return withCandidates({
|
|
3310
|
+
...artifact,
|
|
3311
|
+
trustState: 'review_required',
|
|
3312
|
+
compiledSql: executedSql,
|
|
3313
|
+
...(invocation.parameters.length > 0 ? { parameters: invocation.parameters } : {}),
|
|
3314
|
+
...(Object.keys(invocation.values).length > 0 ? { parameterValues: invocation.values } : {}),
|
|
3315
|
+
});
|
|
3316
|
+
}
|
|
3317
|
+
return withCandidates({
|
|
3318
|
+
...artifact,
|
|
3319
|
+
compiledSql: executedSql,
|
|
3320
|
+
...(invocation.parameters.length > 0 ? { parameters: invocation.parameters } : {}),
|
|
3321
|
+
...(Object.keys(invocation.values).length > 0 ? { parameterValues: invocation.values } : {}),
|
|
3322
|
+
});
|
|
3323
|
+
}
|
|
3324
|
+
catch (error) {
|
|
3325
|
+
warnings.push(`The reusable DQL block for this answer needs review before reuse: ${apiErrorMessage(error)}`);
|
|
3326
|
+
return withCandidates({ ...artifact, trustState: 'review_required', compiledSql: executedSql });
|
|
3327
|
+
}
|
|
3328
|
+
};
|
|
3329
|
+
/**
|
|
3330
|
+
* Execute the model's SQL EXACTLY as validated — the same way a notebook cell
|
|
3331
|
+
* runs it. This is the fix for "the query Ask generated fails in Ask but runs
|
|
3332
|
+
* in the notebook": both paths now reach `executor.executeQuery` with the same
|
|
3333
|
+
* string, so the only remaining reasons to differ are the connection and the
|
|
3334
|
+
* governance gates, both of which report themselves honestly.
|
|
3335
|
+
*/
|
|
3336
|
+
const executeGeneratedSqlDirect = async (question, sql, seed, executionConnection) => {
|
|
3337
|
+
const activeConnection = requireActiveConnection(executionConnection);
|
|
3338
|
+
const rowBound = clampAnalyticalRowBound(seed?.limit ?? 200);
|
|
3339
|
+
const trimmed = sql.trim().replace(/;\s*$/, '').trim();
|
|
3340
|
+
if (!trimmed)
|
|
3341
|
+
throw analyticalError('The generated SQL was empty.', { origin: 'host', stage: 'execute' });
|
|
3342
|
+
const readOnlyError = readOnlySqlValidationError(trimmed, 'Generated SQL', activeConnection.driver);
|
|
3343
|
+
if (readOnlyError) {
|
|
3344
|
+
throw analyticalError(readOnlyError, { origin: 'governance_gate', stage: 'validation', code: 'unsafe_sql' });
|
|
3345
|
+
}
|
|
3346
|
+
const prepared = prepareLocalExecution(trimmed, activeConnection, projectRoot, projectConfig);
|
|
3347
|
+
const bounded = buildRowBoundedSql(prepared.sql, rowBound, activeConnection.driver);
|
|
3348
|
+
const app = loadRuntimeApp(projectRoot, activePersonaAppId());
|
|
3349
|
+
const sourceDomain = seed?.source?.match(/\bdomain\s*=\s*"([^"]+)"/i)?.[1];
|
|
3350
|
+
assertAppAccess({ app, domain: sourceDomain ?? app?.domain, level: 'execute' });
|
|
3351
|
+
const rawResult = await executor.executeQuery(bounded.sql, [], runtimeVariables({}), prepared.connection);
|
|
3352
|
+
const normalized = normalizeQueryResult(rawResult);
|
|
3353
|
+
// The authoritative bound. `buildRowBoundedSql` may legitimately decline to
|
|
3354
|
+
// touch the statement, and only one of 15 drivers honours a maxRows hint, so
|
|
3355
|
+
// the host is the only layer correctness may depend on.
|
|
3356
|
+
const truncated = normalized.rows.length > rowBound;
|
|
3357
|
+
const rows = truncated ? normalized.rows.slice(0, rowBound) : normalized.rows;
|
|
3358
|
+
const warnings = [];
|
|
3359
|
+
const artifact = buildVerifiedGeneratedArtifact(question, trimmed, bounded.sql, seed, warnings, normalized.columns, activeConnection.driver);
|
|
3360
|
+
return {
|
|
3361
|
+
columns: normalized.columns,
|
|
3362
|
+
rows,
|
|
3363
|
+
rowCount: rows.length,
|
|
3364
|
+
executionTime: normalized.executionTime,
|
|
3365
|
+
sql: bounded.sql,
|
|
3366
|
+
...(truncated ? { truncated: true } : {}),
|
|
3367
|
+
...(artifact ? { dqlArtifact: { ...artifact, limit: rowBound } } : {}),
|
|
3368
|
+
...(warnings.length > 0 ? { validationWarnings: warnings } : {}),
|
|
3369
|
+
executionReceipt: createDqlArtifactExecutionReceipt(artifact?.source ?? trimmed, bounded.sql, {}, { columns: normalized.columns, rows, rowCount: rows.length }),
|
|
3370
|
+
};
|
|
3371
|
+
};
|
|
3372
|
+
const executeGeneratedArtifactForAgent = async (question, sql, seed, executionConnection, executionConnectionName) => {
|
|
3373
|
+
// A seed that is already a certified/saved artifact keeps the DQL-first
|
|
3374
|
+
// path: there the `.dql` source IS the contract, and its parameters and
|
|
3375
|
+
// semantic refs must be compiled, not bypassed.
|
|
3376
|
+
if (seed && seed.kind !== 'sql_block') {
|
|
3377
|
+
return executeArtifactReferenceForAgent({ ...seed, limit: seed.limit ?? 200 }, question, executionConnection, executionConnectionName);
|
|
3378
|
+
}
|
|
3379
|
+
return executeGeneratedSqlDirect(question, sql, seed, executionConnection);
|
|
3157
3380
|
};
|
|
3158
3381
|
/**
|
|
3159
3382
|
* EXP-001: execution host for the deliberately narrow non-governed lane.
|
|
@@ -3262,6 +3485,60 @@ export async function startLocalServer(opts) {
|
|
|
3262
3485
|
return { proofs, sql: boundedSql, repairs, error: error instanceof Error ? error.message : String(error) };
|
|
3263
3486
|
}
|
|
3264
3487
|
};
|
|
3488
|
+
/**
|
|
3489
|
+
* Ask the warehouse whether specific named relations exist, AFTER the cached
|
|
3490
|
+
* catalog has already missed them.
|
|
3491
|
+
*
|
|
3492
|
+
* `getSchemaContextForAgent` deliberately returns nothing on a lexical miss —
|
|
3493
|
+
* "a lexical miss is not permission to rescan every visible schema". That is
|
|
3494
|
+
* right for BULK retrieval, but it also meant a relation the model named
|
|
3495
|
+
* explicitly, and that the notebook queries without complaint, was refused as
|
|
3496
|
+
* "outside the inspected metadata context" without the warehouse ever being
|
|
3497
|
+
* asked. A point lookup of up to four named `(schema, table)` pairs is not a
|
|
3498
|
+
* rescan.
|
|
3499
|
+
*
|
|
3500
|
+
* The payoff is honesty as much as recall: after this probe, a miss is
|
|
3501
|
+
* evidence. The refusal can say the relation is not visible to this
|
|
3502
|
+
* connection, rather than only that DQL had not looked at it.
|
|
3503
|
+
*/
|
|
3504
|
+
const probeNamedRelationsForAgent = async (relations, probeConnection) => {
|
|
3505
|
+
const probeTarget = probeConnection ?? connection;
|
|
3506
|
+
if (!probeTarget)
|
|
3507
|
+
return undefined;
|
|
3508
|
+
// Same operator switch that governs live value grounding: a deployment that
|
|
3509
|
+
// wants zero live metadata traffic keeps it.
|
|
3510
|
+
if (resolveAgentRuntimeValueGrounding(projectConfig).mode !== 'safe_automatic')
|
|
3511
|
+
return undefined;
|
|
3512
|
+
const sql = buildNamedRelationProbeSql(relations);
|
|
3513
|
+
if (!sql)
|
|
3514
|
+
return undefined;
|
|
3515
|
+
const prepared = prepareLocalExecution(sql, probeTarget, projectRoot, projectConfig);
|
|
3516
|
+
const result = await executor.executeQuery(prepared.sql, [], runtimeVariables({}), prepared.connection);
|
|
3517
|
+
const rows = normalizeQueryResult(result).rows;
|
|
3518
|
+
if (rows.length === 0)
|
|
3519
|
+
return undefined;
|
|
3520
|
+
const tables = buildAgentSchemaContext('', rows, { includeUnscored: true, limit: 8 });
|
|
3521
|
+
if (tables.length === 0)
|
|
3522
|
+
return undefined;
|
|
3523
|
+
return {
|
|
3524
|
+
relations: tables.map((table) => ({
|
|
3525
|
+
relation: table.relation,
|
|
3526
|
+
name: table.name,
|
|
3527
|
+
// Provenance is explicit: these columns came from a live lookup, not
|
|
3528
|
+
// from the immutable snapshot.
|
|
3529
|
+
source: 'scoped live probe',
|
|
3530
|
+
columns: table.columns.map((column) => ({ name: column.name, type: column.type })),
|
|
3531
|
+
columnCompleteness: 'complete',
|
|
3532
|
+
})),
|
|
3533
|
+
schemaContext: tables.map((table) => ({
|
|
3534
|
+
relation: table.relation,
|
|
3535
|
+
schema: table.schema,
|
|
3536
|
+
name: table.name,
|
|
3537
|
+
columns: table.columns,
|
|
3538
|
+
})),
|
|
3539
|
+
notes: tables.map((table) => `${table.relation} resolved by a scoped live probe`),
|
|
3540
|
+
};
|
|
3541
|
+
};
|
|
3265
3542
|
const getSchemaContextForAgent = async (question, preparedContextPack, executionConnection, executionConnectionName) => {
|
|
3266
3543
|
const schemaConnection = executionConnection ?? connection;
|
|
3267
3544
|
const scanRuntimeSchema = async () => {
|
|
@@ -3277,7 +3554,7 @@ export async function startLocalServer(opts) {
|
|
|
3277
3554
|
const activeScope = schemaConnection
|
|
3278
3555
|
? optionalActiveConnectionMetadataScope(projectRoot, projectConfig, schemaConnection, executionConnectionName ?? projectConfig.defaultConnectionName ?? 'default')
|
|
3279
3556
|
: null;
|
|
3280
|
-
const activeRuntimeSnapshot = latestRuntimeSchemaSnapshotForProject(projectRoot);
|
|
3557
|
+
const activeRuntimeSnapshot = latestRuntimeSchemaSnapshotForProject(projectRoot, executionConnectionName);
|
|
3281
3558
|
const hasActivatedMetadataGeneration = Boolean(activeScope
|
|
3282
3559
|
&& activeRuntimeSnapshot?.scopeFingerprint === activeScope.scopeFingerprint
|
|
3283
3560
|
&& activeRuntimeSnapshot.status !== 'stale');
|
|
@@ -3289,9 +3566,6 @@ export async function startLocalServer(opts) {
|
|
|
3289
3566
|
// row value must not turn a warm Ask into an information_schema scan over
|
|
3290
3567
|
// thousands of enterprise tables; only field-scoped value probes are live.
|
|
3291
3568
|
const enriched = await enrichAgentSchemaContextWithValueMatches(question, catalogContext, executor, schemaConnection, valueGrounding.searchSafeColumns);
|
|
3292
|
-
if (!hasActivatedMetadataGeneration) {
|
|
3293
|
-
recordAgentRuntimeSchemaSnapshot(projectRoot, catalogContext, 'catalog runtime schema');
|
|
3294
|
-
}
|
|
3295
3569
|
return enriched;
|
|
3296
3570
|
}
|
|
3297
3571
|
// An activated, scope-qualified warehouse generation is the complete warm
|
|
@@ -3307,7 +3581,7 @@ export async function startLocalServer(opts) {
|
|
|
3307
3581
|
const enriched = valueGrounding.mode === 'safe_automatic'
|
|
3308
3582
|
? await enrichAgentSchemaContextWithValueMatches(question, schemaContext.ranked, executor, schemaConnection, valueGrounding.searchSafeColumns)
|
|
3309
3583
|
: schemaContext.ranked;
|
|
3310
|
-
recordAgentRuntimeSchemaSnapshot(projectRoot, schemaContext.snapshot, 'question-scoped information_schema search');
|
|
3584
|
+
recordAgentRuntimeSchemaSnapshot(projectRoot, schemaContext.snapshot, 'question-scoped information_schema search', executionConnectionName);
|
|
3311
3585
|
return enriched;
|
|
3312
3586
|
}
|
|
3313
3587
|
catch {
|
|
@@ -6519,6 +6793,12 @@ export async function startLocalServer(opts) {
|
|
|
6519
6793
|
res.end(serializeJSON({ ok: true }));
|
|
6520
6794
|
return;
|
|
6521
6795
|
}
|
|
6796
|
+
if (req.method === 'DELETE' && !action) {
|
|
6797
|
+
store.deleteThread(threadId);
|
|
6798
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6799
|
+
res.end(serializeJSON({ ok: true }));
|
|
6800
|
+
return;
|
|
6801
|
+
}
|
|
6522
6802
|
// Explicit promotion — the ONLY path from conversation into durable
|
|
6523
6803
|
// memory ("remember this"). Deliberate user act, tagged for audit.
|
|
6524
6804
|
if (req.method === 'POST' && action === 'promote') {
|
|
@@ -9884,7 +10164,7 @@ export async function startLocalServer(opts) {
|
|
|
9884
10164
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
9885
10165
|
res.end(serializeJSON({
|
|
9886
10166
|
scope,
|
|
9887
|
-
status: warehouseMetadataStatus(projectRoot, scope.scopeFingerprint),
|
|
10167
|
+
status: warehouseMetadataStatus(projectRoot, scope.scopeFingerprint, connectionId),
|
|
9888
10168
|
}));
|
|
9889
10169
|
}
|
|
9890
10170
|
catch (error) {
|
|
@@ -9904,7 +10184,7 @@ export async function startLocalServer(opts) {
|
|
|
9904
10184
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
9905
10185
|
res.end(serializeJSON({
|
|
9906
10186
|
scope,
|
|
9907
|
-
status: warehouseMetadataStatus(projectRoot, scope.scopeFingerprint),
|
|
10187
|
+
status: warehouseMetadataStatus(projectRoot, scope.scopeFingerprint, connectionId),
|
|
9908
10188
|
}));
|
|
9909
10189
|
}
|
|
9910
10190
|
catch (error) {
|
|
@@ -9933,7 +10213,7 @@ export async function startLocalServer(opts) {
|
|
|
9933
10213
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
9934
10214
|
res.end(serializeJSON({
|
|
9935
10215
|
scope: synced.scope,
|
|
9936
|
-
status: warehouseMetadataStatus(projectRoot, synced.scope.scopeFingerprint),
|
|
10216
|
+
status: warehouseMetadataStatus(projectRoot, synced.scope.scopeFingerprint, connectionId),
|
|
9937
10217
|
}));
|
|
9938
10218
|
}
|
|
9939
10219
|
catch (error) {
|
|
@@ -9959,7 +10239,7 @@ export async function startLocalServer(opts) {
|
|
|
9959
10239
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
9960
10240
|
res.end(serializeJSON({
|
|
9961
10241
|
scope: synced.scope,
|
|
9962
|
-
status: warehouseMetadataStatus(projectRoot, synced.scope.scopeFingerprint),
|
|
10242
|
+
status: warehouseMetadataStatus(projectRoot, synced.scope.scopeFingerprint, connectionId),
|
|
9963
10243
|
}));
|
|
9964
10244
|
}
|
|
9965
10245
|
catch (error) {
|
|
@@ -9996,7 +10276,7 @@ export async function startLocalServer(opts) {
|
|
|
9996
10276
|
connectorStatus,
|
|
9997
10277
|
metadataScope,
|
|
9998
10278
|
metadataStatus: metadataScope
|
|
9999
|
-
? warehouseMetadataStatus(projectRoot, metadataScope.scopeFingerprint)
|
|
10279
|
+
? warehouseMetadataStatus(projectRoot, metadataScope.scopeFingerprint, defaultKey)
|
|
10000
10280
|
: warehouseMetadataStatus(projectRoot),
|
|
10001
10281
|
}));
|
|
10002
10282
|
return;
|
|
@@ -12107,6 +12387,60 @@ export async function startLocalServer(opts) {
|
|
|
12107
12387
|
res.end(readFileSync(filePath));
|
|
12108
12388
|
return;
|
|
12109
12389
|
}
|
|
12390
|
+
/**
|
|
12391
|
+
* Promote a RESULT COLUMN into a runtime filter input on a generated answer.
|
|
12392
|
+
*
|
|
12393
|
+
* The SQL edit happens here, not in the browser: deciding where a predicate
|
|
12394
|
+
* may go needs a real dialect parse, and shipping a SQL parser to the client
|
|
12395
|
+
* to guess at it is exactly how a filter ends up silently attached to the
|
|
12396
|
+
* wrong query. The client sends a column; it gets back an artifact.
|
|
12397
|
+
*/
|
|
12398
|
+
if (req.method === 'POST' && path === '/api/dql/artifacts/add-filter') {
|
|
12399
|
+
try {
|
|
12400
|
+
const body = await readJSON(req);
|
|
12401
|
+
const artifact = normalizeDqlArtifactReference(body.artifact);
|
|
12402
|
+
const column = typeof body.column === 'string' ? body.column.trim() : '';
|
|
12403
|
+
const value = body.value;
|
|
12404
|
+
const resultColumns = Array.isArray(body.resultColumns)
|
|
12405
|
+
? body.resultColumns.filter((entry) => typeof entry === 'string')
|
|
12406
|
+
: [];
|
|
12407
|
+
if (!artifact)
|
|
12408
|
+
throw new Error('A DQL artifact is required to add a filter input.');
|
|
12409
|
+
if (!column)
|
|
12410
|
+
throw new Error('Choose a result column to filter on.');
|
|
12411
|
+
if (value === undefined || value === null || String(value).trim() === '') {
|
|
12412
|
+
throw new Error('Choose a default filter value before adding the input.');
|
|
12413
|
+
}
|
|
12414
|
+
const sql = extractBlockStudioSql(artifact.source);
|
|
12415
|
+
if (!sql)
|
|
12416
|
+
throw new Error('This answer has no editable SQL to filter.');
|
|
12417
|
+
const activeConnection = requireActiveConnection();
|
|
12418
|
+
const existingNames = (artifact.parameters ?? []).map((parameter) => parameter.name);
|
|
12419
|
+
const added = addSqlResultFilter(sql, resultColumns, column, existingNames, activeConnection.driver);
|
|
12420
|
+
const source = replaceBlockStudioSql(artifact.source, added.sql, added.parameterName, value);
|
|
12421
|
+
// Re-parse so the returned contract is what the block ACTUALLY declares,
|
|
12422
|
+
// rather than what this endpoint believes it wrote.
|
|
12423
|
+
const invocation = prepareBlockInvocation({ source, surface: 'ask_ai' });
|
|
12424
|
+
if (invocation.errors.length > 0)
|
|
12425
|
+
throw new Error(invocation.errors.join(' '));
|
|
12426
|
+
const { executionReceipt: _receipt, compiledSql: _compiled, ...unexecuted } = artifact;
|
|
12427
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
12428
|
+
res.end(serializeJSON({
|
|
12429
|
+
artifact: {
|
|
12430
|
+
...unexecuted,
|
|
12431
|
+
source,
|
|
12432
|
+
parameters: invocation.parameters,
|
|
12433
|
+
parameterValues: { ...(artifact.parameterValues ?? {}), [added.parameterName]: value },
|
|
12434
|
+
},
|
|
12435
|
+
parameterName: added.parameterName,
|
|
12436
|
+
}));
|
|
12437
|
+
}
|
|
12438
|
+
catch (error) {
|
|
12439
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
12440
|
+
res.end(serializeJSON({ error: apiErrorMessage(error) }));
|
|
12441
|
+
}
|
|
12442
|
+
return;
|
|
12443
|
+
}
|
|
12110
12444
|
if (req.method === 'POST' && path === '/api/dql/artifacts/execute') {
|
|
12111
12445
|
try {
|
|
12112
12446
|
const body = await readJSON(req);
|
|
@@ -12524,7 +12858,11 @@ table: ${table}${tagList}
|
|
|
12524
12858
|
return;
|
|
12525
12859
|
}
|
|
12526
12860
|
const content = readFileSync(filePath);
|
|
12527
|
-
res.writeHead(200, {
|
|
12861
|
+
res.writeHead(200, {
|
|
12862
|
+
'Content-Type': contentTypeFor(filePath),
|
|
12863
|
+
'Cache-Control': staticResponseCacheControl(filePath),
|
|
12864
|
+
'Content-Length': content.byteLength,
|
|
12865
|
+
});
|
|
12528
12866
|
res.end(content);
|
|
12529
12867
|
})().catch((error) => {
|
|
12530
12868
|
// API-007/E2E-014: no rejected request promise may terminate the local
|
|
@@ -13480,10 +13818,10 @@ export function compileBlockStudioManifest(projectRoot, projectConfig = loadProj
|
|
|
13480
13818
|
_lineageCache.delete(projectRoot);
|
|
13481
13819
|
return manifest;
|
|
13482
13820
|
}
|
|
13483
|
-
async function refreshLocalMetadataCatalog(projectRoot, manifest, semanticLayer) {
|
|
13821
|
+
async function refreshLocalMetadataCatalog(projectRoot, manifest, semanticLayer, force = true) {
|
|
13484
13822
|
try {
|
|
13485
13823
|
await ensureMetadataCatalogFresh(projectRoot, {
|
|
13486
|
-
force
|
|
13824
|
+
force,
|
|
13487
13825
|
...(manifest ? { manifest } : {}),
|
|
13488
13826
|
...(semanticLayer ? { semanticLayer } : {}),
|
|
13489
13827
|
});
|
|
@@ -15061,18 +15399,80 @@ const AGENT_PREVIEW_FORBIDDEN_SQL = [
|
|
|
15061
15399
|
'update',
|
|
15062
15400
|
'vacuum',
|
|
15063
15401
|
];
|
|
15402
|
+
/**
|
|
15403
|
+
* Run DQL's own block-compiler machinery with an honest origin. Anything these
|
|
15404
|
+
* stages throw is a DQL-artifact failure, never a warehouse failure.
|
|
15405
|
+
*/
|
|
15406
|
+
function withAnalyticalCompilationOrigin(stage, fn) {
|
|
15407
|
+
return withAnalyticalErrorOriginSync({ origin: 'dql_compilation', stage }, fn);
|
|
15408
|
+
}
|
|
15409
|
+
export function clampAnalyticalRowBound(rowLimit) {
|
|
15410
|
+
return Number.isFinite(rowLimit) ? Math.max(1, Math.min(10_000, Math.floor(rowLimit))) : 200;
|
|
15411
|
+
}
|
|
15412
|
+
/**
|
|
15413
|
+
* Dialects where a bare trailing `LIMIT n` is valid on a top-level SELECT.
|
|
15414
|
+
* MSSQL/Fabric need `TOP`/`OFFSET…FETCH` in positions that depend on the rest
|
|
15415
|
+
* of the statement, so we never rewrite there — the host-side truncation below
|
|
15416
|
+
* is the guarantee, and appending is only an optimization.
|
|
15417
|
+
*/
|
|
15418
|
+
const TRAILING_LIMIT_DIALECTS = new Set([
|
|
15419
|
+
'duckdb', 'postgres', 'postgresql', 'redshift', 'snowflake', 'bigquery',
|
|
15420
|
+
'databricks', 'trino', 'presto', 'clickhouse', 'mysql', 'mariadb', 'sqlite', 'athena',
|
|
15421
|
+
]);
|
|
15422
|
+
/**
|
|
15423
|
+
* Bound a result set WITHOUT rewriting the statement's shape.
|
|
15424
|
+
*
|
|
15425
|
+
* The previous approach wrapped every Ask query as
|
|
15426
|
+
* `SELECT * FROM (<sql>) AS dql_agent_preview LIMIT n`. That is not a no-op:
|
|
15427
|
+
* - `SELECT * FROM (WITH x AS (…) SELECT …) AS t` is a syntax error on
|
|
15428
|
+
* MSSQL/Fabric, and the model emits CTEs constantly;
|
|
15429
|
+
* - duplicate output column names are legal at top level but not as a
|
|
15430
|
+
* derived table on several engines;
|
|
15431
|
+
* - the inner `ORDER BY` stops being guaranteed, silently breaking top-N.
|
|
15432
|
+
* All three produced "fails in Ask, runs in the notebook" for identical SQL.
|
|
15433
|
+
*
|
|
15434
|
+
* Appending is skipped whenever it cannot be proven safe. Callers must still
|
|
15435
|
+
* truncate rows host-side — that, not this, is what actually enforces the bound.
|
|
15436
|
+
*/
|
|
15437
|
+
export function buildRowBoundedSql(sql, rowLimit, dialect = 'duckdb') {
|
|
15438
|
+
const trimmed = sql.trim().replace(/;\s*$/, '').trim();
|
|
15439
|
+
if (!rowLimit)
|
|
15440
|
+
return { sql: trimmed, outcome: 'skipped', reason: 'no row bound requested' };
|
|
15441
|
+
if (!TRAILING_LIMIT_DIALECTS.has(dialect.toLowerCase())) {
|
|
15442
|
+
return { sql: trimmed, outcome: 'skipped', reason: `trailing LIMIT is not portable on ${dialect}` };
|
|
15443
|
+
}
|
|
15444
|
+
const analysis = analyzeSqlReferences(trimmed, dialect);
|
|
15445
|
+
if (!analysis.parsed) {
|
|
15446
|
+
return { sql: trimmed, outcome: 'skipped', reason: 'statement did not parse; not rewriting it' };
|
|
15447
|
+
}
|
|
15448
|
+
const statements = analysis.statementTypes ?? [];
|
|
15449
|
+
if (statements.length !== 1 || statements[0]?.toLowerCase() !== 'select') {
|
|
15450
|
+
return { sql: trimmed, outcome: 'skipped', reason: 'not a single SELECT statement' };
|
|
15451
|
+
}
|
|
15452
|
+
// Any existing bound — LIMIT, FETCH FIRST/NEXT, TOP — wins. Detection is
|
|
15453
|
+
// deliberately greedy: a false positive costs nothing (host truncation still
|
|
15454
|
+
// applies), a false negative would override the author's own bound.
|
|
15455
|
+
const scan = stripSqlStringsAndComments(trimmed);
|
|
15456
|
+
if (/\blimit\b/i.test(scan) || /\bfetch\s+(first|next)\b/i.test(scan) || /\btop\s*\(?\s*\d/i.test(scan)) {
|
|
15457
|
+
return { sql: trimmed, outcome: 'existing', reason: 'statement already carries its own bound' };
|
|
15458
|
+
}
|
|
15459
|
+
return { sql: `${trimmed}\nLIMIT ${clampAnalyticalRowBound(rowLimit)}`, outcome: 'appended' };
|
|
15460
|
+
}
|
|
15461
|
+
/**
|
|
15462
|
+
* @deprecated Kept for one release so any out-of-tree caller keeps working.
|
|
15463
|
+
* Use {@link executeAnalyticalSql}, which bounds rows without wrapping.
|
|
15464
|
+
*/
|
|
15064
15465
|
export function buildAgentPreviewSql(sql, rowLimit = 200) {
|
|
15065
15466
|
const trimmed = sql.trim();
|
|
15066
15467
|
if (!trimmed)
|
|
15067
|
-
throw
|
|
15068
|
-
const boundedRowLimit = Number.isFinite(rowLimit)
|
|
15069
|
-
? Math.max(1, Math.min(10_000, Math.floor(rowLimit)))
|
|
15070
|
-
: 200;
|
|
15468
|
+
throw analyticalError('Generated SQL preview is empty.', { origin: 'host', stage: 'execute' });
|
|
15071
15469
|
const withoutTrailingSemicolon = trimmed.replace(/;\s*$/, '').trim();
|
|
15072
15470
|
const readOnlyError = readOnlySqlValidationError(withoutTrailingSemicolon, 'Generated SQL preview');
|
|
15073
|
-
|
|
15074
|
-
|
|
15075
|
-
|
|
15471
|
+
// A deliberate read-only refusal, not a warehouse rejection.
|
|
15472
|
+
if (readOnlyError) {
|
|
15473
|
+
throw analyticalError(readOnlyError, { origin: 'governance_gate', stage: 'validation', code: 'unsafe_sql' });
|
|
15474
|
+
}
|
|
15475
|
+
return buildRowBoundedSql(withoutTrailingSemicolon, rowLimit).sql;
|
|
15076
15476
|
}
|
|
15077
15477
|
/**
|
|
15078
15478
|
* EXP-001 preflight for model-authored exploratory SQL.
|
|
@@ -15460,7 +15860,7 @@ function summarizeExploratoryJoinProbe(rows, leftRelation, leftColumn, rightRela
|
|
|
15460
15860
|
};
|
|
15461
15861
|
return `${leftRelation}.${leftColumn} ↔ ${rightRelation}.${rightColumn}: sample left=${number('left_sample_rows')}, right=${number('right_sample_rows')}, joined=${number('joined_rows')}, unmatched left/right=${number('unmatched_left_sample_rows')}/${number('unmatched_right_sample_rows')}, max matches left/right=${number('max_matches_per_left_key')}/${number('max_matches_per_right_key')}`;
|
|
15462
15862
|
}
|
|
15463
|
-
function readOnlySqlValidationError(sql, subject) {
|
|
15863
|
+
function readOnlySqlValidationError(sql, subject, dialect = 'duckdb') {
|
|
15464
15864
|
const internalRelationError = internalDqlRelationIdValidationError(sql, subject);
|
|
15465
15865
|
if (internalRelationError)
|
|
15466
15866
|
return internalRelationError;
|
|
@@ -15471,6 +15871,22 @@ function readOnlySqlValidationError(sql, subject) {
|
|
|
15471
15871
|
if (scanSql.includes(';')) {
|
|
15472
15872
|
return `${subject} only supports one statement.`;
|
|
15473
15873
|
}
|
|
15874
|
+
// Prefer the parser. The keyword blacklist matches IDENTIFIERS, so a CTE or
|
|
15875
|
+
// column alias legitimately named `set`, `load`, `merge`, or `copy` was
|
|
15876
|
+
// rejected as if it were DDL — a false failure on SQL the notebook runs fine.
|
|
15877
|
+
// The blacklist stays as the conservative fallback for statements the dialect
|
|
15878
|
+
// parser cannot read.
|
|
15879
|
+
const analysis = analyzeSqlReferences(scanSql, dialect);
|
|
15880
|
+
if (analysis.parsed) {
|
|
15881
|
+
const statements = analysis.statementTypes ?? [];
|
|
15882
|
+
const offending = statements.find((statement) => statement.toLowerCase() !== 'select');
|
|
15883
|
+
if (offending) {
|
|
15884
|
+
return `${subject} rejected unsupported statement keyword: ${offending.toUpperCase()}.`;
|
|
15885
|
+
}
|
|
15886
|
+
if (statements.length > 1)
|
|
15887
|
+
return `${subject} only supports one statement.`;
|
|
15888
|
+
return null;
|
|
15889
|
+
}
|
|
15474
15890
|
const forbiddenPattern = new RegExp(`\\b(${AGENT_PREVIEW_FORBIDDEN_SQL.join('|')})\\b`, 'i');
|
|
15475
15891
|
const forbidden = scanSql.match(forbiddenPattern)?.[1];
|
|
15476
15892
|
if (forbidden) {
|
|
@@ -15691,6 +16107,16 @@ function contentTypeFor(filePath) {
|
|
|
15691
16107
|
return 'text/plain; charset=utf-8';
|
|
15692
16108
|
}
|
|
15693
16109
|
}
|
|
16110
|
+
export function staticResponseCacheControl(filePath) {
|
|
16111
|
+
// Vite fingerprints every generated asset filename, so a new DQL build gets
|
|
16112
|
+
// a new URL and the previous file is safe to retain indefinitely. Keep the
|
|
16113
|
+
// HTML shell revalidated so it can point at the current asset hashes.
|
|
16114
|
+
if (extname(filePath).toLowerCase() === '.html')
|
|
16115
|
+
return 'no-cache';
|
|
16116
|
+
return filePath.split(sep).includes('assets')
|
|
16117
|
+
? 'public, max-age=31536000, immutable'
|
|
16118
|
+
: 'no-cache';
|
|
16119
|
+
}
|
|
15694
16120
|
function listProjectFiles(projectRoot) {
|
|
15695
16121
|
const allowed = new Set(['.dql', '.sql', '.md', '.json', '.csv', '.yaml', '.yml', '.dqlnb']);
|
|
15696
16122
|
const files = [];
|
|
@@ -16132,11 +16558,11 @@ function parseSemanticBlockConfig(source) {
|
|
|
16132
16558
|
limit: limitMatch ? Number.parseInt(limitMatch[1], 10) : undefined,
|
|
16133
16559
|
};
|
|
16134
16560
|
}
|
|
16135
|
-
export async function resolveSemanticTableMapping(executor, connection, semanticLayer, projectRoot) {
|
|
16561
|
+
export async function resolveSemanticTableMapping(executor, connection, semanticLayer, projectRoot, connectionId) {
|
|
16136
16562
|
if (!semanticLayer)
|
|
16137
16563
|
return undefined;
|
|
16138
16564
|
if (projectRoot) {
|
|
16139
|
-
const snapshot = latestRuntimeSchemaSnapshotForProject(projectRoot);
|
|
16565
|
+
const snapshot = latestRuntimeSchemaSnapshotForProject(projectRoot, connectionId);
|
|
16140
16566
|
if (snapshot?.scopeFingerprint && snapshot.tables.length) {
|
|
16141
16567
|
return buildSemanticTableMapping(semanticLayer, snapshot.tables.map((table) => ({
|
|
16142
16568
|
table_catalog: table.catalogOrDatabase,
|
|
@@ -16158,16 +16584,30 @@ export async function resolveSemanticTableMapping(executor, connection, semantic
|
|
|
16158
16584
|
}
|
|
16159
16585
|
}
|
|
16160
16586
|
export function buildSemanticTableMapping(semanticLayer, rows) {
|
|
16161
|
-
const
|
|
16162
|
-
|
|
16587
|
+
const normalizeIdentifierPart = (value) => value
|
|
16588
|
+
.trim()
|
|
16589
|
+
.replace(/^["'`\[]|["'`\]]$/g, '')
|
|
16590
|
+
.toLowerCase();
|
|
16591
|
+
const normalizeRelation = (value) => value
|
|
16592
|
+
.split('.')
|
|
16593
|
+
.map(normalizeIdentifierPart)
|
|
16594
|
+
.filter(Boolean)
|
|
16595
|
+
.join('.');
|
|
16596
|
+
const candidates = [];
|
|
16163
16597
|
for (const row of rows) {
|
|
16598
|
+
const catalog = String(row['table_catalog'] ?? '');
|
|
16164
16599
|
const schema = String(row['table_schema'] ?? '');
|
|
16165
16600
|
const name = String(row['table_name'] ?? '');
|
|
16166
|
-
const relation = typeof row['table_relation'] === 'string'
|
|
16601
|
+
const relation = typeof row['table_relation'] === 'string' && row['table_relation'].trim()
|
|
16602
|
+
? row['table_relation']
|
|
16603
|
+
: [catalog, schema, name].filter(Boolean).join('.');
|
|
16167
16604
|
if (!name)
|
|
16168
16605
|
continue;
|
|
16169
|
-
|
|
16170
|
-
|
|
16606
|
+
candidates.push({
|
|
16607
|
+
name: normalizeIdentifierPart(name),
|
|
16608
|
+
relation,
|
|
16609
|
+
normalizedRelation: normalizeRelation(relation),
|
|
16610
|
+
});
|
|
16171
16611
|
}
|
|
16172
16612
|
const tableMapping = {};
|
|
16173
16613
|
const allSemanticTables = new Set();
|
|
@@ -16182,9 +16622,19 @@ export function buildSemanticTableMapping(semanticLayer, rows) {
|
|
|
16182
16622
|
for (const semTable of allSemanticTables) {
|
|
16183
16623
|
if (!semTable)
|
|
16184
16624
|
continue;
|
|
16185
|
-
|
|
16186
|
-
|
|
16187
|
-
|
|
16625
|
+
const normalizedSemantic = normalizeRelation(semTable);
|
|
16626
|
+
const semanticLeaf = normalizedSemantic.split('.').at(-1);
|
|
16627
|
+
if (!semanticLeaf)
|
|
16628
|
+
continue;
|
|
16629
|
+
const qualifiedMatches = candidates.filter((candidate) => candidate.normalizedRelation === normalizedSemantic
|
|
16630
|
+
|| candidate.normalizedRelation.endsWith(`.${normalizedSemantic}`));
|
|
16631
|
+
const leafMatches = candidates.filter((candidate) => candidate.name === semanticLeaf);
|
|
16632
|
+
const matches = qualifiedMatches.length > 0 ? qualifiedMatches : leafMatches;
|
|
16633
|
+
// Never guess when the same semantic leaf exists in several schemas. A
|
|
16634
|
+
// qualified semantic relation can disambiguate; otherwise the semantic
|
|
16635
|
+
// runtime must surface the modeling gap instead of executing the wrong one.
|
|
16636
|
+
if (matches.length === 1)
|
|
16637
|
+
tableMapping[semTable] = matches[0].relation;
|
|
16188
16638
|
}
|
|
16189
16639
|
return Object.keys(tableMapping).length > 0 ? tableMapping : undefined;
|
|
16190
16640
|
}
|
|
@@ -18738,7 +19188,7 @@ function agentResultToSynthesisPreview(result) {
|
|
|
18738
19188
|
}
|
|
18739
19189
|
/** Up to three example questions built from real catalog blocks (falls back to generic asks). */
|
|
18740
19190
|
function buildConversationSuggestions(projectRoot, kind) {
|
|
18741
|
-
if (kind === 'gratitude')
|
|
19191
|
+
if (kind === 'gratitude' || kind === 'answer_explanation')
|
|
18742
19192
|
return [];
|
|
18743
19193
|
let names = [];
|
|
18744
19194
|
try {
|
|
@@ -18834,9 +19284,9 @@ export function buildConversationContextRecap(context) {
|
|
|
18834
19284
|
return undefined;
|
|
18835
19285
|
return `We were talking about ${contextBits.join('. ')}.`;
|
|
18836
19286
|
}
|
|
18837
|
-
function
|
|
19287
|
+
function latestSubstantiveConversationTurn(context) {
|
|
18838
19288
|
const turns = Array.isArray(context.turns)
|
|
18839
|
-
? context.turns.map(agentRunRecord).filter((turn) => Boolean(turn)).slice(-
|
|
19289
|
+
? context.turns.map(agentRunRecord).filter((turn) => Boolean(turn)).slice(-6)
|
|
18840
19290
|
: [];
|
|
18841
19291
|
if (turns.length === 0)
|
|
18842
19292
|
return undefined;
|
|
@@ -18848,9 +19298,120 @@ function buildConversationTurnsRecap(context) {
|
|
|
18848
19298
|
// subject of the next recap. Prefer the newest analytical/authoring turn and
|
|
18849
19299
|
// fall back to the active turn only when the thread contains conversation alone.
|
|
18850
19300
|
const substantiveTurns = turns.filter((turn) => agentRunString(turn.route) !== 'conversation');
|
|
18851
|
-
|
|
19301
|
+
return active && agentRunString(active.route) !== 'conversation'
|
|
18852
19302
|
? active
|
|
18853
19303
|
: substantiveTurns[substantiveTurns.length - 1] ?? active ?? turns[turns.length - 1];
|
|
19304
|
+
}
|
|
19305
|
+
/**
|
|
19306
|
+
* Explain the latest successful answer from its persisted artifact contract.
|
|
19307
|
+
* This is intentionally deterministic and never executes SQL: missing evidence
|
|
19308
|
+
* is reported as missing instead of being guessed or converted into a new metric.
|
|
19309
|
+
*/
|
|
19310
|
+
export function buildPriorAnswerExplanation(question, context) {
|
|
19311
|
+
if (!context)
|
|
19312
|
+
return undefined;
|
|
19313
|
+
const selected = latestSubstantiveConversationTurn(context);
|
|
19314
|
+
const artifact = agentRunRecord(selected?.dqlArtifact) ?? agentRunRecord(context.dqlArtifact);
|
|
19315
|
+
const result = agentRunRecord(selected?.result);
|
|
19316
|
+
const metrics = agentRunStringArray(artifact?.metrics
|
|
19317
|
+
?? selected?.requestedMeasures
|
|
19318
|
+
?? context.priorMeasures);
|
|
19319
|
+
const dimensions = agentRunStringArray(artifact?.dimensions
|
|
19320
|
+
?? selected?.requestedDimensions
|
|
19321
|
+
?? context.requestedDimensions);
|
|
19322
|
+
const timeDimension = agentRunRecord(artifact?.timeDimension);
|
|
19323
|
+
const timeName = agentRunString(timeDimension?.name);
|
|
19324
|
+
const granularity = agentRunString(timeDimension?.granularity);
|
|
19325
|
+
const filters = formatPriorAnswerFilters(artifact?.filters, selected?.requestedFilters ?? context.requestedFilters);
|
|
19326
|
+
const topN = typeof selected?.topN === 'number'
|
|
19327
|
+
? selected.topN
|
|
19328
|
+
: typeof context.priorLimit === 'number'
|
|
19329
|
+
? context.priorLimit
|
|
19330
|
+
: undefined;
|
|
19331
|
+
const source = agentRunString(artifact?.name)
|
|
19332
|
+
?? agentRunString(selected?.sourceCertifiedBlock)
|
|
19333
|
+
?? agentRunString(context.sourceCertifiedBlock);
|
|
19334
|
+
const columns = agentRunStringArray(result?.columns ?? context.resultColumns).slice(0, 8);
|
|
19335
|
+
const metricText = metrics.length > 0 ? metrics.map(humanizeArtifactField).join(', ') : undefined;
|
|
19336
|
+
const dimensionText = dimensions.length > 0 ? dimensions.map(humanizeArtifactField).join(', ') : undefined;
|
|
19337
|
+
const normalizedQuestion = question.toLowerCase();
|
|
19338
|
+
if (/\b(?:daily|monthly|weekly|quarterly|yearly|annual|grain|period|timeframe|date range)\b/.test(normalizedQuestion)) {
|
|
19339
|
+
if (granularity) {
|
|
19340
|
+
return [
|
|
19341
|
+
`The previous result uses${metricText ? ` ${metricText}` : ' the recorded metric'} and is grouped`
|
|
19342
|
+
+ `${timeName ? ` by ${humanizeArtifactField(timeName)}` : ''} at a ${formatTimeGranularity(granularity)} grain.`,
|
|
19343
|
+
filters.length > 0 ? `Applied filters: ${filters.join('; ')}.` : '',
|
|
19344
|
+
].filter(Boolean).join(' ');
|
|
19345
|
+
}
|
|
19346
|
+
return [
|
|
19347
|
+
`The previous result does not declare a daily or monthly time grain${metricText ? ` for ${metricText}` : ''}.`,
|
|
19348
|
+
dimensionText ? `Its recorded grouping is ${dimensionText}.` : 'It is an aggregate across the applied result scope.',
|
|
19349
|
+
filters.length > 0 ? `Applied filters: ${filters.join('; ')}.` : '',
|
|
19350
|
+
'If you want a new monthly or daily breakdown, ask me to show or group it that way.',
|
|
19351
|
+
].filter(Boolean).join(' ');
|
|
19352
|
+
}
|
|
19353
|
+
if (/\b(?:which|what)\s+(?:metric|measure)\b|\bmetric\s+(?:did|does|was|is)\b/.test(normalizedQuestion)) {
|
|
19354
|
+
return metricText
|
|
19355
|
+
? `The previous answer used ${metricText}${source ? ` from ${humanizeArtifactField(source)}` : ''}.`
|
|
19356
|
+
: 'The previous answer did not retain a qualified metric identifier, so I cannot name one without rerunning or reviewing its DQL.';
|
|
19357
|
+
}
|
|
19358
|
+
if (/\bfilter|include|exclude\b/.test(normalizedQuestion)) {
|
|
19359
|
+
return filters.length > 0
|
|
19360
|
+
? `The previous answer applied these filters: ${filters.join('; ')}.`
|
|
19361
|
+
: 'The previous artifact does not record any explicit filters.';
|
|
19362
|
+
}
|
|
19363
|
+
const facts = [
|
|
19364
|
+
metricText ? `Metric: ${metricText}.` : '',
|
|
19365
|
+
dimensionText ? `Grouped by: ${dimensionText}.` : '',
|
|
19366
|
+
granularity ? `Time grain: ${formatTimeGranularity(granularity)}${timeName ? ` on ${humanizeArtifactField(timeName)}` : ''}.` : '',
|
|
19367
|
+
filters.length > 0 ? `Filters: ${filters.join('; ')}.` : '',
|
|
19368
|
+
topN ? `Limit: top ${topN}.` : '',
|
|
19369
|
+
source ? `Source: ${humanizeArtifactField(source)}.` : '',
|
|
19370
|
+
columns.length > 0 ? `Returned fields: ${columns.map(humanizeArtifactField).join(', ')}.` : '',
|
|
19371
|
+
].filter(Boolean);
|
|
19372
|
+
return facts.length > 0
|
|
19373
|
+
? facts.join(' ')
|
|
19374
|
+
: 'The previous answer did not retain enough artifact metadata to explain its metric, filters, or grain safely.';
|
|
19375
|
+
}
|
|
19376
|
+
function formatPriorAnswerFilters(primary, fallback) {
|
|
19377
|
+
const fromArtifact = Array.isArray(primary)
|
|
19378
|
+
? primary.map(agentRunRecord).filter((filter) => Boolean(filter)).flatMap((filter) => {
|
|
19379
|
+
const dimension = agentRunString(filter.dimension);
|
|
19380
|
+
const operator = agentRunString(filter.operator) ?? 'equals';
|
|
19381
|
+
const values = agentRunStringArray(filter.values);
|
|
19382
|
+
return dimension && values.length > 0
|
|
19383
|
+
? [`${humanizeArtifactField(dimension)} ${operator.replace(/_/g, ' ')} ${values.join(', ')}`]
|
|
19384
|
+
: [];
|
|
19385
|
+
})
|
|
19386
|
+
: [];
|
|
19387
|
+
return fromArtifact.length > 0
|
|
19388
|
+
? fromArtifact
|
|
19389
|
+
: agentRunStringArray(fallback).map((value) => value.replace(/_/g, ' '));
|
|
19390
|
+
}
|
|
19391
|
+
function humanizeArtifactField(value) {
|
|
19392
|
+
return value
|
|
19393
|
+
.split('.')
|
|
19394
|
+
.at(-1)
|
|
19395
|
+
.replace(/::/g, ' ')
|
|
19396
|
+
.replace(/[_-]+/g, ' ')
|
|
19397
|
+
.replace(/\s+/g, ' ')
|
|
19398
|
+
.trim();
|
|
19399
|
+
}
|
|
19400
|
+
function formatTimeGranularity(value) {
|
|
19401
|
+
const normalized = value.trim().toLowerCase();
|
|
19402
|
+
return {
|
|
19403
|
+
day: 'daily',
|
|
19404
|
+
week: 'weekly',
|
|
19405
|
+
month: 'monthly',
|
|
19406
|
+
quarter: 'quarterly',
|
|
19407
|
+
year: 'yearly',
|
|
19408
|
+
}[normalized] ?? humanizeArtifactField(value);
|
|
19409
|
+
}
|
|
19410
|
+
function buildConversationTurnsRecap(context) {
|
|
19411
|
+
const turns = Array.isArray(context.turns)
|
|
19412
|
+
? context.turns.map(agentRunRecord).filter((turn) => Boolean(turn)).slice(-3)
|
|
19413
|
+
: [];
|
|
19414
|
+
const selected = latestSubstantiveConversationTurn(context);
|
|
18854
19415
|
if (!selected)
|
|
18855
19416
|
return undefined;
|
|
18856
19417
|
const result = agentRunRecord(selected.result);
|
|
@@ -21433,11 +21994,36 @@ export function runtimeSnapshotStale(projectRoot, maxAgeMs = RUNTIME_SNAPSHOT_MA
|
|
|
21433
21994
|
return false;
|
|
21434
21995
|
}
|
|
21435
21996
|
}
|
|
21436
|
-
|
|
21997
|
+
/**
|
|
21998
|
+
* Resolve only metadata captured for the connection executing this Ask run.
|
|
21999
|
+
* Returning an explicit empty snapshot is a correctness boundary: callers pass
|
|
22000
|
+
* it into the context builder so it cannot silently fall back to another
|
|
22001
|
+
* connection's project-global runtime FTS rows.
|
|
22002
|
+
*/
|
|
22003
|
+
export function runtimeSchemaSnapshotForAgentConnection(projectRoot, connectionId, expectedScopeFingerprint) {
|
|
22004
|
+
const snapshot = latestRuntimeSchemaSnapshotForProject(projectRoot, connectionId);
|
|
22005
|
+
const current = Boolean(snapshot
|
|
22006
|
+
&& snapshot.status !== 'stale'
|
|
22007
|
+
&& (!expectedScopeFingerprint
|
|
22008
|
+
|| !snapshot.scopeFingerprint
|
|
22009
|
+
|| snapshot.scopeFingerprint === expectedScopeFingerprint));
|
|
22010
|
+
return current
|
|
22011
|
+
? snapshot
|
|
22012
|
+
: {
|
|
22013
|
+
version: 1,
|
|
22014
|
+
connectionId,
|
|
22015
|
+
status: 'partial',
|
|
22016
|
+
source: 'selected connection has no current runtime metadata',
|
|
22017
|
+
tables: [],
|
|
22018
|
+
};
|
|
22019
|
+
}
|
|
22020
|
+
function recordAgentRuntimeSchemaSnapshot(projectRoot, schemaContext, source, connectionId) {
|
|
21437
22021
|
if (schemaContext.length === 0)
|
|
21438
22022
|
return;
|
|
21439
22023
|
try {
|
|
21440
22024
|
recordRuntimeSchemaSnapshot(projectRoot, {
|
|
22025
|
+
...(connectionId ? { connectionId } : {}),
|
|
22026
|
+
status: 'partial',
|
|
21441
22027
|
source,
|
|
21442
22028
|
tables: schemaContext.map((table) => ({
|
|
21443
22029
|
relation: table.relation,
|
|
@@ -22332,6 +22918,51 @@ const RUNTIME_SCHEMA_SEARCH_STOPWORDS = new Set([
|
|
|
22332
22918
|
* execution target. Tokens are reduced to safe identifier characters before
|
|
22333
22919
|
* interpolation, so user text cannot become SQL syntax.
|
|
22334
22920
|
*/
|
|
22921
|
+
/**
|
|
22922
|
+
* Point-lookup of NAMED relations in `information_schema.columns`.
|
|
22923
|
+
*
|
|
22924
|
+
* This is deliberately not `buildRuntimeSchemaSearchSql`. That one matches
|
|
22925
|
+
* `LOWER(table_name) LIKE '%term%'` across every visible schema, which is the
|
|
22926
|
+
* unbounded rescan the "a lexical miss is not permission to rescan" policy
|
|
22927
|
+
* exists to prevent. This one asks about specific `(schema, table)` pairs the
|
|
22928
|
+
* model actually referenced, with equality predicates — a metadata point
|
|
22929
|
+
* lookup, not a scan.
|
|
22930
|
+
*
|
|
22931
|
+
* Returns null when nothing safe to ask about survives normalization.
|
|
22932
|
+
*/
|
|
22933
|
+
export function buildNamedRelationProbeSql(relations, maxRelations = 4) {
|
|
22934
|
+
const predicates = [];
|
|
22935
|
+
const seen = new Set();
|
|
22936
|
+
for (const raw of relations) {
|
|
22937
|
+
const parts = raw.replace(/["`\[\]]/g, '').trim().split('.').filter(Boolean);
|
|
22938
|
+
const table = parts.at(-1);
|
|
22939
|
+
// Identifiers only — never interpolate anything that could carry SQL.
|
|
22940
|
+
if (!table || !/^[A-Za-z_][A-Za-z0-9_$]*$/.test(table))
|
|
22941
|
+
continue;
|
|
22942
|
+
const schema = parts.length >= 2 ? parts.at(-2) : undefined;
|
|
22943
|
+
if (schema && !/^[A-Za-z_][A-Za-z0-9_$]*$/.test(schema))
|
|
22944
|
+
continue;
|
|
22945
|
+
const key = `${schema ?? ''}.${table}`.toLowerCase();
|
|
22946
|
+
if (seen.has(key))
|
|
22947
|
+
continue;
|
|
22948
|
+
seen.add(key);
|
|
22949
|
+
predicates.push(schema
|
|
22950
|
+
? `(LOWER(table_schema) = '${schema.toLowerCase()}' AND LOWER(table_name) = '${table.toLowerCase()}')`
|
|
22951
|
+
: `(LOWER(table_name) = '${table.toLowerCase()}')`);
|
|
22952
|
+
if (predicates.length >= maxRelations)
|
|
22953
|
+
break;
|
|
22954
|
+
}
|
|
22955
|
+
if (predicates.length === 0)
|
|
22956
|
+
return null;
|
|
22957
|
+
return `SELECT table_schema, table_name, column_name, data_type
|
|
22958
|
+
FROM information_schema.columns
|
|
22959
|
+
WHERE UPPER(table_schema) NOT IN ('INFORMATION_SCHEMA', 'PG_CATALOG')
|
|
22960
|
+
AND (
|
|
22961
|
+
${predicates.map((predicate) => ` ${predicate}`).join(' OR\n')}
|
|
22962
|
+
)
|
|
22963
|
+
ORDER BY table_schema, table_name, ordinal_position
|
|
22964
|
+
LIMIT 400`;
|
|
22965
|
+
}
|
|
22335
22966
|
export function buildRuntimeSchemaSearchSql(question) {
|
|
22336
22967
|
const terms = Array.from(agentSchemaTokens(question))
|
|
22337
22968
|
.map((term) => term.toLowerCase().replace(/[^a-z0-9_]/g, ''))
|