@duckcodeailabs/dql-cli 1.13.1 → 1.13.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.
- package/dist/apps-api.d.ts +249 -7
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +2855 -160
- package/dist/apps-api.js.map +1 -1
- package/dist/assets/dql-notebook/assets/{AgentLogPage-D6fgEAEy.js → AgentLogPage-B3ugEb3H.js} +1 -1
- package/dist/assets/dql-notebook/assets/{AiBuildDialog-8miXFPse.js → AiBuildDialog-BK_TDNcM.js} +1 -1
- package/dist/assets/dql-notebook/assets/{AiBuildResult-DPJ_SI0R.js → AiBuildResult-Cob_Lj05.js} +8 -8
- package/dist/assets/dql-notebook/assets/{AiSidePanel-NVA6lZnn.js → AiSidePanel-CzIgxmP9.js} +1 -1
- package/dist/assets/dql-notebook/assets/AnalyticsHome-C2vJ2fxY.js +6 -0
- package/dist/assets/dql-notebook/assets/AppsView-DEWQ0sHT.js +5588 -0
- package/dist/assets/dql-notebook/assets/BlockStudio-CaDNTKMM.js +55 -0
- package/dist/assets/dql-notebook/assets/{BusinessArtifactView-DtRMiaF4.js → BusinessArtifactView-zwUOtddi.js} +1 -1
- package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-BlnhTMu3.js +22 -0
- package/dist/assets/dql-notebook/assets/{GitPage-CazC-eTR.js → GitPage-Coevr9NJ.js} +3 -3
- package/dist/assets/dql-notebook/assets/GlobalAiRail-BTZS11x5.js +1 -0
- package/dist/assets/dql-notebook/assets/GovernedContextPage-CjyyM_ne.js +7 -0
- package/dist/assets/dql-notebook/assets/{HelpDocsPage-DMHPsVqP.js → HelpDocsPage-C1UeP85v.js} +1 -1
- package/dist/assets/dql-notebook/assets/{HomePage-DcSI4dnU.js → HomePage-zGPFIvOY.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDAG-BJ6xz06u.js → LineageDAG-DbXnQWu3.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDetailView-8WrQm-MF.js → LineageDetailView-Ch1Db01I.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDrawer-hnqCW_t-.js → LineageDrawer-DLxqJO6D.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-B4LZTUsl.js → LineagePathBreadcrumb-B2tNdkr_.js} +3 -3
- package/dist/assets/dql-notebook/assets/{MiniLineageGraph-CotnRuxa.js → MiniLineageGraph-0gY-ZwCy.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NewBlockModal-DHn-CEEW.js → NewBlockModal-DKVkXD20.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NewNotebookModal-BPGp-8oq.js → NewNotebookModal-BUIByvYT.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NotebookEditor-B0OTHM6W.js → NotebookEditor-D2fIUxjE.js} +5 -5
- package/dist/assets/dql-notebook/assets/{ReadinessPage-CoOIpxWq.js → ReadinessPage-Ci4LyNiQ.js} +1 -1
- package/dist/assets/dql-notebook/assets/{SetupOnboarding-DxK_mFzf.js → SetupOnboarding-BF7ai_0t.js} +1 -1
- package/dist/assets/dql-notebook/assets/SkillsPage-BgYrgirJ.js +10 -0
- package/dist/assets/dql-notebook/assets/{TrustBadge-ZuD5N5Ww.js → TrustBadge-Bob1Laiq.js} +1 -1
- package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-IxDUKSG8.js +88 -0
- package/dist/assets/dql-notebook/assets/{answer-to-notebook-WAVAYjBv.js → answer-to-notebook-Dk42N0pU.js} +1 -1
- package/dist/assets/dql-notebook/assets/{arrow-left-L8odLbyN.js → arrow-left-DKuuTw9l.js} +1 -1
- package/dist/assets/dql-notebook/assets/{arrow-right-WIF1V9a9.js → arrow-right-TH8_ufKG.js} +1 -1
- package/dist/assets/dql-notebook/assets/{book-open-text-BpqWMa4m.js → book-open-text-B-dcHHn0.js} +1 -1
- package/dist/assets/dql-notebook/assets/{circle-x-CQBOM0b0.js → circle-x-BOc2KCZQ.js} +1 -1
- package/dist/assets/dql-notebook/assets/{dagre.esm-DlZi7NR3.js → dagre.esm-CPT_vn5p.js} +1 -1
- package/dist/assets/dql-notebook/assets/{external-link-DYqdQIGp.js → external-link-DtOfpYc6.js} +1 -1
- package/dist/assets/dql-notebook/assets/{grip-vertical-HmUijbwN.js → grip-vertical-B8joDDH9.js} +1 -1
- package/dist/assets/dql-notebook/assets/index-B3shyZsg.css +1 -0
- package/dist/assets/dql-notebook/assets/index-DJRMz64S.js +635 -0
- package/dist/assets/dql-notebook/assets/link-2-DMZPHawg.js +6 -0
- package/dist/assets/dql-notebook/assets/{list-tree-DGKqCxmz.js → list-tree-Dw0qlxPK.js} +1 -1
- package/dist/assets/dql-notebook/assets/{minimize-2-C8dvqCxX.js → minimize-2-DUiSrPS3.js} +1 -1
- package/dist/assets/dql-notebook/assets/{panel-right-open-yuVkIo5n.js → panel-right-open-BrrFrRZQ.js} +1 -1
- package/dist/assets/dql-notebook/assets/{bot-CLR1V6dO.js → play-Ced0HT9g.js} +7 -2
- package/dist/assets/dql-notebook/assets/{rotate-ccw-Di0kRnrM.js → rotate-ccw-D_9U9o7X.js} +1 -1
- package/dist/assets/dql-notebook/assets/{semantic-fields-BMQV0li8.js → semantic-fields-BlDOaonV.js} +1 -1
- package/dist/assets/dql-notebook/assets/{sliders-horizontal-alebptlJ.js → sliders-horizontal-BDt6KRGR.js} +1 -1
- package/dist/assets/dql-notebook/assets/{star-CgCQbnXc.js → star-CxUbF_BW.js} +1 -1
- package/dist/assets/dql-notebook/assets/{triangle-alert-SyuSZ4ht.js → triangle-alert-COWnsSdh.js} +1 -1
- package/dist/assets/dql-notebook/assets/upload-DSFC2kJX.js +6 -0
- package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-DYdx896E.js +32 -0
- package/dist/assets/dql-notebook/assets/{user-round-J4PQHxo9.js → user-round-Bfu7fosn.js} +1 -1
- package/dist/assets/dql-notebook/assets/{wand-sparkles-c4WDXTCk.js → wand-sparkles-DSpG0c0j.js} +1 -1
- package/dist/assets/dql-notebook/assets/{workflow-NtRLaq7n.js → workflow-B1_pj9gq.js} +1 -1
- package/dist/assets/dql-notebook/assets/{wrench-CS1f1Zv4.js → wrench-CvfM03KZ.js} +1 -1
- package/dist/assets/dql-notebook/assets/{x-CbKEfe5m.js → x-B4coLiOl.js} +1 -1
- package/dist/assets/dql-notebook/index.html +2 -2
- package/dist/commands/app.d.ts.map +1 -1
- package/dist/commands/app.js +65 -133
- package/dist/commands/app.js.map +1 -1
- package/dist/commands/model.js +21 -2
- package/dist/commands/model.js.map +1 -1
- package/dist/llm/providers/dql-agent-provider.d.ts +16 -0
- package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
- package/dist/llm/providers/dql-agent-provider.js +65 -2
- package/dist/llm/providers/dql-agent-provider.js.map +1 -1
- package/dist/local-runtime.d.ts +112 -19
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +2026 -694
- package/dist/local-runtime.js.map +1 -1
- package/dist/package.json +10 -10
- package/dist/sql-result-filter.d.ts +9 -0
- package/dist/sql-result-filter.d.ts.map +1 -1
- package/dist/sql-result-filter.js +25 -0
- package/dist/sql-result-filter.js.map +1 -1
- package/package.json +10 -10
- package/dist/assets/dql-notebook/assets/AgentAnswerCard-c8QUo6NH.js +0 -5
- package/dist/assets/dql-notebook/assets/AnalyticsHome-DgwnRW71.js +0 -6
- package/dist/assets/dql-notebook/assets/AppsView-B6LYUpgm.js +0 -4578
- package/dist/assets/dql-notebook/assets/BlockStudio--jNw9XUI.js +0 -60
- package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-DhBsLCYr.js +0 -22
- package/dist/assets/dql-notebook/assets/GlobalAiRail-1Tgweuu2.js +0 -1
- package/dist/assets/dql-notebook/assets/GovernedContextPage-C7qGrH3d.js +0 -16
- package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-duFXXNV_.js +0 -88
- package/dist/assets/dql-notebook/assets/index-Cpx47jCH.js +0 -634
- package/dist/assets/dql-notebook/assets/index-DKu4CZt3.css +0 -1
- package/dist/assets/dql-notebook/assets/upload-BPhfea_E.js +0 -11
- package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Bnl9gWhj.js +0 -28
package/dist/local-runtime.js
CHANGED
|
@@ -15,7 +15,7 @@ import { buildMixedSourceWarehouseFallbackSql, findMentionedNotebookDataset, pla
|
|
|
15
15
|
import { resolveNpmInvocation } from './npm-runtime.js';
|
|
16
16
|
import { ensureDqlGitignore, isGovernedSourceFile, isLegacyBroadDqlIgnore, } from './git-contract.js';
|
|
17
17
|
import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, hasStandaloneSemanticRef, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
|
|
18
|
-
import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, resolveRepoSource, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, previewModelingChanges, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, normalizeAnalyticalFailureV1, relationshipValidationProofFingerprint, renderSemanticBlockSource, discoverDbtDomains, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, semanticDimensionReference, } from '@duckcodeailabs/dql-core';
|
|
18
|
+
import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, resolveRepoSource, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, previewModelingChanges, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, normalizeAnalyticalFailureV1, relationshipValidationProofFingerprint, renderSemanticBlockSource, discoverDbtDomains, collectDbtRelationshipTests, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, semanticDimensionReference, modelAreaLocalId, DEFAULT_MODEL_AREA_ID, } from '@duckcodeailabs/dql-core';
|
|
19
19
|
import { load as loadYaml } from 'js-yaml';
|
|
20
20
|
import { listBlockTemplates } from './block-templates.js';
|
|
21
21
|
import { getRunner as getLLMRunner } from './llm/index.js';
|
|
@@ -25,7 +25,7 @@ import { resolveRetrievalHealthStatus } from './retrieval-health.js';
|
|
|
25
25
|
import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
|
|
26
26
|
import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
|
|
27
27
|
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, normalizeWarehouseSqlFailure, parseProposal, 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, previewSkillChange, buildContextAuthoringProposal, contextAuthoringDependencyClosure, FileContextAuthoringProposalStore, 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, resolveInternalRelationIds, analyticalError, tagAnalyticalError, withAnalyticalErrorOrigin, withAnalyticalErrorOriginSync, } from '@duckcodeailabs/dql-agent';
|
|
28
|
-
import { addSqlResultFilter, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
|
|
28
|
+
import { addSqlResultFilter, dashboardFilterableResultColumns, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
|
|
29
29
|
import { gatherProposeEnrichment } from './propose-enrich.js';
|
|
30
30
|
import { handleAppsApi, proposeAppAiBuild, recommendVisualization, } from './apps-api.js';
|
|
31
31
|
import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
|
|
@@ -87,7 +87,16 @@ function hasDbtSemanticArtifacts(projectRoot, dbtProjectDir, configuredManifestP
|
|
|
87
87
|
}
|
|
88
88
|
return false;
|
|
89
89
|
}
|
|
90
|
-
|
|
90
|
+
// Every member of `AgentRunRequestedMode`. The `Record` (rather than a bare
|
|
91
|
+
// Set literal) is deliberate: a `Set<AgentRunRequestedMode>` happily accepts a
|
|
92
|
+
// subset, which is how 'modeling' and 'skill' went missing here — the parser
|
|
93
|
+
// dropped them to `undefined`, `selectRoute` never saw an explicit mode, and
|
|
94
|
+
// Modeling AI silently ran the governed answer loop instead of proposing a
|
|
95
|
+
// model. A Record makes an omission a compile error.
|
|
96
|
+
const AGENT_RUN_REQUESTED_MODE_MEMBERS = {
|
|
97
|
+
auto: true, ask: true, research: true, sql: true, block: true, app: true, modeling: true, skill: true,
|
|
98
|
+
};
|
|
99
|
+
const AGENT_RUN_REQUESTED_MODES = new Set(Object.keys(AGENT_RUN_REQUESTED_MODE_MEMBERS));
|
|
91
100
|
/** AGT-007 / AGT-022: App-specific context adapter into the uniform AgentRun. */
|
|
92
101
|
export function buildAppCopilotResearchAgentRequest(input) {
|
|
93
102
|
const memoOnly = input.mode === 'memo_only';
|
|
@@ -959,6 +968,36 @@ export async function startLocalServer(opts) {
|
|
|
959
968
|
// rather than treating compiled semantic SQL as unrelated generic SQL.
|
|
960
969
|
const compiledSemanticQueries = new Map();
|
|
961
970
|
const dashboardRunEvidence = new Map();
|
|
971
|
+
const loadAppBuildPreviewEvidence = (runId) => {
|
|
972
|
+
const memoryEvidence = dashboardRunEvidence.get(runId);
|
|
973
|
+
if (memoryEvidence && memoryEvidence.expiresAt >= Date.now())
|
|
974
|
+
return memoryEvidence;
|
|
975
|
+
if (memoryEvidence)
|
|
976
|
+
dashboardRunEvidence.delete(runId);
|
|
977
|
+
const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
978
|
+
try {
|
|
979
|
+
const persisted = storage.getAppPreviewEvidence(runId);
|
|
980
|
+
if (!persisted)
|
|
981
|
+
return null;
|
|
982
|
+
return {
|
|
983
|
+
appId: persisted.draftId,
|
|
984
|
+
dashboardId: persisted.dashboardId,
|
|
985
|
+
snapshotId: persisted.snapshotId,
|
|
986
|
+
filterFingerprint: persisted.filterFingerprint,
|
|
987
|
+
resultFingerprint: persisted.resultFingerprint,
|
|
988
|
+
personaFingerprint: persisted.personaFingerprint,
|
|
989
|
+
successfulTileIds: persisted.successfulTileIds,
|
|
990
|
+
semanticApprovalEligibleTileIds: persisted.semanticApprovalEligibleTileIds,
|
|
991
|
+
// Persisted receipts are bound again by draft revision, source
|
|
992
|
+
// fingerprints, filters and snapshot during preflight. They therefore
|
|
993
|
+
// survive a process restart without making result rows durable.
|
|
994
|
+
expiresAt: Number.POSITIVE_INFINITY,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
finally {
|
|
998
|
+
storage.close();
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
962
1001
|
const dbtNodeDetailCache = new Map();
|
|
963
1002
|
const onboardingJobs = new Map();
|
|
964
1003
|
const metricFlowInstaller = new ManagedMetricFlowInstaller(projectRoot);
|
|
@@ -1503,59 +1542,6 @@ export async function startLocalServer(opts) {
|
|
|
1503
1542
|
});
|
|
1504
1543
|
return { domainContext, contextPack };
|
|
1505
1544
|
};
|
|
1506
|
-
const buildAgentPromptArtifact = async (request, target, repair) => {
|
|
1507
|
-
try {
|
|
1508
|
-
await reindexProject(projectRoot, { kgPath: defaultKgPath(projectRoot) });
|
|
1509
|
-
}
|
|
1510
|
-
catch {
|
|
1511
|
-
// Best-effort: buildFromPrompt can still use any existing KG/cache state.
|
|
1512
|
-
}
|
|
1513
|
-
const skills = loadSkills(projectRoot).skills;
|
|
1514
|
-
// On a repair re-run, target the prior failure and (for blocks) revise in place.
|
|
1515
|
-
const isRepair = (repair?.attempt ?? 0) > 0 && Boolean(repair?.repairHint);
|
|
1516
|
-
const mode = target === 'block' && (isRepair || agentRunWorkspaceValue(request, 'mode') === 'edit')
|
|
1517
|
-
? 'edit'
|
|
1518
|
-
: 'create';
|
|
1519
|
-
const prompt = isRepair
|
|
1520
|
-
? `${request.question}\n\nFix the previous attempt: ${repair?.repairHint}`
|
|
1521
|
-
: request.question;
|
|
1522
|
-
const domain = target === 'block' ? agentRunWorkspaceValue(request, 'domain') : undefined;
|
|
1523
|
-
const modelAreaId = agentRunWorkspaceValue(request, 'modelAreaId');
|
|
1524
|
-
const purpose = agentRunWorkspaceValue(request, 'purpose');
|
|
1525
|
-
const { domainContext, contextPack } = await buildDirectBuildContext({
|
|
1526
|
-
question: prompt,
|
|
1527
|
-
domain,
|
|
1528
|
-
purpose,
|
|
1529
|
-
modelAreaId,
|
|
1530
|
-
surface: target === 'cell' ? 'notebook' : 'block',
|
|
1531
|
-
selectedContext: { target, mode },
|
|
1532
|
-
});
|
|
1533
|
-
return buildFromPrompt({
|
|
1534
|
-
projectRoot,
|
|
1535
|
-
prompt,
|
|
1536
|
-
context: {
|
|
1537
|
-
cellSql: agentRunWorkspaceValue(request, 'cellSql'),
|
|
1538
|
-
selection: agentRunWorkspaceValue(request, 'selection'),
|
|
1539
|
-
},
|
|
1540
|
-
target,
|
|
1541
|
-
mode,
|
|
1542
|
-
blockPath: target === 'block'
|
|
1543
|
-
? agentRunWorkspaceValue(request, 'blockPath')
|
|
1544
|
-
?? (request.selectedObject?.kind === 'block' && request.selectedObject.path?.endsWith('.dql')
|
|
1545
|
-
? request.selectedObject.path
|
|
1546
|
-
: undefined)
|
|
1547
|
-
: undefined,
|
|
1548
|
-
owner: agentRunWorkspaceValue(request, 'owner'),
|
|
1549
|
-
domain,
|
|
1550
|
-
modelAreaId,
|
|
1551
|
-
contextPack,
|
|
1552
|
-
domainContext,
|
|
1553
|
-
userId: agentRunWorkspaceValue(request, 'userId'),
|
|
1554
|
-
skills,
|
|
1555
|
-
dbtManifestPath: resolveDbtManifestPath(projectRoot, projectConfig),
|
|
1556
|
-
executionProbe: target === 'block' ? runBlockReflectionProbe : undefined,
|
|
1557
|
-
});
|
|
1558
|
-
};
|
|
1559
1545
|
const agentRunEvaluation = (id, label, passed, severity, message, evidence) => ({ id, label, passed, severity, message, evidence });
|
|
1560
1546
|
const agentRunArtifact = (kind, title, payload, ref, trustState = 'review_required') => ({
|
|
1561
1547
|
id: `${kind}:${Date.now()}`,
|
|
@@ -2591,6 +2577,171 @@ export async function startLocalServer(opts) {
|
|
|
2591
2577
|
};
|
|
2592
2578
|
};
|
|
2593
2579
|
const contextProposalStore = new FileContextAuthoringProposalStore(projectRoot);
|
|
2580
|
+
/**
|
|
2581
|
+
* UI-019: authoring must never dead-end on a Domain/Area that does not exist
|
|
2582
|
+
* yet. Every modeling operation still belongs to exactly one Domain and Area
|
|
2583
|
+
* (`11-context-authoring-and-ai.md`); the difference is that DQL now creates
|
|
2584
|
+
* the missing scope as part of the same reviewable proposal instead of
|
|
2585
|
+
* blocking the author until they create it by hand. The synthesized
|
|
2586
|
+
* operations are ordinary reviewable patches, so nothing is written until the
|
|
2587
|
+
* proposal is explicitly committed.
|
|
2588
|
+
*/
|
|
2589
|
+
const ensureAuthoringScopeOperations = (operations, manifest) => {
|
|
2590
|
+
const modeling = manifest.modeling;
|
|
2591
|
+
if (!modeling)
|
|
2592
|
+
return operations;
|
|
2593
|
+
const existingDomains = new Set(Object.keys(modeling.packages ?? {}));
|
|
2594
|
+
const existingAreas = new Set(Object.values(modeling.areas ?? {}).flatMap((area) => [`${area.domain}::${area.localId}`, `${area.domain}::${area.qualifiedId}`]));
|
|
2595
|
+
// A scope created earlier in this same batch counts as existing.
|
|
2596
|
+
for (const operation of operations) {
|
|
2597
|
+
if (operation.kind !== 'modeling_change')
|
|
2598
|
+
continue;
|
|
2599
|
+
if (operation.change.operation === 'upsert_domain')
|
|
2600
|
+
existingDomains.add(operation.change.value.id);
|
|
2601
|
+
if (operation.change.operation === 'upsert_area')
|
|
2602
|
+
existingAreas.add(`${operation.change.value.domain}::${operation.change.value.id}`);
|
|
2603
|
+
}
|
|
2604
|
+
const created = [];
|
|
2605
|
+
const domainOperationId = new Map();
|
|
2606
|
+
const areaOperationId = new Map();
|
|
2607
|
+
const defaultAreaName = manifest.dbtProvenance?.projectName
|
|
2608
|
+
? titleCaseAuthoring(manifest.dbtProvenance.projectName)
|
|
2609
|
+
: 'Core models';
|
|
2610
|
+
const withScope = operations.map((operation) => {
|
|
2611
|
+
if (operation.kind !== 'modeling_change')
|
|
2612
|
+
return operation;
|
|
2613
|
+
const change = operation.change;
|
|
2614
|
+
if (change.operation !== 'upsert_entity' && change.operation !== 'upsert_relationship')
|
|
2615
|
+
return operation;
|
|
2616
|
+
const domain = change.value.domain;
|
|
2617
|
+
if (!domain)
|
|
2618
|
+
return operation;
|
|
2619
|
+
const dependsOn = [...(operation.dependsOn ?? [])];
|
|
2620
|
+
if (!existingDomains.has(domain)) {
|
|
2621
|
+
let id = domainOperationId.get(domain);
|
|
2622
|
+
if (!id) {
|
|
2623
|
+
id = `scope:domain:${domain}`;
|
|
2624
|
+
domainOperationId.set(domain, id);
|
|
2625
|
+
created.push({
|
|
2626
|
+
id,
|
|
2627
|
+
kind: 'modeling_change',
|
|
2628
|
+
evidence: ['created automatically so the requested modeling scope exists'],
|
|
2629
|
+
change: { operation: 'upsert_domain', value: { id: domain, name: titleCaseAuthoring(domain) } },
|
|
2630
|
+
});
|
|
2631
|
+
}
|
|
2632
|
+
if (!dependsOn.includes(id))
|
|
2633
|
+
dependsOn.push(id);
|
|
2634
|
+
}
|
|
2635
|
+
const areaId = change.value.areaId;
|
|
2636
|
+
if (areaId && !existingAreas.has(`${domain}::${areaId}`)) {
|
|
2637
|
+
const key = `${domain}::${areaId}`;
|
|
2638
|
+
let id = areaOperationId.get(key);
|
|
2639
|
+
if (!id) {
|
|
2640
|
+
id = `scope:area:${domain}:${areaId}`;
|
|
2641
|
+
areaOperationId.set(key, id);
|
|
2642
|
+
created.push({
|
|
2643
|
+
id,
|
|
2644
|
+
kind: 'modeling_change',
|
|
2645
|
+
dependsOn: domainOperationId.has(domain) ? [domainOperationId.get(domain)] : [],
|
|
2646
|
+
evidence: ['created automatically so the requested modeling scope exists'],
|
|
2647
|
+
change: {
|
|
2648
|
+
operation: 'upsert_area',
|
|
2649
|
+
value: {
|
|
2650
|
+
id: areaId,
|
|
2651
|
+
domain,
|
|
2652
|
+
name: areaId === DEFAULT_MODEL_AREA_ID ? defaultAreaName : titleCaseAuthoring(areaId),
|
|
2653
|
+
description: 'Subject area created automatically when modeling started. Rename or split it at any time.',
|
|
2654
|
+
},
|
|
2655
|
+
},
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
if (!dependsOn.includes(id))
|
|
2659
|
+
dependsOn.push(id);
|
|
2660
|
+
}
|
|
2661
|
+
return dependsOn.length ? { ...operation, dependsOn } : operation;
|
|
2662
|
+
});
|
|
2663
|
+
return created.length ? [...created, ...withScope] : withScope;
|
|
2664
|
+
};
|
|
2665
|
+
/**
|
|
2666
|
+
* REL-004: when a batch binds dbt models, bring across the relationships dbt
|
|
2667
|
+
* already declares in its `relationships` tests. dbt states the exact key
|
|
2668
|
+
* pair and `dbt test` enforces it, so redrawing those edges by hand is pure
|
|
2669
|
+
* duplicated work.
|
|
2670
|
+
*
|
|
2671
|
+
* REL-001 is unchanged: a dbt test is evidence, not join authorization. Every
|
|
2672
|
+
* edge produced here is `status: 'draft'` with `fanout: 'unknown'` and no
|
|
2673
|
+
* validation receipt, so it can never prove an automatic join.
|
|
2674
|
+
*/
|
|
2675
|
+
const appendDbtDeclaredRelationships = (operations, manifest) => {
|
|
2676
|
+
const manifestPath = manifest.dbtProvenance?.manifestPath;
|
|
2677
|
+
if (!manifestPath || !existsSync(manifestPath))
|
|
2678
|
+
return operations;
|
|
2679
|
+
// Entities this batch creates, plus entities already bound in the project.
|
|
2680
|
+
const bound = new Map();
|
|
2681
|
+
for (const entity of Object.values(manifest.modeling?.entities ?? {})) {
|
|
2682
|
+
bound.set(entity.dbtUniqueId, { id: entity.localId, domain: entity.domain, areaId: modelAreaLocalId(entity.areaId) });
|
|
2683
|
+
}
|
|
2684
|
+
let batchBindings = 0;
|
|
2685
|
+
for (const operation of operations) {
|
|
2686
|
+
if (operation.kind !== 'modeling_change' || operation.change.operation !== 'upsert_entity')
|
|
2687
|
+
continue;
|
|
2688
|
+
const value = operation.change.value;
|
|
2689
|
+
bound.set(value.dbtModel, { id: value.id, domain: value.domain, areaId: value.areaId, operationId: operation.id });
|
|
2690
|
+
batchBindings += 1;
|
|
2691
|
+
}
|
|
2692
|
+
if (batchBindings === 0 || bound.size < 2)
|
|
2693
|
+
return operations;
|
|
2694
|
+
let rawManifest;
|
|
2695
|
+
try {
|
|
2696
|
+
rawManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
2697
|
+
}
|
|
2698
|
+
catch {
|
|
2699
|
+
return operations;
|
|
2700
|
+
}
|
|
2701
|
+
const existingRelationshipIds = new Set([
|
|
2702
|
+
...Object.values(manifest.modeling?.relationships ?? {}).map((relationship) => `${relationship.ownerDomain ?? ''}::${relationship.localId}`),
|
|
2703
|
+
...operations.flatMap((operation) => operation.kind === 'modeling_change' && operation.change.operation === 'upsert_relationship'
|
|
2704
|
+
? [`${operation.change.value.domain}::${operation.change.value.id}`]
|
|
2705
|
+
: []),
|
|
2706
|
+
]);
|
|
2707
|
+
const harvested = [];
|
|
2708
|
+
for (const edge of collectDbtRelationshipTests(rawManifest, new Set(bound.keys()))) {
|
|
2709
|
+
if (edge.fromColumn === 'unknown' || edge.toColumn === 'unknown')
|
|
2710
|
+
continue;
|
|
2711
|
+
const from = bound.get(edge.fromDbtUniqueId);
|
|
2712
|
+
const to = bound.get(edge.toDbtUniqueId);
|
|
2713
|
+
if (!from || !to || from.id === to.id)
|
|
2714
|
+
continue;
|
|
2715
|
+
// Only offer edges this batch actually introduces, and keep cross-domain
|
|
2716
|
+
// links on the explicit export/import path.
|
|
2717
|
+
if (!from.operationId && !to.operationId)
|
|
2718
|
+
continue;
|
|
2719
|
+
if (from.domain !== to.domain)
|
|
2720
|
+
continue;
|
|
2721
|
+
const id = `${from.id}_to_${to.id}`;
|
|
2722
|
+
const key = `${from.domain}::${id}`;
|
|
2723
|
+
if (existingRelationshipIds.has(key))
|
|
2724
|
+
continue;
|
|
2725
|
+
existingRelationshipIds.add(key);
|
|
2726
|
+
harvested.push({
|
|
2727
|
+
id: `dbt-test:${edge.testUniqueId}`,
|
|
2728
|
+
kind: 'modeling_change',
|
|
2729
|
+
dependsOn: [from.operationId, to.operationId].filter((value) => Boolean(value)),
|
|
2730
|
+
evidence: [edge.testUniqueId, ...(edge.sourcePath ? [edge.sourcePath] : [])],
|
|
2731
|
+
change: {
|
|
2732
|
+
operation: 'upsert_relationship',
|
|
2733
|
+
value: {
|
|
2734
|
+
id, domain: from.domain, areaId: from.areaId ?? to.areaId,
|
|
2735
|
+
from: from.id, to: to.id,
|
|
2736
|
+
keys: [{ from: edge.fromColumn, to: edge.toColumn }],
|
|
2737
|
+
cardinality: 'many_to_one', fanout: 'unknown', status: 'draft',
|
|
2738
|
+
rationale: `Declared by the dbt relationships test ${edge.testUniqueId}. Validate against the warehouse before certifying.`,
|
|
2739
|
+
},
|
|
2740
|
+
},
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
return harvested.length ? [...operations, ...harvested] : operations;
|
|
2744
|
+
};
|
|
2594
2745
|
const previewContextAuthoring = (input) => {
|
|
2595
2746
|
const snapshot = projectSnapshot();
|
|
2596
2747
|
if (input.expectedSnapshotId && input.expectedSnapshotId !== snapshot.snapshotId) {
|
|
@@ -2637,30 +2788,48 @@ export async function startLocalServer(opts) {
|
|
|
2637
2788
|
if (operation.change.operation === 'upsert_entity'
|
|
2638
2789
|
&& (input.origin === 'ai' || input.origin === 'yaml_import' || input.origin === 'dbt_discovery')
|
|
2639
2790
|
&& !operation.change.value.areaId) {
|
|
2640
|
-
|
|
2791
|
+
// Every model still belongs to exactly one Domain and subject area. An
|
|
2792
|
+
// omitted area is filled with the default rather than blocking the
|
|
2793
|
+
// author; `ensureAuthoringScopeOperations` creates it if it is new.
|
|
2794
|
+
diagnostics.push({ code: 'MODEL_AREA_DEFAULTED', severity: 'info', operationId: operation.id, message: `No subject area was selected, so these models were placed in "${DEFAULT_MODEL_AREA_ID}". You can rename or move them later.` });
|
|
2795
|
+
return { ...operation, change: { ...operation.change, value: { ...operation.change.value, areaId: DEFAULT_MODEL_AREA_ID } } };
|
|
2641
2796
|
}
|
|
2642
2797
|
return operation;
|
|
2643
2798
|
});
|
|
2644
|
-
|
|
2645
|
-
|
|
2799
|
+
// dbt discovery and Modeling AI bind models; give them the edges dbt already
|
|
2800
|
+
// declares. Manual authoring and YAML import bring their own relationships.
|
|
2801
|
+
const withDeclaredEdges = input.origin === 'dbt_discovery' || input.origin === 'ai'
|
|
2802
|
+
? appendDbtDeclaredRelationships(operations, snapshot.manifest)
|
|
2803
|
+
: operations;
|
|
2804
|
+
const scopedOperations = ensureAuthoringScopeOperations(withDeclaredEdges, snapshot.manifest);
|
|
2805
|
+
const operationIds = new Set(scopedOperations.map((operation) => operation.id));
|
|
2806
|
+
for (const operation of scopedOperations) {
|
|
2646
2807
|
for (const dependency of operation.dependsOn ?? []) {
|
|
2647
2808
|
if (!operationIds.has(dependency))
|
|
2648
2809
|
diagnostics.push({ code: 'DEPENDENCY_CONFLICT', severity: 'blocking', operationId: operation.id, message: `Required proposal operation is missing: ${dependency}` });
|
|
2649
2810
|
}
|
|
2650
2811
|
}
|
|
2651
2812
|
const patches = [];
|
|
2652
|
-
const modelingOperations =
|
|
2813
|
+
const modelingOperations = scopedOperations.filter((operation) => operation.kind === 'modeling_change');
|
|
2653
2814
|
if (modelingOperations.length > 0) {
|
|
2654
2815
|
const batch = previewModelingChanges(projectRoot, modelingOperations.map((operation) => operation.change));
|
|
2816
|
+
// The batch attributes each path while composing its shadow, so a change
|
|
2817
|
+
// that depends on an earlier one (an entity in a Domain this same batch
|
|
2818
|
+
// creates) is attributed correctly and without re-previewing per patch.
|
|
2819
|
+
const ownerByPath = new Map();
|
|
2820
|
+
batch.patchPathsByChange.forEach((paths, index) => {
|
|
2821
|
+
const operationId = modelingOperations[index]?.id;
|
|
2822
|
+
if (!operationId)
|
|
2823
|
+
return;
|
|
2824
|
+
for (const path of paths)
|
|
2825
|
+
if (!ownerByPath.has(path))
|
|
2826
|
+
ownerByPath.set(path, operationId);
|
|
2827
|
+
});
|
|
2655
2828
|
for (const patch of batch.patches) {
|
|
2656
|
-
|
|
2657
|
-
const single = previewModelingChange(projectRoot, operation.change);
|
|
2658
|
-
return single.patches.some((candidate) => candidate.path === patch.path);
|
|
2659
|
-
})?.id ?? modelingOperations[0].id;
|
|
2660
|
-
patches.push({ ...patch, owner: 'dql', operationId: owner });
|
|
2829
|
+
patches.push({ ...patch, owner: 'dql', operationId: ownerByPath.get(patch.path) ?? modelingOperations[0].id });
|
|
2661
2830
|
}
|
|
2662
2831
|
}
|
|
2663
|
-
for (const operation of
|
|
2832
|
+
for (const operation of scopedOperations) {
|
|
2664
2833
|
if (operation.kind === 'skill_change') {
|
|
2665
2834
|
const preview = previewSkillChange(projectRoot, operation.value, operation.targetQualifiedId, operation.expectedSourceHash);
|
|
2666
2835
|
for (const patch of preview.patches) {
|
|
@@ -2698,7 +2867,7 @@ export async function startLocalServer(opts) {
|
|
|
2698
2867
|
origin: input.origin,
|
|
2699
2868
|
baseSnapshotId: snapshot.snapshotId,
|
|
2700
2869
|
dependencyFingerprints,
|
|
2701
|
-
operations,
|
|
2870
|
+
operations: scopedOperations,
|
|
2702
2871
|
patches,
|
|
2703
2872
|
diagnostics,
|
|
2704
2873
|
sourceRunId: input.sourceRunId,
|
|
@@ -2710,34 +2879,75 @@ export async function startLocalServer(opts) {
|
|
|
2710
2879
|
};
|
|
2711
2880
|
const modelingAuthoringRunExecutor = async ({ runId, request }) => {
|
|
2712
2881
|
const snapshot = projectSnapshot();
|
|
2713
|
-
|
|
2882
|
+
// AGT-024: a configured provider designs the model; the deterministic
|
|
2883
|
+
// matcher is the documented provider-unavailable fallback, and the answer
|
|
2884
|
+
// says which one ran so an evidence-only suggestion is never mistaken for
|
|
2885
|
+
// an AI proposal.
|
|
2886
|
+
const provider = await createBlockStudioAssistProvider(projectRoot);
|
|
2887
|
+
let usedProvider = Boolean(provider);
|
|
2888
|
+
let operations;
|
|
2889
|
+
if (provider) {
|
|
2890
|
+
try {
|
|
2891
|
+
operations = await buildAiModelingOperations(projectRoot, snapshot.manifest, request, provider);
|
|
2892
|
+
}
|
|
2893
|
+
catch (error) {
|
|
2894
|
+
// Fall back only when the provider itself is unusable. A rejected
|
|
2895
|
+
// proposal (invented identifiers, nothing to propose) is a real result
|
|
2896
|
+
// and must surface, not be papered over by the template matcher.
|
|
2897
|
+
if (!/could not complete this modeling request/.test(error instanceof Error ? error.message : String(error)))
|
|
2898
|
+
throw error;
|
|
2899
|
+
usedProvider = false;
|
|
2900
|
+
operations = buildDeterministicModelingOperations(projectRoot, snapshot.manifest, request);
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
else {
|
|
2904
|
+
operations = buildDeterministicModelingOperations(projectRoot, snapshot.manifest, request);
|
|
2905
|
+
}
|
|
2714
2906
|
const proposal = previewContextAuthoring({
|
|
2715
2907
|
origin: request.workspaceContext?.correction === true ? 'correction' : 'ai',
|
|
2716
2908
|
operations,
|
|
2717
|
-
|
|
2909
|
+
// Base the draft on the snapshot it was just built from, not on the id the
|
|
2910
|
+
// client captured at page load. Gating construction on the client's id
|
|
2911
|
+
// rejected a proposal assembled from `snapshot.manifest` moments earlier,
|
|
2912
|
+
// so any unrelated project write (a block edit, a dbt reapply) made every
|
|
2913
|
+
// Modeling AI request fail with PROPOSAL_STALE until a manual refresh.
|
|
2914
|
+
// Drift is still caught where it matters: applying the proposal re-checks
|
|
2915
|
+
// its `baseSnapshotId` and `dependencyFingerprints` and fails SOURCE_CHANGED.
|
|
2916
|
+
expectedSnapshotId: snapshot.snapshotId,
|
|
2718
2917
|
sourceRunId: runId,
|
|
2719
2918
|
revision: Number(request.workspaceContext?.revision ?? 1),
|
|
2720
2919
|
});
|
|
2920
|
+
const blocked = proposal.diagnostics.some((item) => item.severity === 'blocking');
|
|
2721
2921
|
return {
|
|
2722
|
-
summary: `Prepared ${proposal.impact.modelingChanges}
|
|
2723
|
-
answer:
|
|
2922
|
+
summary: `Prepared ${proposal.impact.modelingChanges} modeling change${proposal.impact.modelingChanges === 1 ? '' : 's'} for review.`,
|
|
2923
|
+
answer: blocked
|
|
2724
2924
|
? 'I prepared a modeling proposal, but the blocking items must be resolved before it can be saved.'
|
|
2725
|
-
:
|
|
2925
|
+
: usedProvider
|
|
2926
|
+
? 'I designed a modeling proposal from your dbt metadata. Every model and column was checked against the current snapshot. Review the exact source diff before saving it as a draft.'
|
|
2927
|
+
: 'No AI provider is configured, so these are evidence-only suggestions matched from dbt metadata rather than an AI proposal. Review the exact source diff before saving it as a draft.',
|
|
2726
2928
|
status: 'needs_review',
|
|
2727
2929
|
trustState: 'review_required',
|
|
2728
2930
|
stopReason: 'human_review_required',
|
|
2729
|
-
artifacts: [{ id: `${runId}:modeling-proposal`, kind: 'modeling_change_proposal', title: 'Modeling change proposal', trustState: 'review_required', ref: proposal.id, payload: proposal }],
|
|
2931
|
+
artifacts: [{ id: `${runId}:modeling-proposal`, kind: 'modeling_change_proposal', title: usedProvider ? 'Modeling change proposal' : 'Evidence-only modeling suggestions', trustState: 'review_required', ref: proposal.id, payload: proposal }],
|
|
2730
2932
|
evaluations: proposal.diagnostics.map((diagnostic) => ({ id: `${proposal.id}:${diagnostic.code}`, label: diagnostic.code, passed: diagnostic.severity !== 'blocking', severity: diagnostic.severity, message: diagnostic.message })),
|
|
2731
2933
|
nextActions: [{ id: 'review-modeling-proposal', label: 'Review in Modeling', artifactKind: 'modeling_change_proposal' }],
|
|
2732
2934
|
};
|
|
2733
2935
|
};
|
|
2734
2936
|
const skillAuthoringRunExecutor = async ({ runId, request }) => {
|
|
2735
2937
|
const snapshot = projectSnapshot();
|
|
2736
|
-
|
|
2938
|
+
// SKILL-006: same contract as Modeling AI — a provider drafts the skill
|
|
2939
|
+
// body and vocabulary; the template is the labelled fallback.
|
|
2940
|
+
const provider = await createBlockStudioAssistProvider(projectRoot);
|
|
2941
|
+
const operations = provider
|
|
2942
|
+
? await buildAiSkillOperations(projectRoot, snapshot.manifest, request, provider)
|
|
2943
|
+
.catch(() => buildMetadataBoundSkillOperations(projectRoot, snapshot.manifest, request))
|
|
2944
|
+
: buildMetadataBoundSkillOperations(projectRoot, snapshot.manifest, request);
|
|
2737
2945
|
const proposal = previewContextAuthoring({
|
|
2738
2946
|
origin: request.workspaceContext?.correction === true ? 'correction' : 'ai',
|
|
2739
2947
|
operations,
|
|
2740
|
-
|
|
2948
|
+
// Same reasoning as the modeling executor above: base the draft on the
|
|
2949
|
+
// snapshot it was built from; apply-time re-validation catches real drift.
|
|
2950
|
+
expectedSnapshotId: snapshot.snapshotId,
|
|
2741
2951
|
sourceRunId: runId,
|
|
2742
2952
|
revision: Number(request.workspaceContext?.revision ?? 1),
|
|
2743
2953
|
});
|
|
@@ -4173,6 +4383,48 @@ export async function startLocalServer(opts) {
|
|
|
4173
4383
|
return [];
|
|
4174
4384
|
}
|
|
4175
4385
|
};
|
|
4386
|
+
/**
|
|
4387
|
+
* CTX-008: the project's authored relationships, as plain join facts for the
|
|
4388
|
+
* relations a bounded repair can actually see.
|
|
4389
|
+
*
|
|
4390
|
+
* A repair prompt previously carried only the failure, the failed SQL, and a
|
|
4391
|
+
* bare relation list, so a repair could silently replace a correct governed
|
|
4392
|
+
* join with a shared-column guess. This is grounding only — it never widens
|
|
4393
|
+
* the relation scope and never authorizes a join (`REL-002` still governs
|
|
4394
|
+
* execution); trust is labelled so a draft edge reads as a draft.
|
|
4395
|
+
*/
|
|
4396
|
+
const governedJoinFactsForRelations = (relations) => {
|
|
4397
|
+
const modeling = (() => {
|
|
4398
|
+
try {
|
|
4399
|
+
return projectSnapshot().manifest.modeling;
|
|
4400
|
+
}
|
|
4401
|
+
catch {
|
|
4402
|
+
return undefined;
|
|
4403
|
+
}
|
|
4404
|
+
})();
|
|
4405
|
+
if (!modeling)
|
|
4406
|
+
return [];
|
|
4407
|
+
const manifest = projectSnapshot().manifest;
|
|
4408
|
+
const normalize = (value) => value.toLowerCase().replace(/"/g, '').split('.').at(-1) ?? value.toLowerCase();
|
|
4409
|
+
const visible = new Set(relations.map(normalize));
|
|
4410
|
+
const relationFor = (entityKey) => {
|
|
4411
|
+
const entity = modeling.entities[entityKey];
|
|
4412
|
+
if (!entity)
|
|
4413
|
+
return undefined;
|
|
4414
|
+
const node = manifest.dbtProvenance?.nodes[entity.dbtUniqueId];
|
|
4415
|
+
return node?.relation ?? node?.name;
|
|
4416
|
+
};
|
|
4417
|
+
return Object.values(modeling.relationships).flatMap((relationship) => {
|
|
4418
|
+
const from = relationFor(relationship.from);
|
|
4419
|
+
const to = relationFor(relationship.to);
|
|
4420
|
+
if (!from || !to || !visible.has(normalize(from)) || !visible.has(normalize(to)))
|
|
4421
|
+
return [];
|
|
4422
|
+
const keys = relationship.keys.map((key) => `${from}.${key.from} = ${to}.${key.to}`).join(' AND ');
|
|
4423
|
+
const trust = relationship.automaticJoinAllowed ? 'certified' : `${relationship.status} (review required)`;
|
|
4424
|
+
const meaning = [relationship.verb, relationship.description].filter(Boolean).join(' — ');
|
|
4425
|
+
return [`${keys} (${relationship.cardinality}, fanout ${relationship.fanout}, ${trust}${meaning ? `; ${meaning}` : ''})`];
|
|
4426
|
+
}).slice(0, 12);
|
|
4427
|
+
};
|
|
4176
4428
|
const boundedRepairError = (message, status, warehouseFailure) => Object.assign(new Error(message), { status, ...(warehouseFailure ? { warehouseFailure } : {}) });
|
|
4177
4429
|
/**
|
|
4178
4430
|
* Shared one-attempt SQL repair for Ask and Notebook. It never creates a
|
|
@@ -4209,6 +4461,9 @@ export async function startLocalServer(opts) {
|
|
|
4209
4461
|
relation: table.relation,
|
|
4210
4462
|
columns: table.columns.slice(0, 40).map((column) => ({ name: column.name, type: column.type })),
|
|
4211
4463
|
}));
|
|
4464
|
+
// CTX-008: a repair that re-derives joins from column names can undo a
|
|
4465
|
+
// join the original query got right. Give it the authored key pairs.
|
|
4466
|
+
const governedJoins = governedJoinFactsForRelations(schemaContext.slice(0, 16).map((table) => table.relation));
|
|
4212
4467
|
let raw;
|
|
4213
4468
|
try {
|
|
4214
4469
|
raw = await provider.generate([
|
|
@@ -4222,6 +4477,7 @@ export async function startLocalServer(opts) {
|
|
|
4222
4477
|
? 'Preserve every positional parameter placeholder exactly; do not add, remove, or renumber placeholders.'
|
|
4223
4478
|
: '',
|
|
4224
4479
|
'Use only relations and columns present in the supplied schema context.',
|
|
4480
|
+
'When governedJoins is present, join on those exact key pairs rather than inferring a join from column names.',
|
|
4225
4481
|
'Never emit internal DQL relation identities such as source::, model::, seed::, or snapshot::.',
|
|
4226
4482
|
'Do not add writes, DDL, multiple statements, or commentary outside the JSON fence.',
|
|
4227
4483
|
].join(' '),
|
|
@@ -4238,6 +4494,7 @@ export async function startLocalServer(opts) {
|
|
|
4238
4494
|
placeholder: `$${parameter.position}`,
|
|
4239
4495
|
})),
|
|
4240
4496
|
schema: boundedSchema,
|
|
4497
|
+
...(governedJoins.length ? { governedJoins } : {}),
|
|
4241
4498
|
}),
|
|
4242
4499
|
},
|
|
4243
4500
|
], { maxTokens: 1200, temperature: 0 });
|
|
@@ -4307,6 +4564,8 @@ export async function startLocalServer(opts) {
|
|
|
4307
4564
|
relation: table.relation,
|
|
4308
4565
|
columns: table.columns.slice(0, 40).map((column) => ({ name: column.name, type: column.type })),
|
|
4309
4566
|
}));
|
|
4567
|
+
// CTX-008: same as the SQL repair — keep authored joins out of the guesser.
|
|
4568
|
+
const governedJoins = governedJoinFactsForRelations(schemaContext.slice(0, 16).map((table) => table.relation));
|
|
4310
4569
|
let raw;
|
|
4311
4570
|
try {
|
|
4312
4571
|
raw = await provider.generate([
|
|
@@ -4319,6 +4578,7 @@ export async function startLocalServer(opts) {
|
|
|
4319
4578
|
'Preserve the block name, business fields, visualization, tests, requested grain, filters, output columns, and aggregation semantics.',
|
|
4320
4579
|
'Preserve every declared parameter and every ${parameter} interpolation exactly. Do not add or remove parameters.',
|
|
4321
4580
|
'Use only relations and columns present in the supplied schema context.',
|
|
4581
|
+
'When governedJoins is present, join on those exact key pairs rather than inferring a join from column names.',
|
|
4322
4582
|
'Never emit internal relation identities such as source::, model::, seed::, or snapshot::.',
|
|
4323
4583
|
'Return exactly one custom block. Never certify it, bind a saved block, add writes, DDL, or multiple SQL statements.',
|
|
4324
4584
|
].join(' '),
|
|
@@ -4332,6 +4592,7 @@ export async function startLocalServer(opts) {
|
|
|
4332
4592
|
dql: input.source.slice(0, 40_000),
|
|
4333
4593
|
parameters: input.parameters ?? {},
|
|
4334
4594
|
schema: boundedSchema,
|
|
4595
|
+
...(governedJoins.length ? { governedJoins } : {}),
|
|
4335
4596
|
}),
|
|
4336
4597
|
},
|
|
4337
4598
|
], { maxTokens: 2400, temperature: 0 });
|
|
@@ -5422,7 +5683,10 @@ export async function startLocalServer(opts) {
|
|
|
5422
5683
|
parameters: invocation.values,
|
|
5423
5684
|
})
|
|
5424
5685
|
: null;
|
|
5425
|
-
const
|
|
5686
|
+
const staticValidation = validateBlockStudioSource(source, semanticLayer);
|
|
5687
|
+
const validation = semanticCompose
|
|
5688
|
+
? reconcileBlockStudioRuntimeValidation(staticValidation, semanticCompose)
|
|
5689
|
+
: staticValidation;
|
|
5426
5690
|
const executableSql = semanticCompose?.sql ?? validation.executableSql;
|
|
5427
5691
|
if (!executableSql) {
|
|
5428
5692
|
const message = semanticCompose?.diagnostics.find((item) => item.severity === 'error')?.message
|
|
@@ -5444,6 +5708,7 @@ export async function startLocalServer(opts) {
|
|
|
5444
5708
|
chartConfig: plan?.chartConfig ?? validation.chartConfig ?? null,
|
|
5445
5709
|
invocation,
|
|
5446
5710
|
tests: plan?.tests ?? [],
|
|
5711
|
+
validation,
|
|
5447
5712
|
};
|
|
5448
5713
|
};
|
|
5449
5714
|
const runBlockStudioTestSummary = async (source, targetConnection, executedPreview) => {
|
|
@@ -5544,12 +5809,13 @@ export async function startLocalServer(opts) {
|
|
|
5544
5809
|
};
|
|
5545
5810
|
};
|
|
5546
5811
|
const certifyBlockStudioSource = async (source, blockPath, options = {}) => {
|
|
5547
|
-
|
|
5812
|
+
let validation = validateBlockStudioSource(source, semanticLayer);
|
|
5548
5813
|
let preview = null;
|
|
5549
5814
|
let testResults = null;
|
|
5550
5815
|
const blockers = [];
|
|
5551
5816
|
try {
|
|
5552
5817
|
preview = await runBlockStudioPreviewSource(source);
|
|
5818
|
+
validation = preview.validation;
|
|
5553
5819
|
}
|
|
5554
5820
|
catch (error) {
|
|
5555
5821
|
blockers.push(error instanceof Error ? error.message : String(error));
|
|
@@ -6621,10 +6887,13 @@ export async function startLocalServer(opts) {
|
|
|
6621
6887
|
const selectedCandidateIds = Array.isArray(body.selectedCandidateIds)
|
|
6622
6888
|
? body.selectedCandidateIds.filter((value) => typeof value === 'string')
|
|
6623
6889
|
: session.candidates.filter((candidate) => candidate.dialect !== 'unsupported' && candidate.action !== 'open_existing').map((candidate) => candidate.id);
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6890
|
+
// An unspecified scope is defaulted, not rejected: the proposal carries
|
|
6891
|
+
// the `upsert_domain`/`upsert_area` patches so the author reviews the
|
|
6892
|
+
// scope alongside the models it will hold.
|
|
6893
|
+
const domain = typeof body.domain === 'string' && body.domain.trim()
|
|
6894
|
+
? body.domain.trim()
|
|
6895
|
+
: defaultAuthoringDomainId(snapshot.manifest);
|
|
6896
|
+
const areaId = typeof body.areaId === 'string' && body.areaId.trim() ? body.areaId.trim() : DEFAULT_MODEL_AREA_ID;
|
|
6628
6897
|
const planned = modelingImportOperations(session, selectedCandidateIds, domain, areaId, snapshot.manifest);
|
|
6629
6898
|
const proposal = previewContextAuthoring({
|
|
6630
6899
|
origin: 'yaml_import',
|
|
@@ -9008,20 +9277,23 @@ export async function startLocalServer(opts) {
|
|
|
9008
9277
|
}));
|
|
9009
9278
|
return;
|
|
9010
9279
|
}
|
|
9011
|
-
const appDashRun = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/run$/);
|
|
9280
|
+
const appDashRun = path.match(/^\/api\/(apps|app-builds)\/([^/]+)\/dashboards\/([^/]+)\/run$/);
|
|
9012
9281
|
if (req.method === 'POST' && appDashRun) {
|
|
9013
9282
|
try {
|
|
9014
|
-
const
|
|
9015
|
-
const
|
|
9283
|
+
const runSurface = appDashRun[1];
|
|
9284
|
+
const appId = decodeURIComponent(appDashRun[2]);
|
|
9285
|
+
const dashboardId = decodeURIComponent(appDashRun[3]);
|
|
9016
9286
|
const body = await readJSON(req).catch(() => ({}));
|
|
9017
|
-
const loaded =
|
|
9287
|
+
const loaded = runSurface === 'app-builds'
|
|
9288
|
+
? loadAppBuildDraftDashboard(projectRoot, appId, dashboardId)
|
|
9289
|
+
: loadAppDashboard(projectRoot, appId, dashboardId);
|
|
9018
9290
|
if (!loaded) {
|
|
9019
9291
|
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
9020
|
-
res.end(serializeJSON({ error: `Dashboard "${dashboardId}" not found in app "${appId}"` }));
|
|
9292
|
+
res.end(serializeJSON({ error: `Dashboard "${dashboardId}" not found in ${runSurface === 'app-builds' ? 'local App draft' : 'App'} "${appId}"` }));
|
|
9021
9293
|
return;
|
|
9022
9294
|
}
|
|
9023
9295
|
const activeApp = activePersonaAppId();
|
|
9024
|
-
if (activeApp && activeApp !== appId) {
|
|
9296
|
+
if (runSurface === 'apps' && activeApp && activeApp !== appId) {
|
|
9025
9297
|
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
9026
9298
|
res.end(serializeJSON({ error: `The active persona belongs to App "${activeApp}", not "${appId}".` }));
|
|
9027
9299
|
return;
|
|
@@ -9035,627 +9307,689 @@ export async function startLocalServer(opts) {
|
|
|
9035
9307
|
? body.tileId.trim()
|
|
9036
9308
|
: undefined;
|
|
9037
9309
|
const tiles = [];
|
|
9038
|
-
|
|
9039
|
-
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
|
|
9044
|
-
|
|
9045
|
-
|
|
9046
|
-
|
|
9047
|
-
|
|
9048
|
-
|
|
9049
|
-
text: item.text,
|
|
9050
|
-
});
|
|
9051
|
-
continue;
|
|
9052
|
-
}
|
|
9053
|
-
if (item.aiPin) {
|
|
9054
|
-
try {
|
|
9055
|
-
localApps ??= new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
9056
|
-
}
|
|
9057
|
-
catch (err) {
|
|
9058
|
-
tiles.push({
|
|
9059
|
-
tileId: item.i,
|
|
9060
|
-
status: 'error',
|
|
9061
|
-
tileType: 'aiPin',
|
|
9062
|
-
error: err instanceof Error ? err.message : String(err),
|
|
9063
|
-
});
|
|
9310
|
+
const localApps = { current: null };
|
|
9311
|
+
const itemsToRun = loaded.dashboard.layout.items.filter((item) => !requestedTileId || item.i === requestedTileId);
|
|
9312
|
+
const tileOrder = new Map(itemsToRun.map((item, index) => [item.i, index]));
|
|
9313
|
+
let nextTileIndex = 0;
|
|
9314
|
+
// App preview is a multi-source execution, so isolate each tile's
|
|
9315
|
+
// failure and run a small fixed pool. Four avoids serial 4k-catalog
|
|
9316
|
+
// behavior without flooding the configured warehouse.
|
|
9317
|
+
const runTileWorker = async () => {
|
|
9318
|
+
while (nextTileIndex < itemsToRun.length) {
|
|
9319
|
+
const item = itemsToRun[nextTileIndex++];
|
|
9320
|
+
if (!item)
|
|
9064
9321
|
continue;
|
|
9065
|
-
|
|
9066
|
-
let pin = localApps.getAiPin(item.aiPin.id);
|
|
9067
|
-
if (!pin) {
|
|
9322
|
+
if (item.text) {
|
|
9068
9323
|
tiles.push({
|
|
9069
9324
|
tileId: item.i,
|
|
9070
|
-
status: '
|
|
9071
|
-
tileType: '
|
|
9072
|
-
|
|
9325
|
+
status: 'ok',
|
|
9326
|
+
tileType: 'text',
|
|
9327
|
+
title: item.title,
|
|
9328
|
+
viz: item.viz,
|
|
9329
|
+
text: item.text,
|
|
9073
9330
|
});
|
|
9074
9331
|
continue;
|
|
9075
9332
|
}
|
|
9076
|
-
|
|
9077
|
-
? pin.analysisPlan
|
|
9078
|
-
: undefined;
|
|
9079
|
-
const pinArtifact = normalizeDqlArtifactReference(pinPlan?.dqlArtifact);
|
|
9080
|
-
let renderedPinResult = pin.result;
|
|
9081
|
-
let renderedPinChartConfig = pin.chartConfig;
|
|
9082
|
-
if (pinArtifact) {
|
|
9333
|
+
if (item.aiPin) {
|
|
9083
9334
|
try {
|
|
9084
|
-
|
|
9085
|
-
item,
|
|
9086
|
-
dashboardValues: dashboardVariables,
|
|
9087
|
-
requestValues: variables,
|
|
9088
|
-
});
|
|
9089
|
-
const executed = await executeDqlArtifactSourceForAgent(pinArtifact.source, { name: pinArtifact.name ?? pin.title, path: pinArtifact.sourcePath }, { question: pin.question, parameters: { ...(pinArtifact.parameterValues ?? {}), ...boundParameters } });
|
|
9090
|
-
renderedPinResult = executed;
|
|
9091
|
-
renderedPinChartConfig = executed.chartConfig && typeof executed.chartConfig === 'object'
|
|
9092
|
-
? executed.chartConfig
|
|
9093
|
-
: renderedPinChartConfig;
|
|
9335
|
+
localApps.current ??= new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
9094
9336
|
}
|
|
9095
9337
|
catch (err) {
|
|
9096
9338
|
tiles.push({
|
|
9097
9339
|
tileId: item.i,
|
|
9098
9340
|
status: 'error',
|
|
9099
9341
|
tileType: 'aiPin',
|
|
9100
|
-
title: item.title ?? pin.title,
|
|
9101
9342
|
error: err instanceof Error ? err.message : String(err),
|
|
9102
|
-
trustState: pinArtifact.trustState ?? 'review_required',
|
|
9103
9343
|
});
|
|
9104
9344
|
continue;
|
|
9105
9345
|
}
|
|
9106
|
-
|
|
9107
|
-
|
|
9108
|
-
|
|
9109
|
-
|
|
9110
|
-
|
|
9111
|
-
|
|
9112
|
-
|
|
9113
|
-
pin = localApps.updateAiPinResult(pin.id, pin.result, err instanceof Error ? err.message : String(err)) ?? pin;
|
|
9114
|
-
}
|
|
9115
|
-
}
|
|
9116
|
-
tiles.push({
|
|
9117
|
-
tileId: item.i,
|
|
9118
|
-
status: 'ok',
|
|
9119
|
-
tileType: 'aiPin',
|
|
9120
|
-
title: item.title ?? pin.title,
|
|
9121
|
-
viz: item.viz,
|
|
9122
|
-
chartConfig: mergeDashboardChartConfig(renderedPinChartConfig, item),
|
|
9123
|
-
result: renderedPinResult,
|
|
9124
|
-
aiPin: pin,
|
|
9125
|
-
citation: {
|
|
9126
|
-
kind: 'ai_pin',
|
|
9127
|
-
name: pin.title,
|
|
9128
|
-
},
|
|
9129
|
-
artifact: {
|
|
9130
|
-
version: 1,
|
|
9131
|
-
sourceKind: 'ai_pin',
|
|
9132
|
-
name: pinArtifact?.name ?? pin.title,
|
|
9133
|
-
...(pinArtifact?.sourcePath ? { sourcePath: pinArtifact.sourcePath } : {}),
|
|
9134
|
-
...(pinArtifact?.source ? { dql: pinArtifact.source.slice(0, 40_000) } : {}),
|
|
9135
|
-
...(pinArtifact?.compiledSql || pin.sql ? { sql: (pinArtifact?.compiledSql ?? pin.sql).slice(0, 40_000) } : {}),
|
|
9136
|
-
trustState: pinArtifact?.trustState === 'certified' || pin.certification === 'certified'
|
|
9137
|
-
? 'certified'
|
|
9138
|
-
: 'review_required',
|
|
9139
|
-
explanation: [
|
|
9140
|
-
'This result comes from a saved App insight.',
|
|
9141
|
-
pinArtifact ? 'The DQL artifact was executed again with the current App parameters.' : 'The saved bounded result is displayed with its original trust state.',
|
|
9142
|
-
],
|
|
9143
|
-
executionTarget: executionTargetDescriptor(body),
|
|
9144
|
-
},
|
|
9145
|
-
});
|
|
9146
|
-
continue;
|
|
9147
|
-
}
|
|
9148
|
-
if (item.draftAnalysis) {
|
|
9149
|
-
let draftSource;
|
|
9150
|
-
let draftAppRelativePath;
|
|
9151
|
-
let draftParameters = {};
|
|
9152
|
-
let draftTargetConnection;
|
|
9153
|
-
let draftRepairPlan;
|
|
9154
|
-
try {
|
|
9155
|
-
const draftPath = join(loaded.appDir, item.draftAnalysis.ref);
|
|
9156
|
-
draftAppRelativePath = relative(loaded.appDir, draftPath);
|
|
9157
|
-
if (draftAppRelativePath.startsWith('..') || draftAppRelativePath.startsWith('/')) {
|
|
9158
|
-
throw new Error('App-scoped analysis reference escapes the App directory.');
|
|
9159
|
-
}
|
|
9160
|
-
draftSource = readFileSync(draftPath, 'utf-8');
|
|
9161
|
-
const fingerprint = `sha256:${createHash('sha256').update(draftSource).digest('hex')}`;
|
|
9162
|
-
if (fingerprint !== item.draftAnalysis.artifactFingerprint) {
|
|
9163
|
-
throw new Error('App-scoped analysis changed after approval. Review the draft again.');
|
|
9164
|
-
}
|
|
9165
|
-
draftParameters = dashboardTileParameterValues({
|
|
9166
|
-
item,
|
|
9167
|
-
dashboardValues: dashboardVariables,
|
|
9168
|
-
requestValues: variables,
|
|
9169
|
-
});
|
|
9170
|
-
draftTargetConnection = isConnectionConfig(body.connection)
|
|
9171
|
-
? body.connection
|
|
9172
|
-
: connection ?? undefined;
|
|
9173
|
-
draftRepairPlan = buildExecutionPlan({ id: item.i, type: 'dql', source: draftSource, title: item.title ?? item.i }, {
|
|
9174
|
-
semanticLayer,
|
|
9175
|
-
driver: draftTargetConnection?.driver ?? 'duckdb',
|
|
9176
|
-
parameters: draftParameters,
|
|
9177
|
-
});
|
|
9178
|
-
let result;
|
|
9179
|
-
let repair;
|
|
9180
|
-
let repairedSql;
|
|
9181
|
-
let repairedDql;
|
|
9182
|
-
try {
|
|
9183
|
-
result = await executeDqlArtifactSourceForAgent(draftSource, { name: item.title ?? item.i, path: draftAppRelativePath }, { question: item.title, parameters: draftParameters });
|
|
9184
|
-
}
|
|
9185
|
-
catch (executionError) {
|
|
9186
|
-
const repaired = await repairFailedAppTileExecution({
|
|
9187
|
-
source: 'draft_analysis',
|
|
9188
|
-
question: item.title ?? item.i,
|
|
9189
|
-
sourceSql: draftRepairPlan?.sql,
|
|
9190
|
-
sourceDql: draftSource,
|
|
9191
|
-
artifactName: item.title ?? item.i,
|
|
9192
|
-
error: executionError,
|
|
9193
|
-
targetConnection: draftTargetConnection,
|
|
9194
|
-
targetConnectionName: projectConfig.defaultConnectionName,
|
|
9195
|
-
sqlParams: draftRepairPlan?.sqlParams,
|
|
9196
|
-
variables: { ...(draftRepairPlan?.variables ?? {}), ...draftParameters },
|
|
9346
|
+
let pin = localApps.current.getAiPin(item.aiPin.id);
|
|
9347
|
+
if (!pin) {
|
|
9348
|
+
tiles.push({
|
|
9349
|
+
tileId: item.i,
|
|
9350
|
+
status: 'unresolved',
|
|
9351
|
+
tileType: 'aiPin',
|
|
9352
|
+
error: `AI pin "${item.aiPin.id}" could not be found`,
|
|
9197
9353
|
});
|
|
9198
|
-
|
|
9354
|
+
continue;
|
|
9355
|
+
}
|
|
9356
|
+
const pinPlan = pin.analysisPlan && typeof pin.analysisPlan === 'object' && !Array.isArray(pin.analysisPlan)
|
|
9357
|
+
? pin.analysisPlan
|
|
9358
|
+
: undefined;
|
|
9359
|
+
const pinArtifact = normalizeDqlArtifactReference(pinPlan?.dqlArtifact);
|
|
9360
|
+
let renderedPinResult = pin.result;
|
|
9361
|
+
let renderedPinChartConfig = pin.chartConfig;
|
|
9362
|
+
if (pinArtifact) {
|
|
9363
|
+
try {
|
|
9364
|
+
const boundParameters = dashboardTileParameterValues({
|
|
9365
|
+
item,
|
|
9366
|
+
dashboardValues: dashboardVariables,
|
|
9367
|
+
requestValues: variables,
|
|
9368
|
+
});
|
|
9369
|
+
const executed = await executeDqlArtifactSourceForAgent(pinArtifact.source, { name: pinArtifact.name ?? pin.title, path: pinArtifact.sourcePath }, { question: pin.question, parameters: { ...(pinArtifact.parameterValues ?? {}), ...boundParameters } });
|
|
9370
|
+
renderedPinResult = executed;
|
|
9371
|
+
renderedPinChartConfig = executed.chartConfig && typeof executed.chartConfig === 'object'
|
|
9372
|
+
? executed.chartConfig
|
|
9373
|
+
: renderedPinChartConfig;
|
|
9374
|
+
}
|
|
9375
|
+
catch (err) {
|
|
9199
9376
|
tiles.push({
|
|
9200
9377
|
tileId: item.i,
|
|
9201
9378
|
status: 'error',
|
|
9202
|
-
tileType: '
|
|
9203
|
-
title: item.title ??
|
|
9204
|
-
error:
|
|
9205
|
-
trustState: 'review_required',
|
|
9206
|
-
repair: repaired.repair,
|
|
9207
|
-
artifact: {
|
|
9208
|
-
version: 1,
|
|
9209
|
-
sourceKind: 'draft_analysis',
|
|
9210
|
-
name: item.title ?? item.i,
|
|
9211
|
-
sourcePath: draftAppRelativePath,
|
|
9212
|
-
dql: draftSource.slice(0, 40_000),
|
|
9213
|
-
...(draftRepairPlan?.sql ? { sql: draftRepairPlan.sql.slice(0, 40_000) } : {}),
|
|
9214
|
-
trustState: 'review_required',
|
|
9215
|
-
explanation: ['This App-scoped analysis remains review-required.', 'Automatic repair could not produce a safe executable result.'],
|
|
9216
|
-
executionTarget: executionTargetDescriptor(body),
|
|
9217
|
-
},
|
|
9379
|
+
tileType: 'aiPin',
|
|
9380
|
+
title: item.title ?? pin.title,
|
|
9381
|
+
error: err instanceof Error ? err.message : String(err),
|
|
9382
|
+
trustState: pinArtifact.trustState ?? 'review_required',
|
|
9218
9383
|
});
|
|
9219
9384
|
continue;
|
|
9220
9385
|
}
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9386
|
+
}
|
|
9387
|
+
else if (pin.refreshCadence === 'daily' && pin.sql && isAiPinRefreshDue(pin.lastRefreshedAt)) {
|
|
9388
|
+
try {
|
|
9389
|
+
const refreshed = await executeLocalSqlForStoredResult(pin.sql);
|
|
9390
|
+
pin = localApps.current.updateAiPinResult(pin.id, refreshed) ?? pin;
|
|
9391
|
+
}
|
|
9392
|
+
catch (err) {
|
|
9393
|
+
pin = localApps.current.updateAiPinResult(pin.id, pin.result, err instanceof Error ? err.message : String(err)) ?? pin;
|
|
9394
|
+
}
|
|
9225
9395
|
}
|
|
9226
9396
|
tiles.push({
|
|
9227
9397
|
tileId: item.i,
|
|
9228
9398
|
status: 'ok',
|
|
9229
|
-
tileType: '
|
|
9230
|
-
title: item.title ??
|
|
9399
|
+
tileType: 'aiPin',
|
|
9400
|
+
title: item.title ?? pin.title,
|
|
9231
9401
|
viz: item.viz,
|
|
9232
|
-
chartConfig: mergeDashboardChartConfig(
|
|
9233
|
-
result,
|
|
9234
|
-
|
|
9235
|
-
citation: {
|
|
9236
|
-
|
|
9402
|
+
chartConfig: mergeDashboardChartConfig(renderedPinChartConfig, item),
|
|
9403
|
+
result: renderedPinResult,
|
|
9404
|
+
aiPin: pin,
|
|
9405
|
+
citation: {
|
|
9406
|
+
kind: 'ai_pin',
|
|
9407
|
+
name: pin.title,
|
|
9408
|
+
},
|
|
9237
9409
|
artifact: {
|
|
9238
9410
|
version: 1,
|
|
9239
|
-
sourceKind: '
|
|
9240
|
-
name:
|
|
9241
|
-
sourcePath:
|
|
9242
|
-
dql:
|
|
9243
|
-
...(
|
|
9244
|
-
trustState: '
|
|
9411
|
+
sourceKind: 'ai_pin',
|
|
9412
|
+
name: pinArtifact?.name ?? pin.title,
|
|
9413
|
+
...(pinArtifact?.sourcePath ? { sourcePath: pinArtifact.sourcePath } : {}),
|
|
9414
|
+
...(pinArtifact?.source ? { dql: pinArtifact.source.slice(0, 40_000) } : {}),
|
|
9415
|
+
...(pinArtifact?.compiledSql || pin.sql ? { sql: (pinArtifact?.compiledSql ?? pin.sql).slice(0, 40_000) } : {}),
|
|
9416
|
+
trustState: pinArtifact?.trustState === 'certified' || pin.certification === 'certified'
|
|
9417
|
+
? 'certified'
|
|
9418
|
+
: 'review_required',
|
|
9245
9419
|
explanation: [
|
|
9246
|
-
'This
|
|
9247
|
-
|
|
9420
|
+
'This result comes from a saved App insight.',
|
|
9421
|
+
pinArtifact ? 'The DQL artifact was executed again with the current App parameters.' : 'The saved bounded result is displayed with its original trust state.',
|
|
9248
9422
|
],
|
|
9249
9423
|
executionTarget: executionTargetDescriptor(body),
|
|
9250
9424
|
},
|
|
9251
9425
|
});
|
|
9426
|
+
continue;
|
|
9252
9427
|
}
|
|
9253
|
-
|
|
9254
|
-
|
|
9255
|
-
|
|
9256
|
-
|
|
9257
|
-
|
|
9258
|
-
|
|
9259
|
-
|
|
9260
|
-
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
|
|
9264
|
-
|
|
9265
|
-
|
|
9266
|
-
|
|
9428
|
+
if (item.draftAnalysis) {
|
|
9429
|
+
let draftSource;
|
|
9430
|
+
let draftAppRelativePath;
|
|
9431
|
+
let draftParameters = {};
|
|
9432
|
+
let draftTargetConnection;
|
|
9433
|
+
let draftRepairPlan;
|
|
9434
|
+
try {
|
|
9435
|
+
const localDraftPath = loaded.draftArtifactsDir
|
|
9436
|
+
? join(loaded.draftArtifactsDir, item.draftAnalysis.ref)
|
|
9437
|
+
: undefined;
|
|
9438
|
+
const draftRoot = localDraftPath && existsSync(localDraftPath)
|
|
9439
|
+
? loaded.draftArtifactsDir
|
|
9440
|
+
: loaded.appDir;
|
|
9441
|
+
const draftPath = join(draftRoot, item.draftAnalysis.ref);
|
|
9442
|
+
draftAppRelativePath = relative(draftRoot, draftPath);
|
|
9443
|
+
if (draftAppRelativePath.startsWith('..') || draftAppRelativePath.startsWith('/')) {
|
|
9444
|
+
throw new Error('App-scoped analysis reference escapes the App directory.');
|
|
9445
|
+
}
|
|
9446
|
+
draftSource = readFileSync(draftPath, 'utf-8');
|
|
9447
|
+
const fingerprint = `sha256:${createHash('sha256').update(draftSource).digest('hex')}`;
|
|
9448
|
+
if (fingerprint !== item.draftAnalysis.artifactFingerprint) {
|
|
9449
|
+
throw new Error('App-scoped analysis changed after approval. Review the draft again.');
|
|
9450
|
+
}
|
|
9451
|
+
draftParameters = dashboardTileParameterValues({
|
|
9452
|
+
item,
|
|
9453
|
+
dashboardValues: dashboardVariables,
|
|
9454
|
+
requestValues: variables,
|
|
9455
|
+
});
|
|
9456
|
+
draftTargetConnection = isConnectionConfig(body.connection)
|
|
9457
|
+
? body.connection
|
|
9458
|
+
: connection ?? undefined;
|
|
9459
|
+
draftRepairPlan = buildExecutionPlan({ id: item.i, type: 'dql', source: draftSource, title: item.title ?? item.i }, {
|
|
9460
|
+
semanticLayer,
|
|
9461
|
+
driver: draftTargetConnection?.driver ?? 'duckdb',
|
|
9462
|
+
parameters: draftParameters,
|
|
9463
|
+
});
|
|
9464
|
+
let result;
|
|
9465
|
+
let repair;
|
|
9466
|
+
let repairedSql;
|
|
9467
|
+
let repairedDql;
|
|
9468
|
+
try {
|
|
9469
|
+
result = await executeDqlArtifactSourceForAgent(draftSource, { name: item.title ?? item.i, path: draftAppRelativePath }, { question: item.title, parameters: draftParameters });
|
|
9470
|
+
}
|
|
9471
|
+
catch (executionError) {
|
|
9472
|
+
const repaired = await repairFailedAppTileExecution({
|
|
9473
|
+
source: 'draft_analysis',
|
|
9474
|
+
question: item.title ?? item.i,
|
|
9475
|
+
sourceSql: draftRepairPlan?.sql,
|
|
9476
|
+
sourceDql: draftSource,
|
|
9477
|
+
artifactName: item.title ?? item.i,
|
|
9478
|
+
error: executionError,
|
|
9479
|
+
targetConnection: draftTargetConnection,
|
|
9480
|
+
targetConnectionName: projectConfig.defaultConnectionName,
|
|
9481
|
+
sqlParams: draftRepairPlan?.sqlParams,
|
|
9482
|
+
variables: { ...(draftRepairPlan?.variables ?? {}), ...draftParameters },
|
|
9483
|
+
});
|
|
9484
|
+
if (!repaired.ok) {
|
|
9485
|
+
tiles.push({
|
|
9486
|
+
tileId: item.i,
|
|
9487
|
+
status: 'error',
|
|
9488
|
+
tileType: 'draftAnalysis',
|
|
9489
|
+
title: item.title ?? item.i,
|
|
9490
|
+
error: executionError instanceof Error ? executionError.message : String(executionError),
|
|
9491
|
+
trustState: 'review_required',
|
|
9492
|
+
repair: repaired.repair,
|
|
9493
|
+
artifact: {
|
|
9494
|
+
version: 1,
|
|
9495
|
+
sourceKind: 'draft_analysis',
|
|
9496
|
+
name: item.title ?? item.i,
|
|
9497
|
+
sourcePath: draftAppRelativePath,
|
|
9498
|
+
dql: draftSource.slice(0, 40_000),
|
|
9499
|
+
...(draftRepairPlan?.sql ? { sql: draftRepairPlan.sql.slice(0, 40_000) } : {}),
|
|
9500
|
+
trustState: 'review_required',
|
|
9501
|
+
explanation: ['This App-scoped analysis remains review-required.', 'Automatic repair could not produce a safe executable result.'],
|
|
9502
|
+
executionTarget: executionTargetDescriptor(body),
|
|
9503
|
+
},
|
|
9504
|
+
});
|
|
9505
|
+
continue;
|
|
9506
|
+
}
|
|
9507
|
+
result = repaired.result;
|
|
9508
|
+
repair = repaired.repair;
|
|
9509
|
+
repairedSql = repaired.repairedSql;
|
|
9510
|
+
repairedDql = repaired.repairedDql;
|
|
9511
|
+
}
|
|
9267
9512
|
tiles.push({
|
|
9268
9513
|
tileId: item.i,
|
|
9269
9514
|
status: 'ok',
|
|
9270
9515
|
tileType: 'draftAnalysis',
|
|
9271
9516
|
title: item.title ?? item.i,
|
|
9272
9517
|
viz: item.viz,
|
|
9273
|
-
chartConfig: mergeDashboardChartConfig(
|
|
9274
|
-
result
|
|
9518
|
+
chartConfig: mergeDashboardChartConfig(result.chartConfig, item),
|
|
9519
|
+
result,
|
|
9275
9520
|
trustState: 'review_required',
|
|
9276
|
-
citation: { kind: 'app_draft_analysis_repaired', name: item.title ?? item.i, path: draftAppRelativePath },
|
|
9277
|
-
repair:
|
|
9521
|
+
citation: { kind: repair ? 'app_draft_analysis_repaired' : 'app_draft_analysis', name: item.title ?? item.i, path: draftAppRelativePath },
|
|
9522
|
+
...(repair ? { repair } : {}),
|
|
9278
9523
|
artifact: {
|
|
9279
9524
|
version: 1,
|
|
9280
9525
|
sourceKind: 'draft_analysis',
|
|
9281
9526
|
name: item.title ?? item.i,
|
|
9282
|
-
|
|
9283
|
-
dql: (
|
|
9284
|
-
sql:
|
|
9527
|
+
sourcePath: draftAppRelativePath,
|
|
9528
|
+
dql: (repairedDql ?? draftSource).slice(0, 40_000),
|
|
9529
|
+
...(repairedSql ?? draftRepairPlan?.sql ? { sql: (repairedSql ?? draftRepairPlan.sql).slice(0, 40_000) } : {}),
|
|
9285
9530
|
trustState: 'review_required',
|
|
9286
|
-
explanation: [
|
|
9531
|
+
explanation: [
|
|
9532
|
+
'This is private App analysis and remains review-required.',
|
|
9533
|
+
repair ? 'A bounded repair ran on the original data target without changing the saved source file.' : 'The saved App-scoped DQL was executed with the current filters.',
|
|
9534
|
+
],
|
|
9287
9535
|
executionTarget: executionTargetDescriptor(body),
|
|
9288
9536
|
},
|
|
9289
9537
|
});
|
|
9290
|
-
continue;
|
|
9291
|
-
}
|
|
9292
|
-
tiles.push({
|
|
9293
|
-
tileId: item.i,
|
|
9294
|
-
status: 'error',
|
|
9295
|
-
tileType: 'draftAnalysis',
|
|
9296
|
-
title: item.title ?? item.i,
|
|
9297
|
-
error: err instanceof Error ? err.message : String(err),
|
|
9298
|
-
trustState: 'review_required',
|
|
9299
|
-
repair: repaired.repair,
|
|
9300
|
-
...(draftSource ? {
|
|
9301
|
-
artifact: {
|
|
9302
|
-
version: 1,
|
|
9303
|
-
sourceKind: 'draft_analysis',
|
|
9304
|
-
name: item.title ?? item.i,
|
|
9305
|
-
...(draftAppRelativePath ? { sourcePath: draftAppRelativePath } : {}),
|
|
9306
|
-
dql: draftSource.slice(0, 40_000),
|
|
9307
|
-
...(draftRepairPlan?.sql ? { sql: draftRepairPlan.sql.slice(0, 40_000) } : {}),
|
|
9308
|
-
trustState: 'review_required',
|
|
9309
|
-
explanation: ['Automatic repair could not produce a safe executable result. The saved App analysis was not changed.'],
|
|
9310
|
-
executionTarget: executionTargetDescriptor(body),
|
|
9311
|
-
},
|
|
9312
|
-
} : {}),
|
|
9313
|
-
});
|
|
9314
|
-
}
|
|
9315
|
-
continue;
|
|
9316
|
-
}
|
|
9317
|
-
if (item.semantic) {
|
|
9318
|
-
try {
|
|
9319
|
-
if (!semanticLayer)
|
|
9320
|
-
throw new Error('Semantic layer is not available for this governed query.');
|
|
9321
|
-
const knownMetrics = new Set(semanticLayer.listMetrics().map((metric) => metric.name));
|
|
9322
|
-
const missingMetrics = item.semantic.metrics.filter((metric) => !knownMetrics.has(metric));
|
|
9323
|
-
if (missingMetrics.length)
|
|
9324
|
-
throw new Error(`Semantic definition changed; missing metric(s): ${missingMetrics.join(', ')}`);
|
|
9325
|
-
const knownModels = new Set(semanticLayer.listSemanticModels().map((model) => model.name));
|
|
9326
|
-
const missingModels = item.semantic.semanticModelRefs.filter((model) => !knownModels.has(model));
|
|
9327
|
-
if (missingModels.length)
|
|
9328
|
-
throw new Error(`Semantic definition changed; missing model(s): ${missingModels.join(', ')}`);
|
|
9329
|
-
const targetConnection = requireActiveConnection(isConnectionConfig(body.connection) ? body.connection : connection);
|
|
9330
|
-
const tableMapping = await resolveSemanticTableMapping(executor, targetConnection, semanticLayer, projectRoot);
|
|
9331
|
-
const activeFilters = Object.entries(dashboardVariables)
|
|
9332
|
-
.filter(([field, value]) => item.semantic.dimensions?.includes(field) && value !== undefined && value !== null && value !== '')
|
|
9333
|
-
.map(([dimension, value]) => ({ dimension, operator: 'equals', values: Array.isArray(value) ? value.map(String) : [String(value)] }));
|
|
9334
|
-
const staticFilters = (item.semantic.filters ?? []).map((filter) => ({
|
|
9335
|
-
dimension: filter.field,
|
|
9336
|
-
operator: filter.operator,
|
|
9337
|
-
values: Array.isArray(filter.value) ? filter.value.map(String) : [String(filter.value)],
|
|
9338
|
-
}));
|
|
9339
|
-
const composed = await composeRuntimeSemanticQuery({
|
|
9340
|
-
metrics: item.semantic.metrics,
|
|
9341
|
-
dimensions: item.semantic.dimensions ?? [],
|
|
9342
|
-
filters: [...staticFilters, ...activeFilters],
|
|
9343
|
-
...(item.semantic.timeDimension ? { timeDimension: { name: item.semantic.timeDimension, granularity: 'month' } } : {}),
|
|
9344
|
-
orderBy: item.semantic.orderBy?.map((order) => ({ name: order.field, direction: order.direction })),
|
|
9345
|
-
limit: item.semantic.limit,
|
|
9346
|
-
engine: item.semantic.provider,
|
|
9347
|
-
}, semanticLayer, {
|
|
9348
|
-
projectRoot,
|
|
9349
|
-
projectConfig,
|
|
9350
|
-
detectedProvider: semanticDetectedProvider,
|
|
9351
|
-
driver: targetConnection.driver,
|
|
9352
|
-
tableMapping,
|
|
9353
|
-
});
|
|
9354
|
-
if (!composed)
|
|
9355
|
-
throw new Error('The governed semantic query could not be composed.');
|
|
9356
|
-
const prepared = prepareLocalExecution(composed.sql, targetConnection, projectRoot, projectConfig);
|
|
9357
|
-
let normalizedResult;
|
|
9358
|
-
let repair;
|
|
9359
|
-
let executedSql = composed.sql;
|
|
9360
|
-
try {
|
|
9361
|
-
const result = await executor.executeQuery(prepared.sql, [], {}, prepared.connection);
|
|
9362
|
-
normalizedResult = normalizeQueryResult(result);
|
|
9363
9538
|
}
|
|
9364
|
-
catch (
|
|
9539
|
+
catch (err) {
|
|
9365
9540
|
const repaired = await repairFailedAppTileExecution({
|
|
9366
|
-
source: '
|
|
9367
|
-
question: item.title ?? item.
|
|
9368
|
-
sourceSql:
|
|
9369
|
-
|
|
9370
|
-
|
|
9541
|
+
source: 'draft_analysis',
|
|
9542
|
+
question: item.title ?? item.i,
|
|
9543
|
+
sourceSql: draftRepairPlan?.sql,
|
|
9544
|
+
sourceDql: draftSource,
|
|
9545
|
+
artifactName: item.title ?? item.i,
|
|
9546
|
+
error: err,
|
|
9547
|
+
targetConnection: draftTargetConnection,
|
|
9548
|
+
targetConnectionName: projectConfig.defaultConnectionName,
|
|
9549
|
+
sqlParams: draftRepairPlan?.sqlParams,
|
|
9550
|
+
variables: { ...(draftRepairPlan?.variables ?? {}), ...draftParameters },
|
|
9371
9551
|
});
|
|
9372
|
-
if (
|
|
9552
|
+
if (repaired.ok && draftSource) {
|
|
9373
9553
|
tiles.push({
|
|
9374
9554
|
tileId: item.i,
|
|
9375
|
-
status: '
|
|
9376
|
-
tileType: '
|
|
9377
|
-
title: item.title ?? item.
|
|
9378
|
-
|
|
9555
|
+
status: 'ok',
|
|
9556
|
+
tileType: 'draftAnalysis',
|
|
9557
|
+
title: item.title ?? item.i,
|
|
9558
|
+
viz: item.viz,
|
|
9559
|
+
chartConfig: mergeDashboardChartConfig(repaired.result.chartConfig, item),
|
|
9560
|
+
result: repaired.result,
|
|
9379
9561
|
trustState: 'review_required',
|
|
9562
|
+
citation: { kind: 'app_draft_analysis_repaired', name: item.title ?? item.i, path: draftAppRelativePath },
|
|
9380
9563
|
repair: repaired.repair,
|
|
9381
9564
|
artifact: {
|
|
9382
9565
|
version: 1,
|
|
9383
|
-
sourceKind: '
|
|
9384
|
-
name: item.title ?? item.
|
|
9385
|
-
|
|
9566
|
+
sourceKind: 'draft_analysis',
|
|
9567
|
+
name: item.title ?? item.i,
|
|
9568
|
+
...(draftAppRelativePath ? { sourcePath: draftAppRelativePath } : {}),
|
|
9569
|
+
dql: (repaired.repairedDql ?? draftSource).slice(0, 40_000),
|
|
9570
|
+
sql: repaired.repairedSql.slice(0, 40_000),
|
|
9386
9571
|
trustState: 'review_required',
|
|
9387
|
-
explanation: ['
|
|
9572
|
+
explanation: ['A bounded DQL repair executed on the original data target. The saved App analysis was not changed.'],
|
|
9388
9573
|
executionTarget: executionTargetDescriptor(body),
|
|
9389
9574
|
},
|
|
9390
9575
|
});
|
|
9391
9576
|
continue;
|
|
9392
9577
|
}
|
|
9393
|
-
|
|
9394
|
-
|
|
9395
|
-
|
|
9396
|
-
|
|
9397
|
-
|
|
9398
|
-
|
|
9399
|
-
status: 'ok',
|
|
9400
|
-
tileType: 'semantic',
|
|
9401
|
-
title: item.title ?? item.semantic.id,
|
|
9402
|
-
viz: item.viz,
|
|
9403
|
-
result: normalizedResult,
|
|
9404
|
-
trustState: 'review_required',
|
|
9405
|
-
citation: { kind: repair ? 'semantic_query_repaired' : 'semantic_query', name: item.semantic.id },
|
|
9406
|
-
...(repair ? { repair } : {}),
|
|
9407
|
-
artifact: {
|
|
9408
|
-
version: 1,
|
|
9409
|
-
sourceKind: 'semantic_query',
|
|
9410
|
-
name: item.title ?? item.semantic.id,
|
|
9411
|
-
sql: executedSql.slice(0, 40_000),
|
|
9578
|
+
tiles.push({
|
|
9579
|
+
tileId: item.i,
|
|
9580
|
+
status: 'error',
|
|
9581
|
+
tileType: 'draftAnalysis',
|
|
9582
|
+
title: item.title ?? item.i,
|
|
9583
|
+
error: err instanceof Error ? err.message : String(err),
|
|
9412
9584
|
trustState: 'review_required',
|
|
9413
|
-
|
|
9414
|
-
|
|
9415
|
-
|
|
9416
|
-
|
|
9417
|
-
|
|
9418
|
-
|
|
9419
|
-
|
|
9420
|
-
|
|
9421
|
-
|
|
9422
|
-
|
|
9423
|
-
|
|
9424
|
-
|
|
9425
|
-
|
|
9426
|
-
|
|
9427
|
-
|
|
9428
|
-
|
|
9429
|
-
trustState: 'review_required',
|
|
9430
|
-
});
|
|
9431
|
-
}
|
|
9432
|
-
continue;
|
|
9433
|
-
}
|
|
9434
|
-
const block = resolveDashboardItemBlock(item, manifest);
|
|
9435
|
-
if (!block) {
|
|
9436
|
-
tiles.push({
|
|
9437
|
-
tileId: item.i,
|
|
9438
|
-
status: 'unresolved',
|
|
9439
|
-
blockRef: item.block ? (isBlockIdRef(item.block) ? item.block.blockId : item.block.ref) : '(missing source)',
|
|
9440
|
-
error: 'Block reference could not be resolved',
|
|
9441
|
-
});
|
|
9442
|
-
continue;
|
|
9443
|
-
}
|
|
9444
|
-
let blockSource;
|
|
9445
|
-
let blockTargetConnection;
|
|
9446
|
-
let blockPlan;
|
|
9447
|
-
let blockFilterApplication;
|
|
9448
|
-
let blockInvocation;
|
|
9449
|
-
try {
|
|
9450
|
-
assertAppAccess({
|
|
9451
|
-
app: loaded.app,
|
|
9452
|
-
domain: block.domain ?? loaded.dashboard.metadata.domain ?? loaded.app.domain,
|
|
9453
|
-
level: 'execute',
|
|
9454
|
-
});
|
|
9455
|
-
const absBlockPath = join(projectRoot, block.filePath);
|
|
9456
|
-
blockSource = readFileSync(absBlockPath, 'utf-8');
|
|
9457
|
-
blockTargetConnection = requireActiveConnection(isConnectionConfig(body.connection) ? body.connection : connection);
|
|
9458
|
-
const tableMapping = await resolveSemanticTableMapping(executor, blockTargetConnection, semanticLayer, projectRoot);
|
|
9459
|
-
const boundParameters = dashboardTileParameterValues({
|
|
9460
|
-
item,
|
|
9461
|
-
dashboardValues: dashboardVariables,
|
|
9462
|
-
requestValues: variables,
|
|
9463
|
-
});
|
|
9464
|
-
blockInvocation = prepareBlockInvocation({
|
|
9465
|
-
block: block.name,
|
|
9466
|
-
source: blockSource,
|
|
9467
|
-
parameters: { ...dashboardVariables, ...boundParameters },
|
|
9468
|
-
parameterSources: Object.fromEntries(Object.keys(boundParameters).map((name) => [name, 'surface'])),
|
|
9469
|
-
surface: 'app',
|
|
9470
|
-
});
|
|
9471
|
-
if (blockInvocation.errors.length || blockInvocation.unresolvedParameters.length) {
|
|
9472
|
-
tiles.push({
|
|
9473
|
-
tileId: item.i,
|
|
9474
|
-
status: 'unresolved',
|
|
9475
|
-
tileType: 'block',
|
|
9476
|
-
blockId: block.name,
|
|
9477
|
-
title: item.title ?? block.name,
|
|
9478
|
-
error: blockInvocation.errors[0] ?? `Needs values for: ${blockInvocation.unresolvedParameters.join(', ')}`,
|
|
9479
|
-
invocation: {
|
|
9480
|
-
resolvedParameters: blockInvocation.resolvedParameters,
|
|
9481
|
-
unresolvedParameters: blockInvocation.unresolvedParameters,
|
|
9482
|
-
auditId: blockInvocation.auditId,
|
|
9483
|
-
},
|
|
9484
|
-
});
|
|
9585
|
+
repair: repaired.repair,
|
|
9586
|
+
...(draftSource ? {
|
|
9587
|
+
artifact: {
|
|
9588
|
+
version: 1,
|
|
9589
|
+
sourceKind: 'draft_analysis',
|
|
9590
|
+
name: item.title ?? item.i,
|
|
9591
|
+
...(draftAppRelativePath ? { sourcePath: draftAppRelativePath } : {}),
|
|
9592
|
+
dql: draftSource.slice(0, 40_000),
|
|
9593
|
+
...(draftRepairPlan?.sql ? { sql: draftRepairPlan.sql.slice(0, 40_000) } : {}),
|
|
9594
|
+
trustState: 'review_required',
|
|
9595
|
+
explanation: ['Automatic repair could not produce a safe executable result. The saved App analysis was not changed.'],
|
|
9596
|
+
executionTarget: executionTargetDescriptor(body),
|
|
9597
|
+
},
|
|
9598
|
+
} : {}),
|
|
9599
|
+
});
|
|
9600
|
+
}
|
|
9485
9601
|
continue;
|
|
9486
9602
|
}
|
|
9487
|
-
|
|
9488
|
-
|
|
9489
|
-
|
|
9490
|
-
|
|
9491
|
-
|
|
9492
|
-
|
|
9493
|
-
|
|
9494
|
-
|
|
9495
|
-
|
|
9496
|
-
|
|
9497
|
-
|
|
9498
|
-
|
|
9499
|
-
|
|
9500
|
-
|
|
9501
|
-
|
|
9502
|
-
|
|
9503
|
-
|
|
9504
|
-
|
|
9505
|
-
|
|
9506
|
-
|
|
9507
|
-
|
|
9508
|
-
|
|
9509
|
-
|
|
9510
|
-
|
|
9603
|
+
if (item.semantic) {
|
|
9604
|
+
try {
|
|
9605
|
+
if (!semanticLayer)
|
|
9606
|
+
throw new Error('Semantic layer is not available for this governed query.');
|
|
9607
|
+
const knownMetrics = new Set(semanticLayer.listMetrics().map((metric) => metric.name));
|
|
9608
|
+
const missingMetrics = item.semantic.metrics.filter((metric) => !knownMetrics.has(metric));
|
|
9609
|
+
if (missingMetrics.length)
|
|
9610
|
+
throw new Error(`Semantic definition changed; missing metric(s): ${missingMetrics.join(', ')}`);
|
|
9611
|
+
const knownModels = new Set(semanticLayer.listSemanticModels().map((model) => model.name));
|
|
9612
|
+
const missingModels = item.semantic.semanticModelRefs.filter((model) => !knownModels.has(model));
|
|
9613
|
+
if (missingModels.length)
|
|
9614
|
+
throw new Error(`Semantic definition changed; missing model(s): ${missingModels.join(', ')}`);
|
|
9615
|
+
const targetConnection = requireActiveConnection(isConnectionConfig(body.connection) ? body.connection : connection);
|
|
9616
|
+
const tableMapping = await resolveSemanticTableMapping(executor, targetConnection, semanticLayer, projectRoot);
|
|
9617
|
+
const activeFilters = dashboardSemanticFiltersForTile(loaded.dashboard, item, dashboardVariables);
|
|
9618
|
+
const staticFilters = (item.semantic.filters ?? []).map((filter) => ({
|
|
9619
|
+
dimension: filter.field,
|
|
9620
|
+
operator: filter.operator,
|
|
9621
|
+
values: Array.isArray(filter.value) ? filter.value.map(String) : [String(filter.value)],
|
|
9622
|
+
}));
|
|
9623
|
+
const composed = await composeRuntimeSemanticQuery({
|
|
9624
|
+
metrics: item.semantic.metrics,
|
|
9625
|
+
dimensions: item.semantic.dimensions ?? [],
|
|
9626
|
+
filters: [...staticFilters, ...activeFilters],
|
|
9627
|
+
...(item.semantic.timeDimension ? { timeDimension: { name: item.semantic.timeDimension, granularity: 'month' } } : {}),
|
|
9628
|
+
orderBy: item.semantic.orderBy?.map((order) => ({ name: order.field, direction: order.direction })),
|
|
9629
|
+
limit: item.semantic.limit,
|
|
9630
|
+
engine: item.semantic.provider,
|
|
9631
|
+
}, semanticLayer, {
|
|
9632
|
+
projectRoot,
|
|
9633
|
+
projectConfig,
|
|
9634
|
+
detectedProvider: semanticDetectedProvider,
|
|
9635
|
+
driver: targetConnection.driver,
|
|
9636
|
+
tableMapping,
|
|
9637
|
+
});
|
|
9638
|
+
if (!composed)
|
|
9639
|
+
throw new Error('The governed semantic query could not be composed.');
|
|
9640
|
+
const prepared = prepareLocalExecution(composed.sql, targetConnection, projectRoot, projectConfig);
|
|
9641
|
+
let normalizedResult;
|
|
9642
|
+
let repair;
|
|
9643
|
+
let executedSql = composed.sql;
|
|
9644
|
+
try {
|
|
9645
|
+
const result = await executor.executeQuery(prepared.sql, [], {}, prepared.connection);
|
|
9646
|
+
normalizedResult = normalizeQueryResult(result);
|
|
9647
|
+
}
|
|
9648
|
+
catch (executionError) {
|
|
9649
|
+
const repaired = await repairFailedAppTileExecution({
|
|
9650
|
+
source: 'semantic_query',
|
|
9651
|
+
question: item.title ?? item.semantic.id,
|
|
9652
|
+
sourceSql: composed.sql,
|
|
9653
|
+
error: executionError,
|
|
9654
|
+
targetConnection,
|
|
9655
|
+
});
|
|
9656
|
+
if (!repaired.ok) {
|
|
9657
|
+
tiles.push({
|
|
9658
|
+
tileId: item.i,
|
|
9659
|
+
status: 'error',
|
|
9660
|
+
tileType: 'semantic',
|
|
9661
|
+
title: item.title ?? item.semantic.id,
|
|
9662
|
+
error: executionError instanceof Error ? executionError.message : String(executionError),
|
|
9663
|
+
trustState: 'review_required',
|
|
9664
|
+
repair: repaired.repair,
|
|
9665
|
+
artifact: {
|
|
9666
|
+
version: 1,
|
|
9667
|
+
sourceKind: 'semantic_query',
|
|
9668
|
+
name: item.title ?? item.semantic.id,
|
|
9669
|
+
sql: composed.sql.slice(0, 40_000),
|
|
9670
|
+
trustState: 'review_required',
|
|
9671
|
+
explanation: ['Automatic repair could not execute the governed semantic query safely.'],
|
|
9672
|
+
executionTarget: executionTargetDescriptor(body),
|
|
9673
|
+
},
|
|
9674
|
+
});
|
|
9675
|
+
continue;
|
|
9676
|
+
}
|
|
9677
|
+
normalizedResult = normalizeQueryResult(repaired.result);
|
|
9678
|
+
repair = repaired.repair;
|
|
9679
|
+
executedSql = repaired.repairedSql;
|
|
9680
|
+
}
|
|
9681
|
+
tiles.push({
|
|
9682
|
+
tileId: item.i,
|
|
9683
|
+
status: 'ok',
|
|
9684
|
+
tileType: 'semantic',
|
|
9685
|
+
title: item.title ?? item.semantic.id,
|
|
9686
|
+
viz: item.viz,
|
|
9687
|
+
result: normalizedResult,
|
|
9688
|
+
trustState: 'review_required',
|
|
9689
|
+
citation: { kind: repair ? 'semantic_query_repaired' : 'semantic_query', name: item.semantic.id },
|
|
9690
|
+
...(repair ? { repair } : {}),
|
|
9691
|
+
artifact: {
|
|
9692
|
+
version: 1,
|
|
9693
|
+
sourceKind: 'semantic_query',
|
|
9694
|
+
name: item.title ?? item.semantic.id,
|
|
9695
|
+
sql: executedSql.slice(0, 40_000),
|
|
9696
|
+
trustState: 'review_required',
|
|
9697
|
+
explanation: [
|
|
9698
|
+
`Metrics: ${item.semantic.metrics.join(', ') || 'none'}.`,
|
|
9699
|
+
`Dimensions: ${item.semantic.dimensions?.join(', ') || 'none'}.`,
|
|
9700
|
+
repair ? 'The composed SQL needed bounded repair and therefore remains review-required.' : 'The governed semantic intent was composed for the active warehouse and remains review-required until approval.',
|
|
9701
|
+
],
|
|
9702
|
+
executionTarget: executionTargetDescriptor(body),
|
|
9703
|
+
},
|
|
9704
|
+
});
|
|
9705
|
+
}
|
|
9706
|
+
catch (err) {
|
|
9707
|
+
tiles.push({
|
|
9708
|
+
tileId: item.i,
|
|
9709
|
+
status: 'error',
|
|
9710
|
+
tileType: 'semantic',
|
|
9711
|
+
title: item.title ?? item.semantic.id,
|
|
9712
|
+
error: err instanceof Error ? err.message : String(err),
|
|
9713
|
+
trustState: 'review_required',
|
|
9714
|
+
});
|
|
9715
|
+
}
|
|
9511
9716
|
continue;
|
|
9512
9717
|
}
|
|
9513
|
-
|
|
9514
|
-
|
|
9515
|
-
sqlParams: blockPlan?.sqlParams ?? [],
|
|
9516
|
-
variables: { ...(blockPlan?.variables ?? {}), ...dashboardVariables },
|
|
9517
|
-
block,
|
|
9518
|
-
dashboard: loaded.dashboard,
|
|
9519
|
-
});
|
|
9520
|
-
const prepared = prepareLocalExecution(blockFilterApplication.sql, blockTargetConnection, projectRoot, projectConfig);
|
|
9521
|
-
const result = await executor.executeQuery(prepared.sql, blockFilterApplication.sqlParams, runtimeVariables({ ...blockFilterApplication.variables, ...blockInvocation.values }), prepared.connection);
|
|
9522
|
-
tiles.push({
|
|
9523
|
-
tileId: item.i,
|
|
9524
|
-
status: 'ok',
|
|
9525
|
-
blockId: block.name,
|
|
9526
|
-
blockPath: block.filePath,
|
|
9527
|
-
certificationStatus: block.status ?? null,
|
|
9528
|
-
title: item.title ?? block.name,
|
|
9529
|
-
viz: item.viz,
|
|
9530
|
-
chartConfig: mergeDashboardChartConfig(blockPlan?.chartConfig, item),
|
|
9531
|
-
result: normalizeQueryResult(result),
|
|
9532
|
-
filters: {
|
|
9533
|
-
applied: blockFilterApplication.appliedFilters,
|
|
9534
|
-
skipped: blockFilterApplication.skippedFilters,
|
|
9535
|
-
},
|
|
9536
|
-
invocation: {
|
|
9537
|
-
resolvedParameters: blockInvocation.resolvedParameters,
|
|
9538
|
-
unresolvedParameters: blockInvocation.unresolvedParameters,
|
|
9539
|
-
auditId: blockInvocation.auditId,
|
|
9540
|
-
},
|
|
9541
|
-
trustState: block.status === 'certified' ? 'certified' : 'review_required',
|
|
9542
|
-
citation: {
|
|
9543
|
-
kind: 'block',
|
|
9544
|
-
name: block.name,
|
|
9545
|
-
path: block.filePath,
|
|
9546
|
-
},
|
|
9547
|
-
artifact: {
|
|
9548
|
-
version: 1,
|
|
9549
|
-
sourceKind: 'certified_block',
|
|
9550
|
-
name: block.name,
|
|
9551
|
-
sourcePath: block.filePath,
|
|
9552
|
-
dql: blockSource.slice(0, 40_000),
|
|
9553
|
-
sql: blockFilterApplication.sql.slice(0, 40_000),
|
|
9554
|
-
trustState: block.status === 'certified' ? 'certified' : 'review_required',
|
|
9555
|
-
explanation: [
|
|
9556
|
-
block.status === 'certified' ? 'This tile executed a certified project block.' : 'This project block is not certified and remains review-required.',
|
|
9557
|
-
'Dashboard filters and typed parameters were bound before read-only execution.',
|
|
9558
|
-
],
|
|
9559
|
-
executionTarget: executionTargetDescriptor(body),
|
|
9560
|
-
},
|
|
9561
|
-
});
|
|
9562
|
-
}
|
|
9563
|
-
catch (err) {
|
|
9564
|
-
if (err instanceof DQLAccessDeniedError) {
|
|
9718
|
+
const block = resolveDashboardItemBlock(item, manifest);
|
|
9719
|
+
if (!block) {
|
|
9565
9720
|
tiles.push({
|
|
9566
9721
|
tileId: item.i,
|
|
9567
|
-
status: '
|
|
9568
|
-
blockId: block.
|
|
9569
|
-
error:
|
|
9722
|
+
status: 'unresolved',
|
|
9723
|
+
blockRef: item.block ? (isBlockIdRef(item.block) ? item.block.blockId : item.block.ref) : '(missing source)',
|
|
9724
|
+
error: 'Block reference could not be resolved',
|
|
9570
9725
|
});
|
|
9726
|
+
continue;
|
|
9571
9727
|
}
|
|
9572
|
-
|
|
9573
|
-
|
|
9574
|
-
|
|
9575
|
-
|
|
9576
|
-
|
|
9577
|
-
|
|
9578
|
-
|
|
9579
|
-
|
|
9580
|
-
|
|
9581
|
-
|
|
9582
|
-
sqlParams: blockFilterApplication?.sqlParams ?? blockPlan?.sqlParams,
|
|
9583
|
-
variables: {
|
|
9584
|
-
...(blockPlan?.variables ?? {}),
|
|
9585
|
-
...(blockFilterApplication?.variables ?? {}),
|
|
9586
|
-
...(blockInvocation?.values ?? {}),
|
|
9587
|
-
},
|
|
9728
|
+
let blockSource;
|
|
9729
|
+
let blockTargetConnection;
|
|
9730
|
+
let blockPlan;
|
|
9731
|
+
let blockFilterApplication;
|
|
9732
|
+
let blockInvocation;
|
|
9733
|
+
try {
|
|
9734
|
+
assertAppAccess({
|
|
9735
|
+
app: loaded.app,
|
|
9736
|
+
domain: block.domain ?? loaded.dashboard.metadata.domain ?? loaded.app.domain,
|
|
9737
|
+
level: 'execute',
|
|
9588
9738
|
});
|
|
9589
|
-
|
|
9739
|
+
const absBlockPath = join(projectRoot, block.filePath);
|
|
9740
|
+
blockSource = readFileSync(absBlockPath, 'utf-8');
|
|
9741
|
+
const currentSourceRevision = `sha256:${createHash('sha256').update(blockSource).digest('hex')}`;
|
|
9742
|
+
if (item.sourceRevision && item.sourceRevision !== currentSourceRevision) {
|
|
9590
9743
|
tiles.push({
|
|
9591
9744
|
tileId: item.i,
|
|
9592
|
-
status: '
|
|
9745
|
+
status: 'error',
|
|
9593
9746
|
tileType: 'block',
|
|
9594
9747
|
blockId: block.name,
|
|
9595
9748
|
blockPath: block.filePath,
|
|
9596
9749
|
certificationStatus: block.status ?? null,
|
|
9597
9750
|
title: item.title ?? block.name,
|
|
9598
|
-
viz: item.viz,
|
|
9599
|
-
chartConfig: mergeDashboardChartConfig(blockPlan?.chartConfig, item),
|
|
9600
|
-
result: normalizeQueryResult(repaired.result),
|
|
9601
|
-
...(blockFilterApplication ? {
|
|
9602
|
-
filters: {
|
|
9603
|
-
applied: blockFilterApplication.appliedFilters,
|
|
9604
|
-
skipped: blockFilterApplication.skippedFilters,
|
|
9605
|
-
},
|
|
9606
|
-
} : {}),
|
|
9607
|
-
...(blockInvocation ? {
|
|
9608
|
-
invocation: {
|
|
9609
|
-
resolvedParameters: blockInvocation.resolvedParameters,
|
|
9610
|
-
unresolvedParameters: blockInvocation.unresolvedParameters,
|
|
9611
|
-
auditId: blockInvocation.auditId,
|
|
9612
|
-
},
|
|
9613
|
-
} : {}),
|
|
9614
9751
|
trustState: 'review_required',
|
|
9615
|
-
|
|
9616
|
-
|
|
9617
|
-
|
|
9618
|
-
|
|
9619
|
-
|
|
9620
|
-
|
|
9621
|
-
|
|
9622
|
-
|
|
9623
|
-
|
|
9624
|
-
|
|
9625
|
-
|
|
9626
|
-
|
|
9627
|
-
|
|
9628
|
-
|
|
9629
|
-
|
|
9752
|
+
error: 'This App source changed after it was selected. Refresh the source binding and rerun preview.',
|
|
9753
|
+
});
|
|
9754
|
+
continue;
|
|
9755
|
+
}
|
|
9756
|
+
blockTargetConnection = requireActiveConnection(isConnectionConfig(body.connection) ? body.connection : connection);
|
|
9757
|
+
const tableMapping = await resolveSemanticTableMapping(executor, blockTargetConnection, semanticLayer, projectRoot);
|
|
9758
|
+
const boundParameters = dashboardTileParameterValues({
|
|
9759
|
+
item,
|
|
9760
|
+
dashboardValues: dashboardVariables,
|
|
9761
|
+
requestValues: variables,
|
|
9762
|
+
});
|
|
9763
|
+
blockInvocation = prepareBlockInvocation({
|
|
9764
|
+
block: block.name,
|
|
9765
|
+
source: blockSource,
|
|
9766
|
+
parameters: { ...dashboardVariables, ...boundParameters },
|
|
9767
|
+
parameterSources: Object.fromEntries(Object.keys(boundParameters).map((name) => [name, 'surface'])),
|
|
9768
|
+
surface: 'app',
|
|
9769
|
+
});
|
|
9770
|
+
if (blockInvocation.errors.length || blockInvocation.unresolvedParameters.length) {
|
|
9771
|
+
tiles.push({
|
|
9772
|
+
tileId: item.i,
|
|
9773
|
+
status: 'unresolved',
|
|
9774
|
+
tileType: 'block',
|
|
9775
|
+
blockId: block.name,
|
|
9776
|
+
title: item.title ?? block.name,
|
|
9777
|
+
error: blockInvocation.errors[0] ?? `Needs values for: ${blockInvocation.unresolvedParameters.join(', ')}`,
|
|
9778
|
+
invocation: {
|
|
9779
|
+
resolvedParameters: blockInvocation.resolvedParameters,
|
|
9780
|
+
unresolvedParameters: blockInvocation.unresolvedParameters,
|
|
9781
|
+
auditId: blockInvocation.auditId,
|
|
9630
9782
|
},
|
|
9631
9783
|
});
|
|
9632
9784
|
continue;
|
|
9633
9785
|
}
|
|
9786
|
+
const semanticCompose = semanticLayer
|
|
9787
|
+
? await composeSemanticBlockSqlForRuntime(blockSource, semanticLayer, {
|
|
9788
|
+
driver: blockTargetConnection.driver,
|
|
9789
|
+
tableMapping,
|
|
9790
|
+
projectRoot,
|
|
9791
|
+
projectConfig,
|
|
9792
|
+
detectedProvider: semanticDetectedProvider,
|
|
9793
|
+
parameters: blockInvocation.values,
|
|
9794
|
+
})
|
|
9795
|
+
: null;
|
|
9796
|
+
blockPlan = buildExecutionPlan({ id: item.i, type: 'dql', source: blockSource, title: item.title ?? block.name }, {
|
|
9797
|
+
semanticLayer,
|
|
9798
|
+
driver: blockTargetConnection.driver,
|
|
9799
|
+
tableMapping,
|
|
9800
|
+
parameters: blockInvocation.values,
|
|
9801
|
+
semanticSql: semanticCompose?.sql ?? undefined,
|
|
9802
|
+
});
|
|
9803
|
+
if (!blockPlan && !semanticCompose?.sql) {
|
|
9804
|
+
tiles.push({
|
|
9805
|
+
tileId: item.i,
|
|
9806
|
+
status: 'error',
|
|
9807
|
+
blockId: block.name,
|
|
9808
|
+
error: semanticCompose?.diagnostics.find((diagnostic) => diagnostic.severity === 'error')?.message ?? 'Block produced no executable plan',
|
|
9809
|
+
});
|
|
9810
|
+
continue;
|
|
9811
|
+
}
|
|
9812
|
+
blockFilterApplication = applyDashboardFiltersToBlockExecution({
|
|
9813
|
+
sql: semanticCompose?.sql ?? blockPlan.sql,
|
|
9814
|
+
sqlParams: blockPlan?.sqlParams ?? [],
|
|
9815
|
+
variables: { ...(blockPlan?.variables ?? {}), ...dashboardVariables },
|
|
9816
|
+
block,
|
|
9817
|
+
dashboard: loaded.dashboard,
|
|
9818
|
+
tileId: item.i,
|
|
9819
|
+
tileFilterBindings: item.filterBindings,
|
|
9820
|
+
});
|
|
9821
|
+
const prepared = prepareLocalExecution(blockFilterApplication.sql, blockTargetConnection, projectRoot, projectConfig);
|
|
9822
|
+
const result = await executor.executeQuery(prepared.sql, blockFilterApplication.sqlParams, runtimeVariables({ ...blockFilterApplication.variables, ...blockInvocation.values }), prepared.connection);
|
|
9634
9823
|
tiles.push({
|
|
9635
9824
|
tileId: item.i,
|
|
9636
|
-
status: '
|
|
9637
|
-
tileType: 'block',
|
|
9825
|
+
status: 'ok',
|
|
9638
9826
|
blockId: block.name,
|
|
9639
|
-
|
|
9640
|
-
|
|
9641
|
-
|
|
9642
|
-
|
|
9643
|
-
|
|
9644
|
-
|
|
9645
|
-
|
|
9646
|
-
|
|
9647
|
-
|
|
9648
|
-
|
|
9649
|
-
|
|
9650
|
-
|
|
9651
|
-
|
|
9652
|
-
|
|
9653
|
-
}
|
|
9827
|
+
blockPath: block.filePath,
|
|
9828
|
+
certificationStatus: block.status ?? null,
|
|
9829
|
+
title: item.title ?? block.name,
|
|
9830
|
+
viz: item.viz,
|
|
9831
|
+
chartConfig: mergeDashboardChartConfig(blockPlan?.chartConfig, item),
|
|
9832
|
+
result: normalizeQueryResult(result),
|
|
9833
|
+
filters: {
|
|
9834
|
+
applied: blockFilterApplication.appliedFilters,
|
|
9835
|
+
skipped: blockFilterApplication.skippedFilters,
|
|
9836
|
+
},
|
|
9837
|
+
invocation: {
|
|
9838
|
+
resolvedParameters: blockInvocation.resolvedParameters,
|
|
9839
|
+
unresolvedParameters: blockInvocation.unresolvedParameters,
|
|
9840
|
+
auditId: blockInvocation.auditId,
|
|
9841
|
+
},
|
|
9842
|
+
trustState: block.status === 'certified' ? 'certified' : 'review_required',
|
|
9843
|
+
citation: {
|
|
9844
|
+
kind: 'block',
|
|
9845
|
+
name: block.name,
|
|
9846
|
+
path: block.filePath,
|
|
9847
|
+
},
|
|
9848
|
+
artifact: {
|
|
9849
|
+
version: 1,
|
|
9850
|
+
sourceKind: block.status === 'certified' ? 'certified_block' : 'review_block',
|
|
9851
|
+
name: block.name,
|
|
9852
|
+
sourcePath: block.filePath,
|
|
9853
|
+
dql: blockSource.slice(0, 40_000),
|
|
9854
|
+
sql: blockFilterApplication.sql.slice(0, 40_000),
|
|
9855
|
+
trustState: block.status === 'certified' ? 'certified' : 'review_required',
|
|
9856
|
+
explanation: [
|
|
9857
|
+
block.status === 'certified' ? 'This tile executed a certified project block.' : 'This project block is not certified and remains review-required.',
|
|
9858
|
+
'Dashboard filters and typed parameters were bound before read-only execution.',
|
|
9859
|
+
],
|
|
9860
|
+
executionTarget: executionTargetDescriptor(body),
|
|
9861
|
+
},
|
|
9654
9862
|
});
|
|
9655
9863
|
}
|
|
9864
|
+
catch (err) {
|
|
9865
|
+
if (err instanceof DQLAccessDeniedError) {
|
|
9866
|
+
tiles.push({
|
|
9867
|
+
tileId: item.i,
|
|
9868
|
+
status: 'unauthorized',
|
|
9869
|
+
blockId: block.name,
|
|
9870
|
+
error: err.message,
|
|
9871
|
+
});
|
|
9872
|
+
}
|
|
9873
|
+
else {
|
|
9874
|
+
const repaired = await repairFailedAppTileExecution({
|
|
9875
|
+
source: block.status === 'certified' ? 'certified_block' : 'review_block',
|
|
9876
|
+
question: item.title ?? block.name,
|
|
9877
|
+
sourceSql: blockFilterApplication?.sql ?? blockPlan?.sql,
|
|
9878
|
+
sourceDql: blockSource,
|
|
9879
|
+
artifactName: block.name,
|
|
9880
|
+
error: err,
|
|
9881
|
+
targetConnection: blockTargetConnection,
|
|
9882
|
+
targetConnectionName: projectConfig.defaultConnectionName,
|
|
9883
|
+
sqlParams: blockFilterApplication?.sqlParams ?? blockPlan?.sqlParams,
|
|
9884
|
+
variables: {
|
|
9885
|
+
...(blockPlan?.variables ?? {}),
|
|
9886
|
+
...(blockFilterApplication?.variables ?? {}),
|
|
9887
|
+
...(blockInvocation?.values ?? {}),
|
|
9888
|
+
},
|
|
9889
|
+
});
|
|
9890
|
+
if (repaired.ok) {
|
|
9891
|
+
tiles.push({
|
|
9892
|
+
tileId: item.i,
|
|
9893
|
+
status: 'ok',
|
|
9894
|
+
tileType: 'block',
|
|
9895
|
+
blockId: block.name,
|
|
9896
|
+
blockPath: block.filePath,
|
|
9897
|
+
certificationStatus: block.status ?? null,
|
|
9898
|
+
title: item.title ?? block.name,
|
|
9899
|
+
viz: item.viz,
|
|
9900
|
+
chartConfig: mergeDashboardChartConfig(blockPlan?.chartConfig, item),
|
|
9901
|
+
result: normalizeQueryResult(repaired.result),
|
|
9902
|
+
...(blockFilterApplication ? {
|
|
9903
|
+
filters: {
|
|
9904
|
+
applied: blockFilterApplication.appliedFilters,
|
|
9905
|
+
skipped: blockFilterApplication.skippedFilters,
|
|
9906
|
+
},
|
|
9907
|
+
} : {}),
|
|
9908
|
+
...(blockInvocation ? {
|
|
9909
|
+
invocation: {
|
|
9910
|
+
resolvedParameters: blockInvocation.resolvedParameters,
|
|
9911
|
+
unresolvedParameters: blockInvocation.unresolvedParameters,
|
|
9912
|
+
auditId: blockInvocation.auditId,
|
|
9913
|
+
},
|
|
9914
|
+
} : {}),
|
|
9915
|
+
trustState: 'review_required',
|
|
9916
|
+
repair: repaired.repair,
|
|
9917
|
+
citation: { kind: 'block_repaired', name: block.name, path: block.filePath },
|
|
9918
|
+
artifact: {
|
|
9919
|
+
version: 1,
|
|
9920
|
+
sourceKind: block.status === 'certified' ? 'certified_block' : 'review_block',
|
|
9921
|
+
name: block.name,
|
|
9922
|
+
sourcePath: block.filePath,
|
|
9923
|
+
...(repaired.repairedDql ?? blockSource ? { dql: (repaired.repairedDql ?? blockSource).slice(0, 40_000) } : {}),
|
|
9924
|
+
sql: repaired.repairedSql.slice(0, 40_000),
|
|
9925
|
+
trustState: 'review_required',
|
|
9926
|
+
explanation: [
|
|
9927
|
+
'The saved certified block was not changed.',
|
|
9928
|
+
'A bounded repair executed on the original data target; this repaired result requires human review.',
|
|
9929
|
+
],
|
|
9930
|
+
executionTarget: executionTargetDescriptor(body),
|
|
9931
|
+
},
|
|
9932
|
+
});
|
|
9933
|
+
continue;
|
|
9934
|
+
}
|
|
9935
|
+
tiles.push({
|
|
9936
|
+
tileId: item.i,
|
|
9937
|
+
status: 'error',
|
|
9938
|
+
tileType: 'block',
|
|
9939
|
+
blockId: block.name,
|
|
9940
|
+
error: err instanceof Error ? err.message : String(err),
|
|
9941
|
+
repair: repaired.repair,
|
|
9942
|
+
...(blockSource ? {
|
|
9943
|
+
artifact: {
|
|
9944
|
+
version: 1,
|
|
9945
|
+
sourceKind: block.status === 'certified' ? 'certified_block' : 'review_block',
|
|
9946
|
+
name: block.name,
|
|
9947
|
+
sourcePath: block.filePath,
|
|
9948
|
+
dql: blockSource.slice(0, 40_000),
|
|
9949
|
+
...(blockFilterApplication?.sql ?? blockPlan?.sql ? { sql: (blockFilterApplication?.sql ?? blockPlan.sql).slice(0, 40_000) } : {}),
|
|
9950
|
+
trustState: 'review_required',
|
|
9951
|
+
explanation: ['Automatic repair could not produce a safe executable result. The saved block was not changed.'],
|
|
9952
|
+
executionTarget: executionTargetDescriptor(body),
|
|
9953
|
+
},
|
|
9954
|
+
} : {}),
|
|
9955
|
+
});
|
|
9956
|
+
}
|
|
9957
|
+
}
|
|
9656
9958
|
}
|
|
9657
|
-
}
|
|
9658
|
-
|
|
9959
|
+
};
|
|
9960
|
+
await Promise.all(Array.from({ length: Math.min(4, itemsToRun.length) }, () => runTileWorker()));
|
|
9961
|
+
tiles.sort((left, right) => (tileOrder.get(left.tileId) ?? 0) - (tileOrder.get(right.tileId) ?? 0));
|
|
9962
|
+
localApps.current?.close();
|
|
9963
|
+
// Which of each tile's result columns a viewer may safely filter on.
|
|
9964
|
+
// Decided here, from a real dialect parse of the SQL that actually ran,
|
|
9965
|
+
// for the same reason Ask does it server-side: an aggregate output needs
|
|
9966
|
+
// HAVING, and picking such a column off the table would quietly change
|
|
9967
|
+
// the measure. The browser must never re-derive this.
|
|
9968
|
+
const runDialect = (() => {
|
|
9969
|
+
try {
|
|
9970
|
+
return requireActiveConnection().driver || 'duckdb';
|
|
9971
|
+
}
|
|
9972
|
+
catch {
|
|
9973
|
+
return 'duckdb';
|
|
9974
|
+
}
|
|
9975
|
+
})();
|
|
9976
|
+
const tilesWithFilters = tiles.map((tile) => {
|
|
9977
|
+
const executedSql = tile.artifact?.sql;
|
|
9978
|
+
const columns = tile.result?.columns ?? [];
|
|
9979
|
+
if (typeof executedSql !== 'string' || columns.length === 0)
|
|
9980
|
+
return tile;
|
|
9981
|
+
try {
|
|
9982
|
+
const dashboardItem = loaded.dashboard.layout.items.find((item) => item.i === tile.tileId);
|
|
9983
|
+
const block = dashboardItem ? resolveDashboardItemBlock(dashboardItem, manifest) : null;
|
|
9984
|
+
const declaredDimensions = block?.dimensions ?? [];
|
|
9985
|
+
const filterable = dashboardFilterableResultColumns(executedSql, columns, declaredDimensions, runDialect);
|
|
9986
|
+
return filterable.length > 0 ? { ...tile, filterableColumns: filterable } : tile;
|
|
9987
|
+
}
|
|
9988
|
+
catch {
|
|
9989
|
+
return tile; // Unparseable SQL simply offers no filter candidates.
|
|
9990
|
+
}
|
|
9991
|
+
});
|
|
9992
|
+
const filterOptions = collectDashboardFilterOptions(loaded.dashboard, tilesWithFilters);
|
|
9659
9993
|
const snapshot = projectSnapshot();
|
|
9660
9994
|
const filterFingerprint = createHash('sha256').update(JSON.stringify(dashboardVariables)).digest('hex');
|
|
9661
9995
|
const resultFingerprint = createHash('sha256').update(JSON.stringify(tiles.map((tile) => ({ tileId: tile.tileId, status: tile.status, result: tile.result })))).digest('hex');
|
|
@@ -9678,7 +10012,7 @@ export async function startLocalServer(opts) {
|
|
|
9678
10012
|
eligibleTileIds: loaded.dashboard.story?.eligibleTileIds,
|
|
9679
10013
|
driverTileIds: loaded.dashboard.story?.driverTileIds,
|
|
9680
10014
|
});
|
|
9681
|
-
|
|
10015
|
+
const runEvidence = {
|
|
9682
10016
|
appId,
|
|
9683
10017
|
dashboardId,
|
|
9684
10018
|
snapshotId: snapshot.snapshotId,
|
|
@@ -9692,7 +10026,28 @@ export async function startLocalServer(opts) {
|
|
|
9692
10026
|
facts: storyResult.facts,
|
|
9693
10027
|
story: storyResult.story,
|
|
9694
10028
|
expiresAt: Date.now() + 15 * 60_000,
|
|
9695
|
-
}
|
|
10029
|
+
};
|
|
10030
|
+
dashboardRunEvidence.set(runId, runEvidence);
|
|
10031
|
+
if (runSurface === 'app-builds') {
|
|
10032
|
+
const receiptStorage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
10033
|
+
try {
|
|
10034
|
+
receiptStorage.saveAppPreviewEvidence({
|
|
10035
|
+
runId,
|
|
10036
|
+
draftId: appId,
|
|
10037
|
+
dashboardId,
|
|
10038
|
+
snapshotId: snapshot.snapshotId,
|
|
10039
|
+
filterFingerprint,
|
|
10040
|
+
resultFingerprint,
|
|
10041
|
+
personaFingerprint,
|
|
10042
|
+
successfulTileIds: runEvidence.successfulTileIds,
|
|
10043
|
+
semanticApprovalEligibleTileIds: runEvidence.semanticApprovalEligibleTileIds,
|
|
10044
|
+
createdAt: new Date().toISOString(),
|
|
10045
|
+
});
|
|
10046
|
+
}
|
|
10047
|
+
finally {
|
|
10048
|
+
receiptStorage.close();
|
|
10049
|
+
}
|
|
10050
|
+
}
|
|
9696
10051
|
for (const [id, evidence] of dashboardRunEvidence)
|
|
9697
10052
|
if (evidence.expiresAt < Date.now())
|
|
9698
10053
|
dashboardRunEvidence.delete(id);
|
|
@@ -9706,7 +10061,8 @@ export async function startLocalServer(opts) {
|
|
|
9706
10061
|
filterFingerprint,
|
|
9707
10062
|
resultFingerprint,
|
|
9708
10063
|
personaFingerprint,
|
|
9709
|
-
|
|
10064
|
+
filterOptions,
|
|
10065
|
+
tiles: tilesWithFilters,
|
|
9710
10066
|
facts: storyResult.facts,
|
|
9711
10067
|
story: storyResult.story,
|
|
9712
10068
|
}));
|
|
@@ -9718,7 +10074,11 @@ export async function startLocalServer(opts) {
|
|
|
9718
10074
|
return;
|
|
9719
10075
|
}
|
|
9720
10076
|
// Apps, dashboards, persona — see apps-api.ts. Returns true if handled.
|
|
9721
|
-
if (path.startsWith('/api/apps')
|
|
10077
|
+
if (path.startsWith('/api/apps')
|
|
10078
|
+
|| path.startsWith('/api/app-builds')
|
|
10079
|
+
|| path.startsWith('/api/app-recoveries')
|
|
10080
|
+
|| path.startsWith('/api/visualizations')
|
|
10081
|
+
|| path === '/api/persona') {
|
|
9722
10082
|
try {
|
|
9723
10083
|
const handled = await handleAppsApi({
|
|
9724
10084
|
req,
|
|
@@ -9741,6 +10101,17 @@ export async function startLocalServer(opts) {
|
|
|
9741
10101
|
// questions. Throws when no provider is configured — propose degrades
|
|
9742
10102
|
// gracefully by listing the gap instead.
|
|
9743
10103
|
generateGovernedAnswer: (question, appContext) => generateAppBuildAnswerWithRepair(question, appContext),
|
|
10104
|
+
// App Builder owns its orchestration and structured state. It shares
|
|
10105
|
+
// only the configured provider adapter with Ask/Notebook.
|
|
10106
|
+
planAppBuild: async ({ system, user }) => {
|
|
10107
|
+
const provider = await createBlockStudioAssistProvider(projectRoot);
|
|
10108
|
+
if (!provider)
|
|
10109
|
+
return undefined;
|
|
10110
|
+
return provider.generate([
|
|
10111
|
+
{ role: 'system', content: system },
|
|
10112
|
+
{ role: 'user', content: user },
|
|
10113
|
+
], { maxTokens: 2_400, temperature: 0.1 });
|
|
10114
|
+
},
|
|
9744
10115
|
queueOperation: (input, task) => {
|
|
9745
10116
|
const operation = operationCoordinator.create(input);
|
|
9746
10117
|
void operationCoordinator.run(operation.id, task);
|
|
@@ -9764,6 +10135,18 @@ export async function startLocalServer(opts) {
|
|
|
9764
10135
|
}
|
|
9765
10136
|
return { ok: true, snapshotId: evidence.snapshotId, resultFingerprint: evidence.resultFingerprint };
|
|
9766
10137
|
},
|
|
10138
|
+
verifyAppBuildPreview: ({ runId, draftId, dashboardId, tileIds }) => {
|
|
10139
|
+
const evidence = loadAppBuildPreviewEvidence(runId);
|
|
10140
|
+
if (!evidence)
|
|
10141
|
+
return { ok: false, error: 'The App preview receipt is unavailable. Run the local draft again.' };
|
|
10142
|
+
if (evidence.appId !== draftId || evidence.dashboardId !== dashboardId)
|
|
10143
|
+
return { ok: false, error: 'The App preview receipt does not match this local draft page.' };
|
|
10144
|
+
const successful = new Set(evidence.successfulTileIds);
|
|
10145
|
+
const failed = tileIds.filter((tileId) => !successful.has(tileId));
|
|
10146
|
+
if (failed.length > 0)
|
|
10147
|
+
return { ok: false, error: `These App components did not complete successfully: ${failed.join(', ')}` };
|
|
10148
|
+
return { ok: true, snapshotId: evidence.snapshotId, resultFingerprint: evidence.resultFingerprint };
|
|
10149
|
+
},
|
|
9767
10150
|
// Story narration for commit — LLM-backed with deterministic fallback.
|
|
9768
10151
|
narrate: narrateForAgentRun,
|
|
9769
10152
|
});
|
|
@@ -11316,13 +11699,12 @@ export async function startLocalServer(opts) {
|
|
|
11316
11699
|
res.end(serializeJSON({ error: 'Block name is required.' }));
|
|
11317
11700
|
return;
|
|
11318
11701
|
}
|
|
11319
|
-
const
|
|
11702
|
+
const existingCanonicalDraft = Boolean(currentPath && !isDraftBlockPath(currentPath));
|
|
11320
11703
|
const stableSuffix = createHash('sha256')
|
|
11321
11704
|
.update(currentPath ?? `${name}:${draftSource}`)
|
|
11322
11705
|
.digest('hex')
|
|
11323
11706
|
.slice(0, 16);
|
|
11324
|
-
const
|
|
11325
|
-
currentPath: currentPath && isDraftBlockPath(currentPath) ? currentPath : undefined,
|
|
11707
|
+
const draftSaveOptions = {
|
|
11326
11708
|
source: draftSource,
|
|
11327
11709
|
name,
|
|
11328
11710
|
domain: typeof suppliedMetadata.domain === 'string' ? suppliedMetadata.domain : parsed.domain,
|
|
@@ -11330,8 +11712,22 @@ export async function startLocalServer(opts) {
|
|
|
11330
11712
|
owner: typeof suppliedMetadata.owner === 'string' ? suppliedMetadata.owner : parsed.owner,
|
|
11331
11713
|
tags: Array.isArray(suppliedMetadata.tags) ? suppliedMetadata.tags.map(String) : parsed.tags,
|
|
11332
11714
|
lineage: Array.isArray(suppliedMetadata.lineage) ? suppliedMetadata.lineage.map(String) : undefined,
|
|
11333
|
-
|
|
11334
|
-
|
|
11715
|
+
};
|
|
11716
|
+
// A saved draft already has a durable identity, even when it lives in
|
|
11717
|
+
// the canonical domain blocks directory. Reuse that identity for the
|
|
11718
|
+
// certification attempt. Creating a second `_drafts` artifact here
|
|
11719
|
+
// made a failed gate duplicate the block in the library.
|
|
11720
|
+
const draftPath = existingCanonicalDraft
|
|
11721
|
+
? saveBlockStudioArtifacts(projectRoot, {
|
|
11722
|
+
currentPath: currentPath,
|
|
11723
|
+
...draftSaveOptions,
|
|
11724
|
+
})
|
|
11725
|
+
: saveBlockStudioDraftArtifacts(projectRoot, {
|
|
11726
|
+
currentPath: currentPath && isDraftBlockPath(currentPath) ? currentPath : undefined,
|
|
11727
|
+
...draftSaveOptions,
|
|
11728
|
+
stableSuffix,
|
|
11729
|
+
});
|
|
11730
|
+
const canonicalPath = existingCanonicalDraft ? draftPath : undefined;
|
|
11335
11731
|
const operation = operationCoordinator.create({
|
|
11336
11732
|
type: 'block_certification',
|
|
11337
11733
|
scope: `block:${draftPath}`,
|
|
@@ -11357,13 +11753,22 @@ export async function startLocalServer(opts) {
|
|
|
11357
11753
|
if (signal.aborted)
|
|
11358
11754
|
throw Object.assign(new Error('Certification cancelled.'), { code: 'OPERATION_CANCELLED' });
|
|
11359
11755
|
if (!result.certification.certified || blockers.length > 0) {
|
|
11756
|
+
if (result.preview)
|
|
11757
|
+
writeBlockStudioRunSummary(projectRoot, draftPath, draftSource, result.preview.result);
|
|
11758
|
+
const openedDraft = openBlockStudioDocument(projectRoot, draftPath, semanticLayer);
|
|
11360
11759
|
return {
|
|
11361
11760
|
outcome: 'draft_saved_with_blockers',
|
|
11362
11761
|
oldPath: currentPath ?? draftPath,
|
|
11363
11762
|
draftPath,
|
|
11364
11763
|
blockers,
|
|
11365
11764
|
checklist: result.checklist,
|
|
11366
|
-
|
|
11765
|
+
runSummary: result.preview ? {
|
|
11766
|
+
rowCount: result.preview.result.rowCount ?? result.preview.result.rows.length,
|
|
11767
|
+
executionTime: result.preview.result.executionTime,
|
|
11768
|
+
columns: result.preview.result.columns,
|
|
11769
|
+
ranAt: new Date().toISOString(),
|
|
11770
|
+
} : undefined,
|
|
11771
|
+
block: { ...openedDraft, validation: result.validation },
|
|
11367
11772
|
};
|
|
11368
11773
|
}
|
|
11369
11774
|
report({ phase: 'publishing', progress: 80, message: 'Publishing the certified block.' });
|
|
@@ -11409,13 +11814,22 @@ export async function startLocalServer(opts) {
|
|
|
11409
11814
|
/* best-effort: learning capture must never block certification */
|
|
11410
11815
|
}
|
|
11411
11816
|
const refreshOperation = scheduleProjectRefresh('block-studio-certification');
|
|
11817
|
+
if (result.preview)
|
|
11818
|
+
writeBlockStudioRunSummary(projectRoot, savedPath, certifiedSource, result.preview.result);
|
|
11819
|
+
const openedCertified = openBlockStudioDocument(projectRoot, savedPath, semanticLayer);
|
|
11412
11820
|
return {
|
|
11413
11821
|
outcome: 'certified',
|
|
11414
11822
|
oldPath: currentPath ?? draftPath,
|
|
11415
11823
|
draftPath,
|
|
11416
11824
|
newPath: savedPath,
|
|
11417
|
-
block:
|
|
11825
|
+
block: { ...openedCertified, validation: result.validation },
|
|
11418
11826
|
checklist: result.checklist,
|
|
11827
|
+
runSummary: result.preview ? {
|
|
11828
|
+
rowCount: result.preview.result.rowCount ?? result.preview.result.rows.length,
|
|
11829
|
+
executionTime: result.preview.result.executionTime,
|
|
11830
|
+
columns: result.preview.result.columns,
|
|
11831
|
+
ranAt: new Date().toISOString(),
|
|
11832
|
+
} : undefined,
|
|
11419
11833
|
indexRefresh: { status: 'queued', operationId: refreshOperation.id },
|
|
11420
11834
|
};
|
|
11421
11835
|
});
|
|
@@ -11433,6 +11847,32 @@ export async function startLocalServer(opts) {
|
|
|
11433
11847
|
}
|
|
11434
11848
|
return;
|
|
11435
11849
|
}
|
|
11850
|
+
if (req.method === 'POST' && path === '/api/block-studio/certification-check') {
|
|
11851
|
+
try {
|
|
11852
|
+
const body = await readJSON(req);
|
|
11853
|
+
const source = typeof body.source === 'string' ? body.source : '';
|
|
11854
|
+
if (!source.trim()) {
|
|
11855
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
11856
|
+
res.end(serializeJSON({ error: 'source is required' }));
|
|
11857
|
+
return;
|
|
11858
|
+
}
|
|
11859
|
+
// Read-only certification assessment for repair/review workflows. It
|
|
11860
|
+
// deliberately does not write source, receipts, learning, or indexes.
|
|
11861
|
+
const result = await certifyBlockStudioSource(source, null, { enterprise: body.enterprise === true });
|
|
11862
|
+
const blockers = Array.from(new Set(result.checklist.blockers));
|
|
11863
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
11864
|
+
res.end(serializeJSON({
|
|
11865
|
+
ok: result.certification.certified && blockers.length === 0,
|
|
11866
|
+
...result,
|
|
11867
|
+
blockers,
|
|
11868
|
+
}));
|
|
11869
|
+
}
|
|
11870
|
+
catch (error) {
|
|
11871
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
11872
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
11873
|
+
}
|
|
11874
|
+
return;
|
|
11875
|
+
}
|
|
11436
11876
|
if (req.method === 'POST' && path === '/api/block-studio/certify') {
|
|
11437
11877
|
try {
|
|
11438
11878
|
const body = await readJSON(req);
|
|
@@ -11450,6 +11890,8 @@ export async function startLocalServer(opts) {
|
|
|
11450
11890
|
const result = await certifyBlockStudioSource(source, blockPath, { enterprise: body.enterprise === true });
|
|
11451
11891
|
const blockers = Array.from(new Set(result.checklist.blockers));
|
|
11452
11892
|
if (!result.certification.certified || blockers.length > 0) {
|
|
11893
|
+
if (blockPath && result.preview)
|
|
11894
|
+
writeBlockStudioRunSummary(projectRoot, blockPath, source, result.preview.result);
|
|
11453
11895
|
res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
11454
11896
|
res.end(serializeJSON({ ok: false, ...result, blockers }));
|
|
11455
11897
|
return;
|
|
@@ -11469,10 +11911,14 @@ export async function startLocalServer(opts) {
|
|
|
11469
11911
|
owner: parsed.owner,
|
|
11470
11912
|
tags: parsed.tags,
|
|
11471
11913
|
});
|
|
11914
|
+
if (result.preview)
|
|
11915
|
+
writeBlockStudioRunSummary(projectRoot, savedPath, certifiedSource, result.preview.result);
|
|
11472
11916
|
certifiedPayload = openBlockStudioDocument(projectRoot, savedPath, semanticLayer);
|
|
11473
11917
|
}
|
|
11474
11918
|
else {
|
|
11475
11919
|
setBlockStudioStatus(projectRoot, normalizedBlockPath, 'certified');
|
|
11920
|
+
if (result.preview)
|
|
11921
|
+
writeBlockStudioRunSummary(projectRoot, normalizedBlockPath, certifiedSource, result.preview.result);
|
|
11476
11922
|
certifiedPayload = openBlockStudioDocument(projectRoot, normalizedBlockPath, semanticLayer);
|
|
11477
11923
|
}
|
|
11478
11924
|
}
|
|
@@ -11512,7 +11958,7 @@ export async function startLocalServer(opts) {
|
|
|
11512
11958
|
source: certifiedPayload?.source ?? certifiedSource,
|
|
11513
11959
|
metadata: certifiedPayload?.metadata,
|
|
11514
11960
|
companionPath: certifiedPayload?.companionPath ?? null,
|
|
11515
|
-
validation:
|
|
11961
|
+
validation: result.validation,
|
|
11516
11962
|
lineageRefresh: { status: 'queued', operationId: refreshOperation.id },
|
|
11517
11963
|
}));
|
|
11518
11964
|
}
|
|
@@ -12278,15 +12724,26 @@ export async function startLocalServer(opts) {
|
|
|
12278
12724
|
? body.parameters
|
|
12279
12725
|
: {};
|
|
12280
12726
|
const preview = await runBlockStudioPreviewSource(source, targetConnection, parameters);
|
|
12727
|
+
const blockPath = typeof body.path === 'string' ? body.path : null;
|
|
12728
|
+
if (blockPath)
|
|
12729
|
+
writeBlockStudioRunSummary(projectRoot, blockPath, source, preview.result);
|
|
12281
12730
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
12282
12731
|
res.end(serializeJSON(preview));
|
|
12283
12732
|
}
|
|
12284
12733
|
catch (error) {
|
|
12285
12734
|
const raw = error instanceof Error ? error.message : String(error);
|
|
12735
|
+
const friendlyMessage = compactBlockStudioRuntimeFailure(raw);
|
|
12286
12736
|
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
12287
|
-
//
|
|
12288
|
-
//
|
|
12289
|
-
res.end(serializeJSON({
|
|
12737
|
+
// Keep the result surface concise. Raw compiler output remains available
|
|
12738
|
+
// for support and AI repair, but is never the primary end-user message.
|
|
12739
|
+
res.end(serializeJSON({
|
|
12740
|
+
error: raw,
|
|
12741
|
+
friendlyMessage,
|
|
12742
|
+
code: 'BLOCK_PREVIEW_FAILED',
|
|
12743
|
+
recoverable: true,
|
|
12744
|
+
details: { technicalDetails: raw },
|
|
12745
|
+
nextActions: ['Review the named field or metric.', 'Fix the draft and run it again.'],
|
|
12746
|
+
}));
|
|
12290
12747
|
}
|
|
12291
12748
|
return;
|
|
12292
12749
|
}
|
|
@@ -15888,21 +16345,77 @@ function loadAppDashboard(projectRoot, appId, dashboardId) {
|
|
|
15888
16345
|
}
|
|
15889
16346
|
return null;
|
|
15890
16347
|
}
|
|
15891
|
-
|
|
15892
|
-
|
|
15893
|
-
|
|
15894
|
-
|
|
15895
|
-
|
|
15896
|
-
|
|
15897
|
-
|
|
15898
|
-
|
|
15899
|
-
|
|
15900
|
-
|
|
15901
|
-
|
|
15902
|
-
|
|
15903
|
-
|
|
15904
|
-
|
|
15905
|
-
|
|
16348
|
+
/**
|
|
16349
|
+
* Resolves an App Studio draft directly from local SQLite for read-only preview.
|
|
16350
|
+
* The draft is never copied into the Git-owned `apps/` tree. This gives manual
|
|
16351
|
+
* and AI authoring the same execution path and the same filter/story receipts
|
|
16352
|
+
* as a published App while keeping publication explicit.
|
|
16353
|
+
*/
|
|
16354
|
+
function loadAppBuildDraftDashboard(projectRoot, draftId, dashboardId) {
|
|
16355
|
+
const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
16356
|
+
try {
|
|
16357
|
+
const draft = storage.getAppBuildDraft(draftId);
|
|
16358
|
+
if (!draft)
|
|
16359
|
+
return null;
|
|
16360
|
+
const dashboard = draft.pages.find((page) => page.id === dashboardId);
|
|
16361
|
+
if (!dashboard)
|
|
16362
|
+
return null;
|
|
16363
|
+
const base = draft.baseApp ? findAppById(projectRoot, draft.baseApp.appId) : null;
|
|
16364
|
+
const app = base?.app ?? {
|
|
16365
|
+
version: 1,
|
|
16366
|
+
id: draft.appId,
|
|
16367
|
+
name: draft.name,
|
|
16368
|
+
description: draft.frame.goal,
|
|
16369
|
+
visibility: 'private',
|
|
16370
|
+
publicationIntent: 'personal',
|
|
16371
|
+
domain: dashboard.metadata.domain ?? 'general',
|
|
16372
|
+
usesDomains: [],
|
|
16373
|
+
requiredExports: [],
|
|
16374
|
+
audience: draft.frame.audience || 'stakeholders',
|
|
16375
|
+
lifecycle: 'draft',
|
|
16376
|
+
owners: ['local-author'],
|
|
16377
|
+
tags: ['app-studio', 'local-draft'],
|
|
16378
|
+
members: [],
|
|
16379
|
+
roles: [],
|
|
16380
|
+
policies: [],
|
|
16381
|
+
homepage: { type: 'dashboard', id: dashboard.id },
|
|
16382
|
+
};
|
|
16383
|
+
return {
|
|
16384
|
+
app: { ...app, id: draft.appId, name: draft.name, visibility: 'private', lifecycle: 'draft' },
|
|
16385
|
+
dashboard,
|
|
16386
|
+
appDir: base?.appDir ?? join(projectRoot, '.dql', 'local', 'app-builds', draft.id),
|
|
16387
|
+
draftArtifactsDir: join(projectRoot, '.dql', 'local', 'app-builds', draft.id),
|
|
16388
|
+
};
|
|
16389
|
+
}
|
|
16390
|
+
finally {
|
|
16391
|
+
storage.close();
|
|
16392
|
+
}
|
|
16393
|
+
}
|
|
16394
|
+
function findAppById(projectRoot, appId) {
|
|
16395
|
+
for (const path of findAppDocuments(projectRoot)) {
|
|
16396
|
+
const { document: app } = loadAppDocument(path);
|
|
16397
|
+
if (app?.id !== appId)
|
|
16398
|
+
continue;
|
|
16399
|
+
return { app, appDir: path.slice(0, -'/dql.app.json'.length) };
|
|
16400
|
+
}
|
|
16401
|
+
return null;
|
|
16402
|
+
}
|
|
16403
|
+
function resolveDashboardItemBlock(item, manifest) {
|
|
16404
|
+
if (!item.block)
|
|
16405
|
+
return null;
|
|
16406
|
+
if (isBlockIdRef(item.block)) {
|
|
16407
|
+
return manifest.blocks[item.block.blockId] ?? null;
|
|
16408
|
+
}
|
|
16409
|
+
const normalizedRef = normalize(item.block.ref).replaceAll('\\', '/');
|
|
16410
|
+
return (manifest.blockDeclarations ?? Object.values(manifest.blocks))
|
|
16411
|
+
.find((b) => normalize(b.filePath).replaceAll('\\', '/') === normalizedRef) ?? null;
|
|
16412
|
+
}
|
|
16413
|
+
function mergeDashboardChartConfig(base, item) {
|
|
16414
|
+
const options = item.viz.options ?? {};
|
|
16415
|
+
const baseChart = base?.chart;
|
|
16416
|
+
return {
|
|
16417
|
+
...(base ?? {}),
|
|
16418
|
+
...options,
|
|
15906
16419
|
chart: dashboardVizToChart(String(options.chart ?? baseChart ?? item.viz.type)),
|
|
15907
16420
|
};
|
|
15908
16421
|
}
|
|
@@ -16224,6 +16737,8 @@ export function parseDomainInput(raw, fallbackId) {
|
|
|
16224
16737
|
sourcePath: typeof domain.sourcePath === 'string' ? domain.sourcePath : undefined,
|
|
16225
16738
|
};
|
|
16226
16739
|
}
|
|
16740
|
+
/** Bounded discovery: an import scan reads at most this many YAML files. */
|
|
16741
|
+
const MODELING_IMPORT_FILE_LIMIT = 100;
|
|
16227
16742
|
function modelingImportDirectory(projectRoot) {
|
|
16228
16743
|
return join(projectRoot, '.dql', 'local', 'modeling-imports');
|
|
16229
16744
|
}
|
|
@@ -16260,6 +16775,7 @@ function createModelingImportSession(projectRoot, gitRoot, loopback, source) {
|
|
|
16260
16775
|
if (mode !== 'current_project' && mode !== 'path' && mode !== 'upload' && mode !== 'paste')
|
|
16261
16776
|
throw Object.assign(new Error('Choose current_project, path, upload, or paste.'), { code: 'INVALID_REQUEST' });
|
|
16262
16777
|
const files = [];
|
|
16778
|
+
const truncationWarnings = [];
|
|
16263
16779
|
if (mode === 'paste' || mode === 'upload') {
|
|
16264
16780
|
const content = typeof source.content === 'string' ? source.content : '';
|
|
16265
16781
|
if (!content.trim() || Buffer.byteLength(content) > 1_000_000)
|
|
@@ -16278,11 +16794,17 @@ function createModelingImportSession(projectRoot, gitRoot, loopback, source) {
|
|
|
16278
16794
|
const requested = realpathSync(requestedInput);
|
|
16279
16795
|
if (requested !== allowedRoot && !requested.startsWith(`${allowedRoot}${sep}`))
|
|
16280
16796
|
throw Object.assign(new Error('Import path must stay inside the current Git workspace.'), { code: 'PATH_ACCESS_FORBIDDEN' });
|
|
16281
|
-
const paths = collectModelingYamlPaths(requested,
|
|
16797
|
+
const paths = collectModelingYamlPaths(requested, MODELING_IMPORT_FILE_LIMIT);
|
|
16798
|
+
// Truncation used to be silent, which reads as "DQL did not find my file".
|
|
16799
|
+
if (paths.length >= MODELING_IMPORT_FILE_LIMIT) {
|
|
16800
|
+
truncationWarnings.push(`Only the first ${MODELING_IMPORT_FILE_LIMIT} YAML files in this location were scanned. Point the import at a narrower folder to see the rest.`);
|
|
16801
|
+
}
|
|
16282
16802
|
for (const path of paths) {
|
|
16283
16803
|
const size = statSync(path).size;
|
|
16284
|
-
if (size > 1_000_000)
|
|
16804
|
+
if (size > 1_000_000) {
|
|
16805
|
+
truncationWarnings.push(`Skipped ${relative(requested, path).replace(/\\/g, '/')}: larger than the 1 MB import limit.`);
|
|
16285
16806
|
continue;
|
|
16807
|
+
}
|
|
16286
16808
|
const resolvedPath = realpathSync(path);
|
|
16287
16809
|
if (resolvedPath !== allowedRoot && !resolvedPath.startsWith(`${allowedRoot}${sep}`))
|
|
16288
16810
|
continue;
|
|
@@ -16301,6 +16823,7 @@ function createModelingImportSession(projectRoot, gitRoot, loopback, source) {
|
|
|
16301
16823
|
updatedAt: now,
|
|
16302
16824
|
source: { mode, label: mode === 'path' ? String(source.path ?? '') : mode === 'current_project' ? 'Current project' : files[0].name },
|
|
16303
16825
|
candidates,
|
|
16826
|
+
...(truncationWarnings.length ? { warnings: truncationWarnings } : {}),
|
|
16304
16827
|
};
|
|
16305
16828
|
writeModelingImportSession(projectRoot, session);
|
|
16306
16829
|
return session;
|
|
@@ -16368,10 +16891,12 @@ function modelingImportOperations(session, selectedIds, domain, areaId, manifest
|
|
|
16368
16891
|
throw Object.assign(new Error('Select at least one supported YAML candidate.'), { code: 'MODELING_IMPORT_EMPTY' });
|
|
16369
16892
|
const packageExists = Boolean(manifest.modeling?.packages?.[domain]);
|
|
16370
16893
|
const area = Object.values(manifest.modeling?.areas ?? {}).find((item) => item.domain === domain && (item.localId === areaId || item.qualifiedId === areaId));
|
|
16894
|
+
// A missing Domain/subject area is created by `ensureAuthoringScopeOperations`
|
|
16895
|
+
// as part of this same proposal, so it is reported, not blocked.
|
|
16371
16896
|
if (!packageExists)
|
|
16372
|
-
diagnostics.push({ code: '
|
|
16897
|
+
diagnostics.push({ code: 'DOMAIN_CREATED', severity: 'info', message: `Domain "${domain}" does not exist yet and will be created with these models.` });
|
|
16373
16898
|
if (!area)
|
|
16374
|
-
diagnostics.push({ code: '
|
|
16899
|
+
diagnostics.push({ code: 'MODEL_AREA_CREATED', severity: 'info', message: `Subject area "${areaId}" does not exist yet in ${domain} and will be created with these models.` });
|
|
16375
16900
|
const nodes = Object.values(manifest.dbtProvenance?.nodes ?? {});
|
|
16376
16901
|
const resolveDbt = (reference, operationId) => {
|
|
16377
16902
|
const cleaned = reference.replace(/^ref\(['"]|['"]\)$/g, '').replace(/^model\./, '');
|
|
@@ -16381,6 +16906,15 @@ function modelingImportOperations(session, selectedIds, domain, areaId, manifest
|
|
|
16381
16906
|
return matches.length === 1 ? matches[0].uniqueId : undefined;
|
|
16382
16907
|
};
|
|
16383
16908
|
const used = new Set(Object.values(manifest.modeling?.entities ?? {}).filter((entity) => entity.domain === domain).map((entity) => entity.localId));
|
|
16909
|
+
/**
|
|
16910
|
+
* REL-004: dbt already declares its relationships. Every model this import
|
|
16911
|
+
* binds is recorded here so the dbt `relationships` tests and MetricFlow
|
|
16912
|
+
* `entities:` blocks covering those models become draft edges instead of
|
|
16913
|
+
* leaving the author to redraw connections they already wrote down.
|
|
16914
|
+
*/
|
|
16915
|
+
const boundEntityByDbtId = new Map();
|
|
16916
|
+
const semanticPrimaryByName = new Map();
|
|
16917
|
+
const semanticForeignRefs = [];
|
|
16384
16918
|
const allocate = (value) => {
|
|
16385
16919
|
const base = authoringSlug(value).replace(/-/g, '_') || 'model';
|
|
16386
16920
|
let id = base;
|
|
@@ -16410,6 +16944,7 @@ function modelingImportOperations(session, selectedIds, domain, areaId, manifest
|
|
|
16410
16944
|
continue;
|
|
16411
16945
|
const sourceId = String(entity.id ?? nodes.find((node) => node.uniqueId === dbtModel)?.name ?? `model_${index + 1}`);
|
|
16412
16946
|
const id = allocate(sourceId);
|
|
16947
|
+
boundEntityByDbtId.set(dbtModel, { id, operationId });
|
|
16413
16948
|
importedEntities.set(sourceId, { id, operationId });
|
|
16414
16949
|
importedEntities.set(String(entity.qualified_id ?? entity.qualifiedId ?? ''), { id, operationId });
|
|
16415
16950
|
importedEntities.set(dbtModel, { id, operationId });
|
|
@@ -16460,7 +16995,91 @@ function modelingImportOperations(session, selectedIds, domain, areaId, manifest
|
|
|
16460
16995
|
continue;
|
|
16461
16996
|
}
|
|
16462
16997
|
const id = existing?.localId ?? allocate(String(model.name ?? nodes.find((node) => node.uniqueId === dbtModel)?.name ?? `model_${index + 1}`));
|
|
16463
|
-
|
|
16998
|
+
boundEntityByDbtId.set(dbtModel, { id, operationId });
|
|
16999
|
+
// MetricFlow states its own join graph in `entities:`; keep the primary
|
|
17000
|
+
// key so the semantic branch can pair it with foreign references below.
|
|
17001
|
+
const semanticEntities = contextArray(model.entities).map(contextRecord);
|
|
17002
|
+
const primary = semanticEntities.find((entry) => entry.type === 'primary');
|
|
17003
|
+
if (primary?.name)
|
|
17004
|
+
semanticPrimaryByName.set(String(primary.name), { dbtModel, column: String(primary.expr ?? primary.name) });
|
|
17005
|
+
for (const entry of semanticEntities) {
|
|
17006
|
+
if (entry.type !== 'foreign' || !entry.name)
|
|
17007
|
+
continue;
|
|
17008
|
+
semanticForeignRefs.push({ name: String(entry.name), column: String(entry.expr ?? entry.name), dbtModel, operationId });
|
|
17009
|
+
}
|
|
17010
|
+
const aggTimeDimension = contextRecord(model.defaults).agg_time_dimension;
|
|
17011
|
+
operations.push({ id: operationId, kind: 'modeling_change', evidence: [candidate.path ?? candidate.name, dbtModel], change: { operation: 'upsert_entity', value: { id, domain, areaId: area?.localId ?? areaId, dbtModel, businessName: typeof model.name === 'string' ? titleCaseAuthoring(model.name) : undefined, businessContext: typeof model.description === 'string' ? model.description : undefined, status: 'draft', analyticalRole: 'unknown', ...(typeof aggTimeDimension === 'string' ? { grain: aggTimeDimension } : {}), ...(primary?.name ? { keys: [String(primary.expr ?? primary.name)] } : {}) } } });
|
|
17012
|
+
}
|
|
17013
|
+
}
|
|
17014
|
+
}
|
|
17015
|
+
// REL-004: pair MetricFlow foreign references against the primary entity of
|
|
17016
|
+
// the same name. This is dbt's own declared join graph, so it needs no
|
|
17017
|
+
// guessing — but it is still only a draft candidate (REL-001).
|
|
17018
|
+
for (const reference of semanticForeignRefs) {
|
|
17019
|
+
const primary = semanticPrimaryByName.get(reference.name);
|
|
17020
|
+
if (!primary || primary.dbtModel === reference.dbtModel)
|
|
17021
|
+
continue;
|
|
17022
|
+
const from = boundEntityByDbtId.get(reference.dbtModel);
|
|
17023
|
+
const to = boundEntityByDbtId.get(primary.dbtModel);
|
|
17024
|
+
if (!from || !to)
|
|
17025
|
+
continue;
|
|
17026
|
+
operations.push({
|
|
17027
|
+
id: `semantic-entity:${from.id}:${to.id}:${reference.name}`,
|
|
17028
|
+
kind: 'modeling_change',
|
|
17029
|
+
dependsOn: [...new Set([from.operationId, to.operationId])],
|
|
17030
|
+
evidence: [`MetricFlow entity "${reference.name}" declared foreign in ${reference.dbtModel} and primary in ${primary.dbtModel}`],
|
|
17031
|
+
change: {
|
|
17032
|
+
operation: 'upsert_relationship',
|
|
17033
|
+
value: {
|
|
17034
|
+
id: `${from.id}_to_${to.id}`, domain, areaId: area?.localId ?? areaId,
|
|
17035
|
+
from: from.id, to: to.id,
|
|
17036
|
+
keys: [{ from: reference.column, to: primary.column }],
|
|
17037
|
+
cardinality: 'many_to_one', fanout: 'unknown', status: 'draft',
|
|
17038
|
+
verb: reference.name,
|
|
17039
|
+
rationale: 'Declared by the MetricFlow semantic entity graph. Validate against the warehouse before certifying.',
|
|
17040
|
+
},
|
|
17041
|
+
},
|
|
17042
|
+
});
|
|
17043
|
+
}
|
|
17044
|
+
// REL-004: dbt `relationships` tests state an exact key pair and are already
|
|
17045
|
+
// enforced by `dbt test`, which makes them the strongest available candidate.
|
|
17046
|
+
// They remain drafts: a dbt test is evidence, never join authorization.
|
|
17047
|
+
const manifestPath = manifest.dbtProvenance?.manifestPath;
|
|
17048
|
+
if (manifestPath && boundEntityByDbtId.size > 1) {
|
|
17049
|
+
let rawManifest;
|
|
17050
|
+
try {
|
|
17051
|
+
rawManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
17052
|
+
}
|
|
17053
|
+
catch {
|
|
17054
|
+
rawManifest = undefined;
|
|
17055
|
+
}
|
|
17056
|
+
if (rawManifest) {
|
|
17057
|
+
for (const edge of collectDbtRelationshipTests(rawManifest, new Set(boundEntityByDbtId.keys()))) {
|
|
17058
|
+
if (edge.fromColumn === 'unknown' || edge.toColumn === 'unknown')
|
|
17059
|
+
continue;
|
|
17060
|
+
const from = boundEntityByDbtId.get(edge.fromDbtUniqueId);
|
|
17061
|
+
const to = boundEntityByDbtId.get(edge.toDbtUniqueId);
|
|
17062
|
+
if (!from || !to || from.id === to.id)
|
|
17063
|
+
continue;
|
|
17064
|
+
const id = `${from.id}_to_${to.id}`;
|
|
17065
|
+
if (operations.some((operation) => operation.kind === 'modeling_change' && operation.change.operation === 'upsert_relationship' && operation.change.value.id === id))
|
|
17066
|
+
continue;
|
|
17067
|
+
operations.push({
|
|
17068
|
+
id: `dbt-test:${edge.testUniqueId}`,
|
|
17069
|
+
kind: 'modeling_change',
|
|
17070
|
+
dependsOn: [...new Set([from.operationId, to.operationId])],
|
|
17071
|
+
evidence: [edge.testUniqueId, ...(edge.sourcePath ? [edge.sourcePath] : [])],
|
|
17072
|
+
change: {
|
|
17073
|
+
operation: 'upsert_relationship',
|
|
17074
|
+
value: {
|
|
17075
|
+
id, domain, areaId: area?.localId ?? areaId,
|
|
17076
|
+
from: from.id, to: to.id,
|
|
17077
|
+
keys: [{ from: edge.fromColumn, to: edge.toColumn }],
|
|
17078
|
+
cardinality: 'many_to_one', fanout: 'unknown', status: 'draft',
|
|
17079
|
+
rationale: `Declared by the dbt relationships test ${edge.testUniqueId}. Validate against the warehouse before certifying.`,
|
|
17080
|
+
},
|
|
17081
|
+
},
|
|
17082
|
+
});
|
|
16464
17083
|
}
|
|
16465
17084
|
}
|
|
16466
17085
|
}
|
|
@@ -16515,7 +17134,7 @@ function parseContextAuthoringOperations(value) {
|
|
|
16515
17134
|
const evidence = Array.isArray(operation.evidence) ? operation.evidence.filter((item) => typeof item === 'string').slice(0, 32) : undefined;
|
|
16516
17135
|
if (operation.kind === 'modeling_change') {
|
|
16517
17136
|
const change = operation.change;
|
|
16518
|
-
const allowed = new Set(['upsert_domain', 'upsert_area', 'upsert_entity', 'upsert_relationship', 'upsert_contract', 'upsert_export', 'upsert_import', 'remove_entity', 'remove_relationship', 'remove_area']);
|
|
17137
|
+
const allowed = new Set(['upsert_domain', 'upsert_area', 'upsert_entity', 'upsert_relationship', 'upsert_contract', 'upsert_export', 'upsert_import', 'remove_entity', 'remove_relationship', 'remove_area', 'remove_contract', 'remove_export', 'remove_import']);
|
|
16519
17138
|
if (!change || typeof change !== 'object' || !allowed.has(change.operation) || !('value' in change) || !change.value || typeof change.value !== 'object')
|
|
16520
17139
|
throw Object.assign(new Error(`Modeling operation ${id} is invalid.`), { code: 'INVALID_REQUEST' });
|
|
16521
17140
|
return { id, kind: 'modeling_change', change, dependsOn, evidence };
|
|
@@ -16553,7 +17172,225 @@ function resolveContextPatchPath(projectRoot, gitRoot, relativePath) {
|
|
|
16553
17172
|
}
|
|
16554
17173
|
return absolute;
|
|
16555
17174
|
}
|
|
16556
|
-
|
|
17175
|
+
/**
|
|
17176
|
+
* Modeling AI: propose modeling changes from repository metadata.
|
|
17177
|
+
*
|
|
17178
|
+
* `AGT-024` — the model receives manifest identifiers, columns, lineage, tests,
|
|
17179
|
+
* current modeling, and source locations, and never query rows. It proposes;
|
|
17180
|
+
* this function then validates every identifier against the live snapshot and
|
|
17181
|
+
* drops anything invented, so a hallucinated model or column cannot enter a
|
|
17182
|
+
* proposal. `previewContextAuthoring` independently forces `status: 'draft'`,
|
|
17183
|
+
* strips certification and validation, and blocks deletes and cross-domain
|
|
17184
|
+
* relationships, so nothing here can certify or authorize a join.
|
|
17185
|
+
*/
|
|
17186
|
+
async function buildAiModelingOperations(projectRoot, manifest, request, provider) {
|
|
17187
|
+
const modeling = manifest.modeling;
|
|
17188
|
+
if (!modeling || !manifest.dbtProvenance)
|
|
17189
|
+
throw new Error('Modeling AI requires a compiled dbt-first project snapshot.');
|
|
17190
|
+
const context = request.workspaceContext ?? {};
|
|
17191
|
+
const requestedDomain = typeof context.domain === 'string'
|
|
17192
|
+
? context.domain
|
|
17193
|
+
: request.selectedObject?.kind === 'domain' ? request.selectedObject.id : undefined;
|
|
17194
|
+
const domain = requestedDomain ?? Object.keys(modeling.packages)[0] ?? defaultAuthoringDomainId(manifest);
|
|
17195
|
+
const requestedArea = typeof context.modelAreaId === 'string' ? context.modelAreaId : typeof context.areaId === 'string' ? context.areaId : undefined;
|
|
17196
|
+
const area = Object.values(modeling.areas).find((candidate) => candidate.domain === domain
|
|
17197
|
+
&& (!requestedArea || candidate.qualifiedId === requestedArea || candidate.localId === requestedArea));
|
|
17198
|
+
const areaId = area?.localId ?? modelAreaLocalId(requestedArea) ?? DEFAULT_MODEL_AREA_ID;
|
|
17199
|
+
const bound = new Set(Object.values(modeling.entities).map((entity) => entity.dbtUniqueId));
|
|
17200
|
+
const manifestPath = resolveDbtManifestPath(projectRoot);
|
|
17201
|
+
const describe = (uniqueId) => {
|
|
17202
|
+
const detail = manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, uniqueId) : undefined;
|
|
17203
|
+
return {
|
|
17204
|
+
dbtUniqueId: uniqueId,
|
|
17205
|
+
name: manifest.dbtProvenance.nodes[uniqueId]?.name,
|
|
17206
|
+
relation: manifest.dbtProvenance.nodes[uniqueId]?.relation,
|
|
17207
|
+
description: detail?.description,
|
|
17208
|
+
columns: (detail?.columns ?? []).slice(0, 60).map((column) => ({ name: column.name, type: column.type, description: column.description })),
|
|
17209
|
+
tests: (detail?.tests ?? []).slice(0, 40),
|
|
17210
|
+
};
|
|
17211
|
+
};
|
|
17212
|
+
// Bound as candidates so a large project still fits a prompt.
|
|
17213
|
+
const unbound = Object.values(manifest.dbtProvenance.nodes).filter((node) => !bound.has(node.uniqueId));
|
|
17214
|
+
const tokens = authoringTokens(request.question);
|
|
17215
|
+
const ranked = [
|
|
17216
|
+
...unbound.filter((node) => tokens.some((token) => `${node.name} ${node.uniqueId} ${node.sourcePath ?? ''}`.toLowerCase().includes(token))),
|
|
17217
|
+
...unbound,
|
|
17218
|
+
].filter((node, index, values) => values.findIndex((other) => other.uniqueId === node.uniqueId) === index).slice(0, 24);
|
|
17219
|
+
const payload = {
|
|
17220
|
+
request: request.question.trim(),
|
|
17221
|
+
targetDomain: domain,
|
|
17222
|
+
targetAreaId: areaId,
|
|
17223
|
+
currentModels: Object.values(modeling.entities).filter((entity) => entity.domain === domain).map((entity) => ({
|
|
17224
|
+
id: entity.localId, dbtUniqueId: entity.dbtUniqueId, businessName: entity.businessName,
|
|
17225
|
+
businessContext: entity.businessContext, grain: entity.grain, keys: entity.keys, analyticalRole: entity.analyticalRole,
|
|
17226
|
+
})),
|
|
17227
|
+
currentRelationships: Object.values(modeling.relationships).filter((relationship) => relationship.ownerDomain === domain).map((relationship) => ({
|
|
17228
|
+
id: relationship.localId, from: modeling.entities[relationship.from]?.localId, to: modeling.entities[relationship.to]?.localId,
|
|
17229
|
+
keys: relationship.keys, cardinality: relationship.cardinality, fanout: relationship.fanout, status: relationship.status,
|
|
17230
|
+
})),
|
|
17231
|
+
availableDbtModels: ranked.map((node) => describe(node.uniqueId)),
|
|
17232
|
+
focusedModel: request.selectedObject?.kind === 'model'
|
|
17233
|
+
? Object.values(modeling.entities).find((entity) => entity.qualifiedId === request.selectedObject.id)
|
|
17234
|
+
: undefined,
|
|
17235
|
+
};
|
|
17236
|
+
let raw;
|
|
17237
|
+
try {
|
|
17238
|
+
raw = await provider.generate([
|
|
17239
|
+
{
|
|
17240
|
+
role: 'system',
|
|
17241
|
+
content: [
|
|
17242
|
+
'You design analytical data models. Propose modeling changes for review; you are never applying them.',
|
|
17243
|
+
'Return exactly one JSON object in a ```json fence: {"operations":[...]}. No prose outside the fence.',
|
|
17244
|
+
'Each operation is {"kind":"upsert_entity","id":"<snake_case>","dbtModel":"<exact dbt unique id>","businessName":"...","businessContext":"...","grain":"...","keys":["..."],"analyticalRole":"event|dimension|snapshot|bridge|unknown"}',
|
|
17245
|
+
'or {"kind":"upsert_relationship","id":"<snake_case>","from":"<entity id>","to":"<entity id>","keys":[{"from":"<column>","to":"<column>"}],"cardinality":"one_to_one|one_to_many|many_to_one|many_to_many|unknown","fanout":"safe|dedupe_required|attribution_required|forbidden|unknown","verb":"...","description":"...","rationale":"..."}.',
|
|
17246
|
+
'Use ONLY dbt unique ids and column names present in the supplied metadata. Never invent an identifier.',
|
|
17247
|
+
'businessContext states what one row means in business terms. Never restate the request back as business context.',
|
|
17248
|
+
'Set cardinality and fanout from the evidence; use "unknown" when the metadata does not establish them. Never claim a relationship is certified or validated.',
|
|
17249
|
+
'Do not propose deletions, cross-domain relationships, or changes to dbt-owned SQL, columns, tests, or descriptions.',
|
|
17250
|
+
].join(' '),
|
|
17251
|
+
},
|
|
17252
|
+
{ role: 'user', content: JSON.stringify(payload) },
|
|
17253
|
+
], { maxTokens: 3000, temperature: 0 });
|
|
17254
|
+
}
|
|
17255
|
+
catch (error) {
|
|
17256
|
+
throw new Error(`The AI provider could not complete this modeling request: ${error instanceof Error ? error.message : String(error)}`);
|
|
17257
|
+
}
|
|
17258
|
+
const parsed = parseAiModelingOperations(raw);
|
|
17259
|
+
if (parsed.length === 0)
|
|
17260
|
+
throw new Error('Modeling AI did not return any usable modeling changes. Try naming the models or the relationship you want.');
|
|
17261
|
+
// Validate every identifier against the live snapshot. An operation that
|
|
17262
|
+
// references something the project does not have is dropped, not repaired.
|
|
17263
|
+
const knownDbtIds = new Set(Object.keys(manifest.dbtProvenance.nodes));
|
|
17264
|
+
const columnsFor = new Map();
|
|
17265
|
+
const proposedEntityIds = new Set(Object.values(modeling.entities).filter((entity) => entity.domain === domain).map((entity) => entity.localId));
|
|
17266
|
+
const operations = [];
|
|
17267
|
+
for (const candidate of parsed) {
|
|
17268
|
+
if (candidate.kind === 'upsert_entity') {
|
|
17269
|
+
if (!candidate.dbtModel || !knownDbtIds.has(candidate.dbtModel))
|
|
17270
|
+
continue;
|
|
17271
|
+
const id = authoringSlug(candidate.id || candidate.dbtModel.split('.').at(-1) || 'model').replace(/-/g, '_');
|
|
17272
|
+
if (!id)
|
|
17273
|
+
continue;
|
|
17274
|
+
proposedEntityIds.add(id);
|
|
17275
|
+
const detail = manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, candidate.dbtModel) : undefined;
|
|
17276
|
+
const columns = new Set((detail?.columns ?? []).map((column) => column.name.toLowerCase()));
|
|
17277
|
+
columnsFor.set(id, columns);
|
|
17278
|
+
operations.push({
|
|
17279
|
+
id: `entity:${domain}:${id}`,
|
|
17280
|
+
kind: 'modeling_change',
|
|
17281
|
+
evidence: [candidate.dbtModel, ...(detail?.sourcePath ? [detail.sourcePath] : [])],
|
|
17282
|
+
change: {
|
|
17283
|
+
operation: 'upsert_entity',
|
|
17284
|
+
value: {
|
|
17285
|
+
id, domain, areaId, dbtModel: candidate.dbtModel,
|
|
17286
|
+
businessName: candidate.businessName?.trim() || titleCaseAuthoring(id),
|
|
17287
|
+
businessContext: candidate.businessContext?.trim() || undefined,
|
|
17288
|
+
grain: candidate.grain && columns.has(candidate.grain.toLowerCase()) ? candidate.grain : undefined,
|
|
17289
|
+
keys: (candidate.keys ?? []).filter((key) => columns.has(key.toLowerCase())),
|
|
17290
|
+
analyticalRole: parseImportedAnalyticalRole(candidate.analyticalRole) ?? 'unknown',
|
|
17291
|
+
status: 'draft',
|
|
17292
|
+
},
|
|
17293
|
+
},
|
|
17294
|
+
});
|
|
17295
|
+
continue;
|
|
17296
|
+
}
|
|
17297
|
+
const from = authoringSlug(candidate.from ?? '').replace(/-/g, '_');
|
|
17298
|
+
const to = authoringSlug(candidate.to ?? '').replace(/-/g, '_');
|
|
17299
|
+
if (!proposedEntityIds.has(from) || !proposedEntityIds.has(to) || from === to)
|
|
17300
|
+
continue;
|
|
17301
|
+
const knownColumns = (entityId) => {
|
|
17302
|
+
const cached = columnsFor.get(entityId);
|
|
17303
|
+
if (cached)
|
|
17304
|
+
return cached;
|
|
17305
|
+
const entity = Object.values(modeling.entities).find((item) => item.domain === domain && item.localId === entityId);
|
|
17306
|
+
const detail = entity && manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, entity.dbtUniqueId) : undefined;
|
|
17307
|
+
const columns = new Set((detail?.columns ?? []).map((column) => column.name.toLowerCase()));
|
|
17308
|
+
columnsFor.set(entityId, columns);
|
|
17309
|
+
return columns;
|
|
17310
|
+
};
|
|
17311
|
+
const fromColumns = knownColumns(from);
|
|
17312
|
+
const toColumns = knownColumns(to);
|
|
17313
|
+
const keys = (candidate.keys ?? []).filter((key) => key?.from && key?.to && fromColumns.has(key.from.toLowerCase()) && toColumns.has(key.to.toLowerCase()));
|
|
17314
|
+
if (keys.length === 0)
|
|
17315
|
+
continue;
|
|
17316
|
+
const id = authoringSlug(candidate.id || `${from}_to_${to}`).replace(/-/g, '_');
|
|
17317
|
+
operations.push({
|
|
17318
|
+
id: `relationship:${domain}:${id}`,
|
|
17319
|
+
kind: 'modeling_change',
|
|
17320
|
+
dependsOn: [`entity:${domain}:${from}`, `entity:${domain}:${to}`].filter((dependency) => operations.some((operation) => operation.id === dependency)),
|
|
17321
|
+
evidence: ['Modeling AI proposal from dbt metadata; requires warehouse validation before certification'],
|
|
17322
|
+
change: {
|
|
17323
|
+
operation: 'upsert_relationship',
|
|
17324
|
+
value: {
|
|
17325
|
+
id, domain, areaId, from, to, keys,
|
|
17326
|
+
cardinality: parseImportedCardinality(candidate.cardinality),
|
|
17327
|
+
fanout: parseImportedFanout(candidate.fanout),
|
|
17328
|
+
status: 'draft',
|
|
17329
|
+
verb: candidate.verb?.trim() || undefined,
|
|
17330
|
+
description: candidate.description?.trim() || undefined,
|
|
17331
|
+
rationale: candidate.rationale?.trim() || undefined,
|
|
17332
|
+
},
|
|
17333
|
+
},
|
|
17334
|
+
});
|
|
17335
|
+
}
|
|
17336
|
+
if (operations.length === 0)
|
|
17337
|
+
throw new Error('Modeling AI referenced models or columns that are not in the current dbt snapshot, so nothing was proposed.');
|
|
17338
|
+
return operations;
|
|
17339
|
+
}
|
|
17340
|
+
/** Tolerant JSON extraction; a malformed provider reply yields no operations. */
|
|
17341
|
+
export function parseAiModelingOperations(raw) {
|
|
17342
|
+
const fenced = raw.match(/```json\s*([\s\S]*?)```/i)?.[1] ?? raw.match(/```\s*([\s\S]*?)```/)?.[1] ?? raw;
|
|
17343
|
+
let parsed;
|
|
17344
|
+
try {
|
|
17345
|
+
parsed = JSON.parse(fenced.trim());
|
|
17346
|
+
}
|
|
17347
|
+
catch {
|
|
17348
|
+
return [];
|
|
17349
|
+
}
|
|
17350
|
+
const text = (value) => (typeof value === 'string' ? value : undefined);
|
|
17351
|
+
return contextArray(contextRecord(parsed).operations).flatMap((value) => {
|
|
17352
|
+
const record = contextRecord(value);
|
|
17353
|
+
if (record.kind === 'upsert_entity') {
|
|
17354
|
+
return [{
|
|
17355
|
+
kind: 'upsert_entity',
|
|
17356
|
+
id: text(record.id),
|
|
17357
|
+
dbtModel: text(record.dbtModel),
|
|
17358
|
+
businessName: text(record.businessName),
|
|
17359
|
+
businessContext: text(record.businessContext),
|
|
17360
|
+
grain: text(record.grain),
|
|
17361
|
+
analyticalRole: record.analyticalRole,
|
|
17362
|
+
keys: contextStrings(record.keys),
|
|
17363
|
+
}];
|
|
17364
|
+
}
|
|
17365
|
+
if (record.kind === 'upsert_relationship') {
|
|
17366
|
+
return [{
|
|
17367
|
+
kind: 'upsert_relationship',
|
|
17368
|
+
id: text(record.id),
|
|
17369
|
+
from: text(record.from),
|
|
17370
|
+
to: text(record.to),
|
|
17371
|
+
verb: text(record.verb),
|
|
17372
|
+
description: text(record.description),
|
|
17373
|
+
rationale: text(record.rationale),
|
|
17374
|
+
cardinality: record.cardinality,
|
|
17375
|
+
fanout: record.fanout,
|
|
17376
|
+
keys: contextArray(record.keys).flatMap((entry) => {
|
|
17377
|
+
const key = contextRecord(entry);
|
|
17378
|
+
return typeof key.from === 'string' && typeof key.to === 'string' ? [{ from: key.from, to: key.to }] : [];
|
|
17379
|
+
}),
|
|
17380
|
+
}];
|
|
17381
|
+
}
|
|
17382
|
+
return [];
|
|
17383
|
+
});
|
|
17384
|
+
}
|
|
17385
|
+
/**
|
|
17386
|
+
* Evidence-only modeling suggestions: deterministic, no provider required.
|
|
17387
|
+
*
|
|
17388
|
+
* This is the `AGT-024` provider-unavailable fallback, not "Modeling AI". It
|
|
17389
|
+
* matches dbt model names against the request and pairs shared `*_id` columns.
|
|
17390
|
+
* Callers must label its output honestly so a user is never told a template
|
|
17391
|
+
* expansion was an AI proposal.
|
|
17392
|
+
*/
|
|
17393
|
+
function buildDeterministicModelingOperations(projectRoot, manifest, request) {
|
|
16557
17394
|
const modeling = manifest.modeling;
|
|
16558
17395
|
if (!modeling || !manifest.dbtProvenance)
|
|
16559
17396
|
throw new Error('Modeling AI requires a compiled dbt-first project snapshot.');
|
|
@@ -16589,7 +17426,7 @@ function buildMetadataBoundModelingOperations(projectRoot, manifest, request) {
|
|
|
16589
17426
|
value: {
|
|
16590
17427
|
id: selectedRelationship.localId,
|
|
16591
17428
|
domain: selectedRelationship.ownerDomain ?? domain,
|
|
16592
|
-
areaId: selectedRelationship.areaId
|
|
17429
|
+
areaId: modelAreaLocalId(selectedRelationship.areaId),
|
|
16593
17430
|
from: modeling.entities[selectedRelationship.from]?.localId ?? selectedRelationship.from,
|
|
16594
17431
|
to: modeling.entities[selectedRelationship.to]?.localId ?? selectedRelationship.to,
|
|
16595
17432
|
keys: selectedRelationship.keys,
|
|
@@ -16617,33 +17454,15 @@ function buildMetadataBoundModelingOperations(projectRoot, manifest, request) {
|
|
|
16617
17454
|
? Object.values(modeling.entities).find((item) => item.qualifiedId === selectedIdentity || item.id === selectedIdentity || item.localId === selectedIdentity)
|
|
16618
17455
|
: undefined;
|
|
16619
17456
|
if (selectedEntity) {
|
|
16620
|
-
|
|
16621
|
-
|
|
16622
|
-
|
|
16623
|
-
|
|
16624
|
-
change: {
|
|
16625
|
-
operation: 'upsert_entity',
|
|
16626
|
-
value: {
|
|
16627
|
-
id: selectedEntity.localId,
|
|
16628
|
-
domain: selectedEntity.domain,
|
|
16629
|
-
areaId: selectedEntity.areaId?.split('::area::').at(-1),
|
|
16630
|
-
dbtModel: selectedEntity.dbtUniqueId,
|
|
16631
|
-
businessName: selectedEntity.businessName,
|
|
16632
|
-
businessContext: request.question.trim(),
|
|
16633
|
-
conceptRefs: selectedEntity.conceptRefs,
|
|
16634
|
-
analyticalRole: selectedEntity.analyticalRole,
|
|
16635
|
-
status: 'draft',
|
|
16636
|
-
owner: selectedEntity.owner,
|
|
16637
|
-
grain: selectedEntity.grain,
|
|
16638
|
-
keys: selectedEntity.keys,
|
|
16639
|
-
},
|
|
16640
|
-
},
|
|
16641
|
-
}];
|
|
17457
|
+
// Without a provider there is nothing evidence-backed to propose here.
|
|
17458
|
+
// Copying the raw request into `businessContext` would present the user's
|
|
17459
|
+
// own prompt back to them as authored business meaning.
|
|
17460
|
+
throw new Error('Writing business context needs an AI provider. Configure one in Settings, or edit this model directly in Modeling.');
|
|
16642
17461
|
}
|
|
16643
17462
|
const requestedArea = typeof context.modelAreaId === 'string' ? context.modelAreaId : typeof context.areaId === 'string' ? context.areaId : undefined;
|
|
16644
17463
|
const existingArea = Object.values(modeling.areas).find((area) => area.domain === domain
|
|
16645
17464
|
&& (!requestedArea || area.qualifiedId === requestedArea || area.localId === requestedArea));
|
|
16646
|
-
const areaId = existingArea?.localId ?? requestedArea
|
|
17465
|
+
const areaId = existingArea?.localId ?? modelAreaLocalId(requestedArea) ?? `${authoringSlug(request.question).slice(0, 36) || 'metadata'}_models`;
|
|
16647
17466
|
const operations = [];
|
|
16648
17467
|
if (!existingArea) {
|
|
16649
17468
|
operations.push({
|
|
@@ -16701,6 +17520,81 @@ function buildMetadataBoundModelingOperations(projectRoot, manifest, request) {
|
|
|
16701
17520
|
}
|
|
16702
17521
|
return operations;
|
|
16703
17522
|
}
|
|
17523
|
+
/**
|
|
17524
|
+
* Skills AI: draft a domain skill's purpose, guardrails, and vocabulary.
|
|
17525
|
+
*
|
|
17526
|
+
* `SKILL-006` — the model sees qualified modeling/metric references, the
|
|
17527
|
+
* current skill, and the Domain/Area scope. It writes prose and vocabulary
|
|
17528
|
+
* only; every governed reference is taken from the deterministic base built
|
|
17529
|
+
* below, so it cannot invent a metric, block, or model. `previewContextAuthoring`
|
|
17530
|
+
* independently forces `status: 'draft'`, so a drafted skill never guides an
|
|
17531
|
+
* agent until a human activates it.
|
|
17532
|
+
*/
|
|
17533
|
+
async function buildAiSkillOperations(projectRoot, manifest, request, provider) {
|
|
17534
|
+
const base = buildMetadataBoundSkillOperations(projectRoot, manifest, request);
|
|
17535
|
+
const operation = base[0];
|
|
17536
|
+
if (!operation || operation.kind !== 'skill_change')
|
|
17537
|
+
return base;
|
|
17538
|
+
const raw = await provider.generate([
|
|
17539
|
+
{
|
|
17540
|
+
role: 'system',
|
|
17541
|
+
content: [
|
|
17542
|
+
'You write governed analytics guidance for one business domain.',
|
|
17543
|
+
'Return exactly one JSON object in a ```json fence: {"description":"...","body":"...","triggers":["..."],"clarifyWhen":["..."],"vocabulary":{"term":"meaning"}}. No prose outside the fence.',
|
|
17544
|
+
'body is markdown with a "## Purpose" section, a "## Guardrails" section, and a "## Clarify" section.',
|
|
17545
|
+
'Write guidance an analyst would follow. Never restate the request back as the guidance.',
|
|
17546
|
+
'Never name a metric, block, model, or relationship that is not in the supplied governed references.',
|
|
17547
|
+
'Do not grant tools, authorize joins or data access, activate the skill, or write executable code.',
|
|
17548
|
+
].join(' '),
|
|
17549
|
+
},
|
|
17550
|
+
{
|
|
17551
|
+
role: 'user',
|
|
17552
|
+
content: JSON.stringify({
|
|
17553
|
+
request: request.question.trim(),
|
|
17554
|
+
domain: operation.value.domain,
|
|
17555
|
+
modelAreaRefs: operation.value.modelAreaRefs,
|
|
17556
|
+
currentSkill: { id: operation.value.id, description: operation.value.description, body: operation.value.body },
|
|
17557
|
+
governedReferences: {
|
|
17558
|
+
metrics: operation.value.preferredMetrics,
|
|
17559
|
+
blocks: operation.value.preferredBlocks,
|
|
17560
|
+
modelingRefs: operation.value.sourceRefs,
|
|
17561
|
+
},
|
|
17562
|
+
}),
|
|
17563
|
+
},
|
|
17564
|
+
], { maxTokens: 2000, temperature: 0.2 });
|
|
17565
|
+
const fenced = raw.match(/```json\s*([\s\S]*?)```/i)?.[1] ?? raw.match(/```\s*([\s\S]*?)```/)?.[1] ?? raw;
|
|
17566
|
+
let parsed;
|
|
17567
|
+
try {
|
|
17568
|
+
parsed = contextRecord(JSON.parse(fenced.trim()));
|
|
17569
|
+
}
|
|
17570
|
+
catch {
|
|
17571
|
+
return base;
|
|
17572
|
+
}
|
|
17573
|
+
const body = typeof parsed.body === 'string' ? parsed.body.trim() : '';
|
|
17574
|
+
if (!body)
|
|
17575
|
+
return base;
|
|
17576
|
+
const vocabulary = contextRecord(parsed.vocabulary);
|
|
17577
|
+
return [{
|
|
17578
|
+
...operation,
|
|
17579
|
+
evidence: [...(operation.evidence ?? []), 'drafted by Skills AI from governed references only'],
|
|
17580
|
+
value: {
|
|
17581
|
+
...operation.value,
|
|
17582
|
+
description: typeof parsed.description === 'string' && parsed.description.trim() ? parsed.description.trim() : operation.value.description,
|
|
17583
|
+
body,
|
|
17584
|
+
triggers: contextStrings(parsed.triggers).slice(0, 12).length ? contextStrings(parsed.triggers).slice(0, 12) : operation.value.triggers,
|
|
17585
|
+
clarifyWhen: contextStrings(parsed.clarifyWhen).slice(0, 8).length ? contextStrings(parsed.clarifyWhen).slice(0, 8) : operation.value.clarifyWhen,
|
|
17586
|
+
vocabulary: Object.fromEntries(Object.entries(vocabulary)
|
|
17587
|
+
.filter(([, value]) => typeof value === 'string')
|
|
17588
|
+
.slice(0, 24)),
|
|
17589
|
+
// Governed references always come from the deterministic base; the model
|
|
17590
|
+
// never gets to add one.
|
|
17591
|
+
preferredMetrics: operation.value.preferredMetrics,
|
|
17592
|
+
preferredBlocks: operation.value.preferredBlocks,
|
|
17593
|
+
sourceRefs: operation.value.sourceRefs,
|
|
17594
|
+
status: 'draft',
|
|
17595
|
+
},
|
|
17596
|
+
}];
|
|
17597
|
+
}
|
|
16704
17598
|
function buildMetadataBoundSkillOperations(projectRoot, manifest, request) {
|
|
16705
17599
|
const context = request.workspaceContext ?? {};
|
|
16706
17600
|
const selectedIdentity = request.selectedObject?.kind === 'skill' ? request.selectedObject.id : undefined;
|
|
@@ -16761,6 +17655,18 @@ function buildMetadataBoundSkillOperations(projectRoot, manifest, request) {
|
|
|
16761
17655
|
value,
|
|
16762
17656
|
}];
|
|
16763
17657
|
}
|
|
17658
|
+
/**
|
|
17659
|
+
* The Domain an author lands in when none is selected: the single existing
|
|
17660
|
+
* Domain if there is exactly one, otherwise a new one named after the dbt
|
|
17661
|
+
* project. Callers keep full control — this is only the prefilled default.
|
|
17662
|
+
*/
|
|
17663
|
+
function defaultAuthoringDomainId(manifest) {
|
|
17664
|
+
const packages = Object.keys(manifest.modeling?.packages ?? {});
|
|
17665
|
+
if (packages.length === 1)
|
|
17666
|
+
return packages[0];
|
|
17667
|
+
const projectName = manifest.dbtProvenance?.projectName;
|
|
17668
|
+
return projectName ? authoringSlug(projectName).replace(/-/g, '_') || 'analytics' : 'analytics';
|
|
17669
|
+
}
|
|
16764
17670
|
function authoringTokens(value) {
|
|
16765
17671
|
const stop = new Set(['the', 'and', 'for', 'with', 'from', 'this', 'that', 'build', 'create', 'model', 'models', 'skill', 'please', 'into', 'about']);
|
|
16766
17672
|
return [...new Set(value.toLowerCase().split(/[^a-z0-9_]+/).filter((token) => token.length > 2 && !stop.has(token)))];
|
|
@@ -18184,6 +19090,132 @@ export function dashboardRuntimeVariables(dashboard, overrides = {}) {
|
|
|
18184
19090
|
}
|
|
18185
19091
|
return { ...variables, ...overrides };
|
|
18186
19092
|
}
|
|
19093
|
+
/** Resolve semantic filters only through the component's explicit mapping. */
|
|
19094
|
+
export function dashboardSemanticFiltersForTile(dashboard, item, dashboardValues) {
|
|
19095
|
+
if (!item.semantic)
|
|
19096
|
+
return [];
|
|
19097
|
+
return (dashboard.filters ?? []).flatMap((filter) => {
|
|
19098
|
+
if (filter.scope?.tileIds && !filter.scope.tileIds.includes(item.i))
|
|
19099
|
+
return [];
|
|
19100
|
+
const binding = item.filterBindings?.find((candidate) => candidate.filter === filter.id
|
|
19101
|
+
&& candidate.capability !== 'unsupported'
|
|
19102
|
+
&& !candidate.unsupportedReason);
|
|
19103
|
+
const dimension = binding?.binding;
|
|
19104
|
+
const value = dashboardValues[filter.id];
|
|
19105
|
+
if (!dimension || !item.semantic?.dimensions?.includes(dimension) || isEmptyDashboardFilterValue(value))
|
|
19106
|
+
return [];
|
|
19107
|
+
const values = Array.isArray(value) ? value.map(String) : [String(value)];
|
|
19108
|
+
return [{ dimension, operator: values.length > 1 ? 'in' : 'equals', values }];
|
|
19109
|
+
});
|
|
19110
|
+
}
|
|
19111
|
+
/**
|
|
19112
|
+
* Return bounded, run-scoped filter choices only from columns the server has
|
|
19113
|
+
* already proven safe for predicate filtering. These sampled labels are sent
|
|
19114
|
+
* to the browser for a searchable control and are never written to `.dqld`.
|
|
19115
|
+
*/
|
|
19116
|
+
export function collectDashboardFilterOptions(dashboard, tiles, defaultLimit = 100) {
|
|
19117
|
+
const items = new Map(dashboard.layout.items.map((item) => [item.i, item]));
|
|
19118
|
+
const optionSets = [];
|
|
19119
|
+
for (const filter of dashboard.filters ?? []) {
|
|
19120
|
+
if (filter.type === 'boolean' || filter.type === 'relative_date' || filter.type === 'number_range')
|
|
19121
|
+
continue;
|
|
19122
|
+
const dateControl = filter.type === 'daterange' || filter.type === 'date';
|
|
19123
|
+
const limit = Math.max(1, Math.min(100, filter.optionSource?.limit ?? defaultLimit));
|
|
19124
|
+
const values = new Set();
|
|
19125
|
+
const sourceTileIds = [];
|
|
19126
|
+
let truncated = false;
|
|
19127
|
+
for (const tile of tiles) {
|
|
19128
|
+
const result = dashboardFilterOptionResult(tile.result);
|
|
19129
|
+
if (tile.status !== 'ok' || !result?.rows.length)
|
|
19130
|
+
continue;
|
|
19131
|
+
const item = items.get(tile.tileId);
|
|
19132
|
+
const binding = item?.filterBindings?.find((candidate) => candidate.filter === filter.id && candidate.capability !== 'unsupported');
|
|
19133
|
+
if (!binding)
|
|
19134
|
+
continue;
|
|
19135
|
+
const requestedColumn = dashboardOutputColumn(binding?.binding ?? filter.field?.name ?? filter.bindsTo ?? filter.id);
|
|
19136
|
+
if (!requestedColumn)
|
|
19137
|
+
continue;
|
|
19138
|
+
const safeColumn = tile.filterableColumns?.find((candidate) => normalizeDashboardOptionColumn(candidate.column) === normalizeDashboardOptionColumn(requestedColumn));
|
|
19139
|
+
if (!safeColumn)
|
|
19140
|
+
continue;
|
|
19141
|
+
const resultColumn = result.columns.find((column) => normalizeDashboardOptionColumn(column) === normalizeDashboardOptionColumn(safeColumn.column));
|
|
19142
|
+
if (!resultColumn)
|
|
19143
|
+
continue;
|
|
19144
|
+
sourceTileIds.push(tile.tileId);
|
|
19145
|
+
for (const row of result.rows) {
|
|
19146
|
+
const value = dashboardFilterOptionValue(row[resultColumn]);
|
|
19147
|
+
if (value !== null)
|
|
19148
|
+
values.add(value);
|
|
19149
|
+
}
|
|
19150
|
+
if (result.truncated || (result.rowCount ?? result.rows.length) > result.rows.length)
|
|
19151
|
+
truncated = true;
|
|
19152
|
+
}
|
|
19153
|
+
if (values.size === 0) {
|
|
19154
|
+
if (dateControl) {
|
|
19155
|
+
optionSets.push({
|
|
19156
|
+
filterId: filter.id,
|
|
19157
|
+
values: [],
|
|
19158
|
+
truncated,
|
|
19159
|
+
sourceTileIds: Array.from(new Set(sourceTileIds)),
|
|
19160
|
+
valueCount: 0,
|
|
19161
|
+
});
|
|
19162
|
+
}
|
|
19163
|
+
continue;
|
|
19164
|
+
}
|
|
19165
|
+
const sorted = Array.from(values).sort((left, right) => left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' }));
|
|
19166
|
+
const dateValues = dateControl
|
|
19167
|
+
? sorted
|
|
19168
|
+
.map((value) => ({ value, timestamp: Date.parse(value) }))
|
|
19169
|
+
.filter((item) => Number.isFinite(item.timestamp))
|
|
19170
|
+
.sort((left, right) => left.timestamp - right.timestamp)
|
|
19171
|
+
: [];
|
|
19172
|
+
optionSets.push({
|
|
19173
|
+
filterId: filter.id,
|
|
19174
|
+
values: dateControl ? [] : sorted.slice(0, limit),
|
|
19175
|
+
truncated: dateControl ? truncated : truncated || sorted.length > limit,
|
|
19176
|
+
sourceTileIds: Array.from(new Set(sourceTileIds)),
|
|
19177
|
+
...(dateControl ? {
|
|
19178
|
+
valueCount: dateValues.length,
|
|
19179
|
+
...(dateValues.length ? {
|
|
19180
|
+
dateRange: {
|
|
19181
|
+
min: new Date(dateValues[0].timestamp).toISOString().slice(0, 10),
|
|
19182
|
+
max: new Date(dateValues[dateValues.length - 1].timestamp).toISOString().slice(0, 10),
|
|
19183
|
+
},
|
|
19184
|
+
} : {}),
|
|
19185
|
+
} : {}),
|
|
19186
|
+
});
|
|
19187
|
+
}
|
|
19188
|
+
return optionSets;
|
|
19189
|
+
}
|
|
19190
|
+
function dashboardFilterOptionResult(value) {
|
|
19191
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
19192
|
+
return null;
|
|
19193
|
+
const record = value;
|
|
19194
|
+
if (!Array.isArray(record.columns) || !record.columns.every((column) => typeof column === 'string'))
|
|
19195
|
+
return null;
|
|
19196
|
+
if (!Array.isArray(record.rows) || !record.rows.every((row) => Boolean(row) && typeof row === 'object' && !Array.isArray(row)))
|
|
19197
|
+
return null;
|
|
19198
|
+
return {
|
|
19199
|
+
columns: record.columns,
|
|
19200
|
+
rows: record.rows,
|
|
19201
|
+
...(typeof record.rowCount === 'number' ? { rowCount: record.rowCount } : {}),
|
|
19202
|
+
...(typeof record.truncated === 'boolean' ? { truncated: record.truncated } : {}),
|
|
19203
|
+
};
|
|
19204
|
+
}
|
|
19205
|
+
function normalizeDashboardOptionColumn(value) {
|
|
19206
|
+
return value.replace(/[`"[\]]/g, '').trim().split('.').filter(Boolean).at(-1)?.toLowerCase() ?? '';
|
|
19207
|
+
}
|
|
19208
|
+
function dashboardFilterOptionValue(value) {
|
|
19209
|
+
if (typeof value === 'string')
|
|
19210
|
+
return value.trim() ? value : null;
|
|
19211
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
19212
|
+
return String(value);
|
|
19213
|
+
if (typeof value === 'boolean')
|
|
19214
|
+
return String(value);
|
|
19215
|
+
if (value instanceof Date && Number.isFinite(value.getTime()))
|
|
19216
|
+
return value.toISOString();
|
|
19217
|
+
return null;
|
|
19218
|
+
}
|
|
18187
19219
|
/**
|
|
18188
19220
|
* Resolve only declared tile parameter bindings. Dashboard filters remain
|
|
18189
19221
|
* values; they never become SQL identifiers or an implicit name-based binding.
|
|
@@ -18224,6 +19256,10 @@ export function applyDashboardFiltersToBlockExecution(input) {
|
|
|
18224
19256
|
const clauses = [];
|
|
18225
19257
|
let nextPosition = sqlParams.reduce((max, param) => Math.max(max, param.position), 0);
|
|
18226
19258
|
for (const filter of input.dashboard.filters ?? []) {
|
|
19259
|
+
if (filter.scope?.tileIds && input.tileId && !filter.scope.tileIds.includes(input.tileId)) {
|
|
19260
|
+
skippedFilters.push({ filter: filter.id, reason: 'component is outside this filter scope' });
|
|
19261
|
+
continue;
|
|
19262
|
+
}
|
|
18227
19263
|
const value = dashboardFilterValue(filter, variables);
|
|
18228
19264
|
if (isEmptyDashboardFilterValue(value)) {
|
|
18229
19265
|
skippedFilters.push({ filter: filter.id, reason: 'no value supplied' });
|
|
@@ -18238,7 +19274,7 @@ export function applyDashboardFiltersToBlockExecution(input) {
|
|
|
18238
19274
|
});
|
|
18239
19275
|
continue;
|
|
18240
19276
|
}
|
|
18241
|
-
const binding = resolveDashboardFilterBinding(filter, input.block);
|
|
19277
|
+
const binding = resolveDashboardFilterBinding(filter, input.block, input.tileFilterBindings);
|
|
18242
19278
|
if (!binding) {
|
|
18243
19279
|
skippedFilters.push({ filter: filter.id, reason: `block "${input.block.name}" does not declare a compatible filter binding` });
|
|
18244
19280
|
continue;
|
|
@@ -18360,8 +19396,16 @@ function bindDashboardFilterToExistingParams(filter, value, block, sqlParams, va
|
|
|
18360
19396
|
}
|
|
18361
19397
|
return [];
|
|
18362
19398
|
}
|
|
18363
|
-
function resolveDashboardFilterBinding(filter, block) {
|
|
19399
|
+
function resolveDashboardFilterBinding(filter, block, tileFilterBindings = []) {
|
|
18364
19400
|
const candidates = uniqueDashboardStrings([filter.id, filter.bindsTo ?? '']).map(normalizeDashboardFilterName);
|
|
19401
|
+
for (const entry of tileFilterBindings ?? []) {
|
|
19402
|
+
const mode = entry.mode ?? (entry.binding ? 'predicate' : undefined);
|
|
19403
|
+
if (mode === 'predicate'
|
|
19404
|
+
&& entry.binding
|
|
19405
|
+
&& !entry.unsupportedReason
|
|
19406
|
+
&& candidates.includes(normalizeDashboardFilterName(entry.filter)))
|
|
19407
|
+
return entry.binding;
|
|
19408
|
+
}
|
|
18365
19409
|
for (const entry of block.filterBindings ?? []) {
|
|
18366
19410
|
if (candidates.includes(normalizeDashboardFilterName(entry.filter)))
|
|
18367
19411
|
return entry.binding;
|
|
@@ -18405,6 +19449,13 @@ function buildDashboardFilterPredicate(input) {
|
|
|
18405
19449
|
const values = Array.isArray(input.value) ? input.value.filter((item) => !isEmptyDashboardFilterValue(item)) : [input.value];
|
|
18406
19450
|
if (values.length === 0)
|
|
18407
19451
|
return null;
|
|
19452
|
+
if (input.filterType === 'search' && values.length === 1) {
|
|
19453
|
+
const search = String(values[0] ?? '').trim();
|
|
19454
|
+
if (!search)
|
|
19455
|
+
return null;
|
|
19456
|
+
const param = addDashboardFilterParam(input, 'contains', `%${search}%`);
|
|
19457
|
+
return `LOWER(${input.expression}) LIKE LOWER($${param.position})`;
|
|
19458
|
+
}
|
|
18408
19459
|
if (values.length === 1) {
|
|
18409
19460
|
const param = addDashboardFilterParam(input, 'value', values[0]);
|
|
18410
19461
|
return `${input.expression} = $${param.position}`;
|
|
@@ -19825,14 +20876,224 @@ export function openBlockStudioDocument(projectRoot, relativePath, semanticLayer
|
|
|
19825
20876
|
metadata,
|
|
19826
20877
|
companionPath: companionPath && existsSync(join(projectRoot, companionPath)) ? companionPath : null,
|
|
19827
20878
|
validation: validateBlockStudioSource(source, semanticLayer),
|
|
20879
|
+
lastRun: readBlockStudioRunSummary(projectRoot, normalizedPath, source) ?? undefined,
|
|
19828
20880
|
};
|
|
19829
20881
|
}
|
|
20882
|
+
function blockStudioExecutionFingerprint(source) {
|
|
20883
|
+
return createHash('sha256')
|
|
20884
|
+
.update(setBlockStudioStatusInSource(source, 'draft').trim())
|
|
20885
|
+
.digest('hex');
|
|
20886
|
+
}
|
|
20887
|
+
function blockStudioRunSummaryPath(projectRoot, blockPath) {
|
|
20888
|
+
const normalizedPath = normalize(blockPath).replace(/^\/+/, '').replaceAll('\\', '/');
|
|
20889
|
+
const pathKey = createHash('sha256').update(normalizedPath).digest('hex').slice(0, 32);
|
|
20890
|
+
return join(projectRoot, '.dql', 'runs', 'block-studio', `${pathKey}.json`);
|
|
20891
|
+
}
|
|
20892
|
+
function writeBlockStudioRunSummary(projectRoot, blockPath, source, result) {
|
|
20893
|
+
const normalizedPath = normalize(blockPath).replace(/^\/+/, '').replaceAll('\\', '/');
|
|
20894
|
+
if (!isBlockStudioBlockPath(normalizedPath))
|
|
20895
|
+
return;
|
|
20896
|
+
const receiptPath = blockStudioRunSummaryPath(projectRoot, normalizedPath);
|
|
20897
|
+
mkdirSync(dirname(receiptPath), { recursive: true });
|
|
20898
|
+
const receipt = {
|
|
20899
|
+
version: 1,
|
|
20900
|
+
blockPath: normalizedPath,
|
|
20901
|
+
sourceFingerprint: blockStudioExecutionFingerprint(source),
|
|
20902
|
+
rowCount: result.rowCount ?? result.rows.length,
|
|
20903
|
+
executionTime: result.executionTime,
|
|
20904
|
+
columns: result.columns,
|
|
20905
|
+
ranAt: new Date().toISOString(),
|
|
20906
|
+
};
|
|
20907
|
+
const tempPath = `${receiptPath}.${process.pid}.tmp`;
|
|
20908
|
+
writeFileSync(tempPath, JSON.stringify(receipt, null, 2) + '\n', 'utf-8');
|
|
20909
|
+
renameSync(tempPath, receiptPath);
|
|
20910
|
+
}
|
|
20911
|
+
function readBlockStudioRunSummary(projectRoot, blockPath, source) {
|
|
20912
|
+
try {
|
|
20913
|
+
const normalizedPath = normalize(blockPath).replace(/^\/+/, '').replaceAll('\\', '/');
|
|
20914
|
+
const receiptPath = blockStudioRunSummaryPath(projectRoot, normalizedPath);
|
|
20915
|
+
if (!existsSync(receiptPath))
|
|
20916
|
+
return null;
|
|
20917
|
+
const receipt = JSON.parse(readFileSync(receiptPath, 'utf-8'));
|
|
20918
|
+
if (receipt.version !== 1
|
|
20919
|
+
|| receipt.blockPath !== normalizedPath
|
|
20920
|
+
|| receipt.sourceFingerprint !== blockStudioExecutionFingerprint(source)
|
|
20921
|
+
|| !Number.isFinite(receipt.rowCount)
|
|
20922
|
+
|| !Array.isArray(receipt.columns)
|
|
20923
|
+
|| typeof receipt.ranAt !== 'string')
|
|
20924
|
+
return null;
|
|
20925
|
+
return {
|
|
20926
|
+
rowCount: Number(receipt.rowCount),
|
|
20927
|
+
executionTime: typeof receipt.executionTime === 'number' ? receipt.executionTime : undefined,
|
|
20928
|
+
columns: receipt.columns.filter((column) => typeof column === 'string'),
|
|
20929
|
+
ranAt: receipt.ranAt,
|
|
20930
|
+
};
|
|
20931
|
+
}
|
|
20932
|
+
catch {
|
|
20933
|
+
return null;
|
|
20934
|
+
}
|
|
20935
|
+
}
|
|
19830
20936
|
const BLOCK_STUDIO_DRAFT_RUNTIME_DIAGNOSTICS = new Set([
|
|
19831
20937
|
'metricflow_unavailable',
|
|
19832
20938
|
'semantic_compose_failed',
|
|
19833
20939
|
'semantic_layer_missing',
|
|
19834
20940
|
'semantic_runtime_required',
|
|
19835
20941
|
]);
|
|
20942
|
+
const BLOCK_STUDIO_RUNTIME_COMPILE_DIAGNOSTICS = new Set([
|
|
20943
|
+
'metricflow_unavailable',
|
|
20944
|
+
'semantic_compose_failed',
|
|
20945
|
+
'semantic_layer_missing',
|
|
20946
|
+
'semantic_runtime_check_required',
|
|
20947
|
+
'semantic_runtime_required',
|
|
20948
|
+
]);
|
|
20949
|
+
function staticSemanticAuthoringDiagnostic(diagnostic) {
|
|
20950
|
+
if (!BLOCK_STUDIO_RUNTIME_COMPILE_DIAGNOSTICS.has(diagnostic.code ?? ''))
|
|
20951
|
+
return diagnostic;
|
|
20952
|
+
return {
|
|
20953
|
+
severity: 'warning',
|
|
20954
|
+
code: 'semantic_runtime_check_required',
|
|
20955
|
+
title: 'Run required for semantic validation',
|
|
20956
|
+
field: 'Runtime',
|
|
20957
|
+
message: 'The block structure and references are valid, but only the configured semantic runtime can prove this metric combination.',
|
|
20958
|
+
correction: 'Run the block to validate compilation and data access before certification.',
|
|
20959
|
+
technicalDetails: diagnostic.message,
|
|
20960
|
+
action: 'run_again',
|
|
20961
|
+
};
|
|
20962
|
+
}
|
|
20963
|
+
function presentBlockStudioDiagnostic(diagnostic, semanticRefs) {
|
|
20964
|
+
if (diagnostic.title || diagnostic.correction)
|
|
20965
|
+
return diagnostic;
|
|
20966
|
+
const raw = diagnostic.message.trim();
|
|
20967
|
+
const locationMatch = /(?:line\s+)?(\d+)[: ,]+(?:column\s+)?(\d+)/i.exec(raw);
|
|
20968
|
+
const technicalDetails = raw.length > 300 || raw.includes('\n') ? raw : undefined;
|
|
20969
|
+
const base = {
|
|
20970
|
+
...diagnostic,
|
|
20971
|
+
...(technicalDetails ? { technicalDetails } : {}),
|
|
20972
|
+
...(locationMatch ? { location: { line: Number(locationMatch[1]), column: Number(locationMatch[2]) } } : {}),
|
|
20973
|
+
};
|
|
20974
|
+
switch (diagnostic.code) {
|
|
20975
|
+
case 'semantic_compose_failed':
|
|
20976
|
+
return {
|
|
20977
|
+
...base,
|
|
20978
|
+
title: 'Selected metrics cannot be compiled',
|
|
20979
|
+
field: 'Metrics',
|
|
20980
|
+
references: semanticRefs.metrics,
|
|
20981
|
+
message: semanticRefs.metrics.length > 0
|
|
20982
|
+
? `${semanticRefs.metrics.length} selected metric${semanticRefs.metrics.length === 1 ? '' : 's'} could not be compiled by the configured semantic runtime.`
|
|
20983
|
+
: 'The selected metrics and dimensions do not share a governed query path.',
|
|
20984
|
+
correction: 'Review the listed metrics in Modeling. Add the missing governed measure or relationship metadata, or replace the incompatible metric, then run the block again.',
|
|
20985
|
+
technicalDetails: raw,
|
|
20986
|
+
action: 'review_metrics',
|
|
20987
|
+
};
|
|
20988
|
+
case 'metricflow_unavailable':
|
|
20989
|
+
case 'semantic_runtime_required':
|
|
20990
|
+
case 'semantic_layer_missing':
|
|
20991
|
+
return {
|
|
20992
|
+
...base,
|
|
20993
|
+
title: 'Semantic runtime is not ready',
|
|
20994
|
+
field: 'Runtime',
|
|
20995
|
+
references: semanticRefs.metrics,
|
|
20996
|
+
message: 'This semantic block needs a configured dbt Cloud, local MetricFlow, or DQL semantic runtime before it can run or be certified.',
|
|
20997
|
+
correction: 'Open semantic runtime settings, connect the project runtime, then run the block again.',
|
|
20998
|
+
technicalDetails: raw,
|
|
20999
|
+
action: 'configure_runtime',
|
|
21000
|
+
};
|
|
21001
|
+
case 'semantic_metric_missing':
|
|
21002
|
+
return {
|
|
21003
|
+
...base,
|
|
21004
|
+
title: 'Choose at least one metric',
|
|
21005
|
+
field: 'Metrics',
|
|
21006
|
+
message: 'This semantic block does not select a metric yet.',
|
|
21007
|
+
correction: 'Add one or more governed metrics in the visual builder or DQL source.',
|
|
21008
|
+
action: 'review_metrics',
|
|
21009
|
+
};
|
|
21010
|
+
case 'semantic_ref': {
|
|
21011
|
+
const reference = raw.split(':').slice(1).join(':').trim();
|
|
21012
|
+
return {
|
|
21013
|
+
...base,
|
|
21014
|
+
title: 'Semantic reference was not found',
|
|
21015
|
+
field: 'Metrics and dimensions',
|
|
21016
|
+
references: reference ? [reference] : [...semanticRefs.metrics, ...semanticRefs.dimensions],
|
|
21017
|
+
message: reference ? `“${reference}” is not available in the current semantic snapshot.` : compactSemanticRuntimeFailure(raw),
|
|
21018
|
+
correction: 'Choose a current metric or dimension from the semantic catalog, then run validation again.',
|
|
21019
|
+
action: 'review_metrics',
|
|
21020
|
+
};
|
|
21021
|
+
}
|
|
21022
|
+
case 'semantic_granularity_missing':
|
|
21023
|
+
return {
|
|
21024
|
+
...base,
|
|
21025
|
+
title: 'Choose a time grain',
|
|
21026
|
+
field: 'Time',
|
|
21027
|
+
message: 'A time dimension is selected without a granularity.',
|
|
21028
|
+
correction: 'Choose day, week, month, quarter, or year for the selected time dimension.',
|
|
21029
|
+
action: 'review_metrics',
|
|
21030
|
+
};
|
|
21031
|
+
case 'parameter_parse':
|
|
21032
|
+
return {
|
|
21033
|
+
...base,
|
|
21034
|
+
title: 'Run input is invalid',
|
|
21035
|
+
field: 'Parameters',
|
|
21036
|
+
message: compactSemanticRuntimeFailure(raw),
|
|
21037
|
+
correction: 'Correct the named parameter, default value, or binding, then run validation again.',
|
|
21038
|
+
action: 'edit_parameters',
|
|
21039
|
+
};
|
|
21040
|
+
case 'syntax':
|
|
21041
|
+
case 'semantic_shape':
|
|
21042
|
+
case 'semantic_block_has_query':
|
|
21043
|
+
case 'sql_missing':
|
|
21044
|
+
case 'sql_read_only':
|
|
21045
|
+
return {
|
|
21046
|
+
...base,
|
|
21047
|
+
title: diagnostic.code === 'sql_read_only' ? 'Only read-only SQL is allowed' : 'DQL source needs attention',
|
|
21048
|
+
field: 'DQL source',
|
|
21049
|
+
message: compactSemanticRuntimeFailure(raw),
|
|
21050
|
+
correction: diagnostic.code === 'sql_read_only'
|
|
21051
|
+
? 'Replace write or DDL statements with one read-only query.'
|
|
21052
|
+
: 'Open DQL source, correct the highlighted declaration, then validate again.',
|
|
21053
|
+
action: 'edit_source',
|
|
21054
|
+
};
|
|
21055
|
+
default:
|
|
21056
|
+
return base;
|
|
21057
|
+
}
|
|
21058
|
+
}
|
|
21059
|
+
export function compactBlockStudioRuntimeFailure(failure) {
|
|
21060
|
+
const semanticPrefix = 'Could not compose SQL for semantic block metrics.';
|
|
21061
|
+
const semanticIndex = failure.indexOf(semanticPrefix);
|
|
21062
|
+
if (semanticIndex >= 0) {
|
|
21063
|
+
const remainder = failure.slice(semanticIndex + semanticPrefix.length);
|
|
21064
|
+
const references = Array.from(remainder.matchAll(/(?:^|\s)([A-Za-z_][\w.:/-]*)\s*:/g), (match) => match[1]);
|
|
21065
|
+
const uniqueReferences = Array.from(new Set(references));
|
|
21066
|
+
if (uniqueReferences.length > 0) {
|
|
21067
|
+
return `${uniqueReferences.length} selected metric${uniqueReferences.length === 1 ? '' : 's'} cannot be compiled by the current semantic runtime: ${uniqueReferences.join(', ')}. Review the measure and relationship metadata, then run again.`;
|
|
21068
|
+
}
|
|
21069
|
+
return 'The selected metrics and dimensions do not share a governed query path. Review the semantic relationships, then run again.';
|
|
21070
|
+
}
|
|
21071
|
+
return compactSemanticRuntimeFailure(failure);
|
|
21072
|
+
}
|
|
21073
|
+
function presentBlockStudioDiagnostics(diagnostics, semanticRefs) {
|
|
21074
|
+
return diagnostics.map((diagnostic) => presentBlockStudioDiagnostic(diagnostic, semanticRefs));
|
|
21075
|
+
}
|
|
21076
|
+
/**
|
|
21077
|
+
* Certification and preview must use the same compiler verdict. A successful
|
|
21078
|
+
* dbt/MetricFlow compile supersedes the native compiler's capability warning;
|
|
21079
|
+
* syntax, unsafe SQL, unknown references, and parameter errors remain intact.
|
|
21080
|
+
*/
|
|
21081
|
+
export function reconcileBlockStudioRuntimeValidation(validation, runtime) {
|
|
21082
|
+
const diagnostics = [
|
|
21083
|
+
...validation.diagnostics.filter((diagnostic) => !BLOCK_STUDIO_RUNTIME_COMPILE_DIAGNOSTICS.has(diagnostic.code ?? '')),
|
|
21084
|
+
...runtime.diagnostics,
|
|
21085
|
+
].filter((diagnostic, index, all) => all.findIndex((candidate) => (candidate.severity === diagnostic.severity
|
|
21086
|
+
&& candidate.code === diagnostic.code
|
|
21087
|
+
&& candidate.message === diagnostic.message)) === index);
|
|
21088
|
+
const presented = presentBlockStudioDiagnostics(diagnostics, validation.semanticRefs);
|
|
21089
|
+
return {
|
|
21090
|
+
...validation,
|
|
21091
|
+
valid: presented.every((diagnostic) => diagnostic.severity !== 'error'),
|
|
21092
|
+
saveable: blockStudioValidationAllowsDraftSave({ diagnostics: presented }),
|
|
21093
|
+
diagnostics: presented,
|
|
21094
|
+
executableSql: runtime.sql ?? validation.executableSql,
|
|
21095
|
+
};
|
|
21096
|
+
}
|
|
19836
21097
|
/**
|
|
19837
21098
|
* Draft persistence is stricter than loose text storage but does not require a
|
|
19838
21099
|
* configured execution runtime. Syntax, unsafe SQL, invalid semantic identity,
|
|
@@ -20383,7 +21644,10 @@ export function validateBlockStudioSource(source, semanticLayer) {
|
|
|
20383
21644
|
if (semanticLayer) {
|
|
20384
21645
|
const semanticCompose = composeSemanticBlockSql(source, semanticLayer);
|
|
20385
21646
|
semanticRefs = semanticCompose.semanticRefs;
|
|
20386
|
-
|
|
21647
|
+
// Synchronous validation owns authored shape, references, safety, and
|
|
21648
|
+
// parameter contracts. A native compile miss is not an authoring error:
|
|
21649
|
+
// dbt Cloud or MetricFlow may compile the exact same source successfully.
|
|
21650
|
+
diagnostics.push(...semanticCompose.diagnostics.map(staticSemanticAuthoringDiagnostic));
|
|
20387
21651
|
executableSql = semanticCompose.sql;
|
|
20388
21652
|
}
|
|
20389
21653
|
else {
|
|
@@ -20469,10 +21733,11 @@ export function validateBlockStudioSource(source, semanticLayer) {
|
|
|
20469
21733
|
});
|
|
20470
21734
|
}
|
|
20471
21735
|
}
|
|
21736
|
+
const presentedDiagnostics = presentBlockStudioDiagnostics(diagnostics, semanticRefs);
|
|
20472
21737
|
return {
|
|
20473
|
-
valid:
|
|
20474
|
-
saveable: blockStudioValidationAllowsDraftSave({ diagnostics }),
|
|
20475
|
-
diagnostics,
|
|
21738
|
+
valid: presentedDiagnostics.every((diagnostic) => diagnostic.severity !== 'error'),
|
|
21739
|
+
saveable: blockStudioValidationAllowsDraftSave({ diagnostics: presentedDiagnostics }),
|
|
21740
|
+
diagnostics: presentedDiagnostics,
|
|
20476
21741
|
semanticRefs,
|
|
20477
21742
|
chartConfig: chartConfig ?? undefined,
|
|
20478
21743
|
executableSql,
|
|
@@ -21456,22 +22721,88 @@ function evaluateBlockStudioTests(tests, rows, columns, startedAt) {
|
|
|
21456
22721
|
function buildBlockStudioCertificationChecklist(input) {
|
|
21457
22722
|
const parsed = parseBlockSourceMetadata(input.source);
|
|
21458
22723
|
const sql = extractBlockStudioSql(input.source) ?? '';
|
|
21459
|
-
const
|
|
21460
|
-
|
|
21461
|
-
|
|
21462
|
-
|
|
21463
|
-
|
|
21464
|
-
|
|
21465
|
-
|
|
21466
|
-
|
|
21467
|
-
|
|
21468
|
-
|
|
21469
|
-
|
|
22724
|
+
const issues = input.validation.diagnostics
|
|
22725
|
+
.filter((diagnostic) => diagnostic.severity === 'error');
|
|
22726
|
+
for (const error of input.certificationErrors) {
|
|
22727
|
+
const combined = `${error.rule}: ${error.message}`;
|
|
22728
|
+
const lower = combined.toLowerCase();
|
|
22729
|
+
if (lower.includes('missing owner')) {
|
|
22730
|
+
issues.push({
|
|
22731
|
+
severity: 'error',
|
|
22732
|
+
code: 'owner_missing',
|
|
22733
|
+
title: 'Add an owner',
|
|
22734
|
+
field: 'Metadata',
|
|
22735
|
+
message: 'Owner is required before certification.',
|
|
22736
|
+
correction: 'Enter the accountable team or person in Metadata, save the draft, and certify again.',
|
|
22737
|
+
action: 'edit_source',
|
|
22738
|
+
technicalDetails: combined,
|
|
22739
|
+
});
|
|
22740
|
+
}
|
|
22741
|
+
else if (lower.includes('tests pass')) {
|
|
22742
|
+
issues.push({
|
|
22743
|
+
severity: 'error',
|
|
22744
|
+
code: 'tests_failed',
|
|
22745
|
+
title: 'A block test failed',
|
|
22746
|
+
field: 'Tests',
|
|
22747
|
+
message: 'One or more declared tests did not pass against the latest preview result.',
|
|
22748
|
+
correction: 'Review the failed assertion and result, correct the query or expectation, then run certification again.',
|
|
22749
|
+
action: 'edit_source',
|
|
22750
|
+
technicalDetails: combined,
|
|
22751
|
+
});
|
|
22752
|
+
}
|
|
22753
|
+
else {
|
|
22754
|
+
issues.push({
|
|
22755
|
+
severity: 'error',
|
|
22756
|
+
code: `certification_${error.rule.toLowerCase().replace(/[^a-z0-9]+/g, '_')}`,
|
|
22757
|
+
title: 'Certification requirement is not ready',
|
|
22758
|
+
field: 'Metadata',
|
|
22759
|
+
message: compactSemanticRuntimeFailure(combined),
|
|
22760
|
+
correction: 'Update the named block field, save the draft, and certify again.',
|
|
22761
|
+
action: 'edit_source',
|
|
22762
|
+
technicalDetails: combined,
|
|
22763
|
+
});
|
|
22764
|
+
}
|
|
22765
|
+
}
|
|
22766
|
+
for (const blocker of input.extraBlockers ?? []) {
|
|
22767
|
+
issues.push({
|
|
22768
|
+
severity: 'error',
|
|
22769
|
+
code: 'preview_failed',
|
|
22770
|
+
title: 'Preview did not finish',
|
|
22771
|
+
field: 'Run',
|
|
22772
|
+
message: compactBlockStudioRuntimeFailure(blocker),
|
|
22773
|
+
correction: 'Correct the reported query or runtime problem, then run the block again before certification.',
|
|
22774
|
+
technicalDetails: blocker,
|
|
22775
|
+
action: 'run_again',
|
|
22776
|
+
});
|
|
22777
|
+
}
|
|
22778
|
+
if (!input.previewSucceeded && (input.extraBlockers?.length ?? 0) === 0) {
|
|
22779
|
+
issues.push({
|
|
22780
|
+
severity: 'error',
|
|
22781
|
+
code: 'preview_required',
|
|
22782
|
+
title: 'Run the block',
|
|
22783
|
+
field: 'Run',
|
|
22784
|
+
message: 'The block needs a successful preview result before certification.',
|
|
22785
|
+
correction: 'Run the block, resolve any preview error, then certify again.',
|
|
22786
|
+
action: 'run_again',
|
|
22787
|
+
});
|
|
22788
|
+
}
|
|
21470
22789
|
// Only a REAL test failure blocks. "No tests" and "no chart" are advisory review
|
|
21471
22790
|
// items the AI fills at draft time — never hard gates (owner is the gate). This is
|
|
21472
22791
|
// what keeps "build a DQL" from demanding ~13 fields per block at 300-query scale.
|
|
21473
|
-
if (input.testResults && input.testResults.failed > 0)
|
|
21474
|
-
|
|
22792
|
+
if (input.testResults && input.testResults.failed > 0 && !issues.some((issue) => issue.code === 'tests_failed')) {
|
|
22793
|
+
issues.push({
|
|
22794
|
+
severity: 'error',
|
|
22795
|
+
code: 'tests_failed',
|
|
22796
|
+
title: 'A block test failed',
|
|
22797
|
+
field: 'Tests',
|
|
22798
|
+
message: 'One or more declared tests did not pass against the latest preview result.',
|
|
22799
|
+
correction: 'Review the failed assertion and result, correct the query or expectation, then run certification again.',
|
|
22800
|
+
action: 'edit_source',
|
|
22801
|
+
});
|
|
22802
|
+
}
|
|
22803
|
+
const uniqueIssues = issues.filter((issue, index, all) => all.findIndex((candidate) => (candidate.code === issue.code
|
|
22804
|
+
&& candidate.message === issue.message
|
|
22805
|
+
&& (candidate.references ?? []).join('|') === (issue.references ?? []).join('|'))) === index);
|
|
21475
22806
|
return {
|
|
21476
22807
|
// The metadata tab is green once the one required field (owner) is present.
|
|
21477
22808
|
// Description/domain are AI-filled + editable and surface as review items, not gates.
|
|
@@ -21482,7 +22813,8 @@ function buildBlockStudioCertificationChecklist(input) {
|
|
|
21482
22813
|
chart: Boolean(input.validation.chartConfig?.chart),
|
|
21483
22814
|
lineage: extractSqlTablesLight(sql).length > 0 || input.validation.semanticRefs.metrics.length > 0,
|
|
21484
22815
|
aiReviewed: true,
|
|
21485
|
-
blockers:
|
|
22816
|
+
blockers: uniqueIssues.map((issue) => issue.message),
|
|
22817
|
+
issues: uniqueIssues,
|
|
21486
22818
|
checkedAt: new Date().toISOString(),
|
|
21487
22819
|
};
|
|
21488
22820
|
}
|