@duckcodeailabs/dql-cli 1.13.0 → 1.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apps-api.d.ts +225 -5
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +1950 -117
- package/dist/apps-api.js.map +1 -1
- package/dist/args.d.ts +2 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +3 -0
- package/dist/args.js.map +1 -1
- package/dist/assets/dql-notebook/assets/{AgentLogPage-CBGEoGmU.js → AgentLogPage-BlUE3qsX.js} +1 -1
- package/dist/assets/dql-notebook/assets/{AiBuildDialog-CwAD3tXE.js → AiBuildDialog-O88iK6UW.js} +1 -1
- package/dist/assets/dql-notebook/assets/AiBuildResult-D-6G4Sh3.js +33 -0
- package/dist/assets/dql-notebook/assets/{AiSidePanel-DjB8PWHG.js → AiSidePanel-MRSGtQDV.js} +1 -1
- package/dist/assets/dql-notebook/assets/AnalyticsHome-CcRVO6zN.js +6 -0
- package/dist/assets/dql-notebook/assets/AppsView-BTkGC-wl.js +5588 -0
- package/dist/assets/dql-notebook/assets/BlockStudio-BrTNaDpb.js +55 -0
- package/dist/assets/dql-notebook/assets/{BusinessArtifactView-C4SZVNW-.js → BusinessArtifactView-itEIUrYy.js} +1 -1
- package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-CbAqOpNc.js +22 -0
- package/dist/assets/dql-notebook/assets/{GitPage-BfVD4tVq.js → GitPage-BxOqRYu2.js} +1 -1
- package/dist/assets/dql-notebook/assets/GlobalAiRail-Tfkx76CI.js +1 -0
- package/dist/assets/dql-notebook/assets/GovernedContextPage-CeaDbepP.js +7 -0
- package/dist/assets/dql-notebook/assets/{HelpDocsPage-BDPNVot1.js → HelpDocsPage-D1lAryYv.js} +1 -1
- package/dist/assets/dql-notebook/assets/{HomePage-DiOnRhEG.js → HomePage-GB6se6xW.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDAG-BiUFX2KL.js → LineageDAG-zCyN-hdg.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDetailView-CfbSvlPJ.js → LineageDetailView-Cg0TgG2Y.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDrawer-CgTScaGB.js → LineageDrawer-DwMcenDf.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-D7ZA7k68.js → LineagePathBreadcrumb-DUZx3ksU.js} +1 -1
- package/dist/assets/dql-notebook/assets/{MiniLineageGraph-GkD4y17x.js → MiniLineageGraph-DT_ycl6s.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NewBlockModal-B9TfRjt7.js → NewBlockModal-qdhzeJPF.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NewNotebookModal-BbO9NKUl.js → NewNotebookModal-BnaDzs1A.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NotebookEditor-CuMpvXR9.js → NotebookEditor-CouPFzdo.js} +26 -31
- package/dist/assets/dql-notebook/assets/{ReadinessPage-BROJ9YBM.js → ReadinessPage-DB_KbH9Y.js} +1 -1
- package/dist/assets/dql-notebook/assets/{SetupOnboarding-BCcZlpuQ.js → SetupOnboarding-CamQq_N2.js} +1 -1
- package/dist/assets/dql-notebook/assets/SkillsPage--mYkOAK2.js +10 -0
- package/dist/assets/dql-notebook/assets/{TrustBadge-vQQh8d0D.js → TrustBadge-ClEYiYeA.js} +1 -1
- package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-DBfsmXes.js +88 -0
- package/dist/assets/dql-notebook/assets/{answer-to-notebook-DuqCcDDl.js → answer-to-notebook-Cg3UyGwh.js} +1 -1
- package/dist/assets/dql-notebook/assets/{arrow-left-CMe1kkOL.js → arrow-left-DnYPGqlr.js} +1 -1
- package/dist/assets/dql-notebook/assets/{arrow-right-DvB_DONr.js → arrow-right-Ct_YGrmU.js} +1 -1
- package/dist/assets/dql-notebook/assets/{book-open-text-CJ3NAvrs.js → book-open-text-DULlwbj6.js} +1 -1
- package/dist/assets/dql-notebook/assets/{circle-x-BCgtha1W.js → circle-x-ByYxoiS9.js} +1 -1
- package/dist/assets/dql-notebook/assets/{dagre.esm-GKp7qoUL.js → dagre.esm-BjYSO53F.js} +1 -1
- package/dist/assets/dql-notebook/assets/{external-link-C-PXokWV.js → external-link-DprWh9Yf.js} +1 -1
- package/dist/assets/dql-notebook/assets/{grip-vertical-B4EHbGXW.js → grip-vertical-CrEEYkXN.js} +1 -1
- package/dist/assets/dql-notebook/assets/{index-1mIC_XX4.js → index-9w93YPF9.js} +127 -126
- package/dist/assets/dql-notebook/assets/index-B3shyZsg.css +1 -0
- package/dist/assets/dql-notebook/assets/{link-2-BLx-goet.js → link-2-BkW-rkEC.js} +1 -1
- package/dist/assets/dql-notebook/assets/{list-tree-BRFpEmvV.js → list-tree-BipBLmIE.js} +1 -1
- package/dist/assets/dql-notebook/assets/{minimize-2-CDwSCGlO.js → minimize-2-BhXsglrO.js} +1 -1
- package/dist/assets/dql-notebook/assets/{panel-right-open-CunA1xO7.js → panel-right-open-BoCSilYB.js} +1 -1
- package/dist/assets/dql-notebook/assets/{bot-CV8DYRrt.js → play-mdoZawPu.js} +7 -2
- package/dist/assets/dql-notebook/assets/{rotate-ccw-DOrfTZk8.js → rotate-ccw-ELiDRUmj.js} +1 -1
- package/dist/assets/dql-notebook/assets/{semantic-fields-AfQvjt5R.js → semantic-fields-DVNRLfeU.js} +1 -1
- package/dist/assets/dql-notebook/assets/sliders-horizontal-GeUzpmKz.js +6 -0
- package/dist/assets/dql-notebook/assets/{star-DDhv4e8M.js → star-D63O81Wp.js} +1 -1
- package/dist/assets/dql-notebook/assets/{triangle-alert-2ZXx8wAu.js → triangle-alert-BWrCxQVE.js} +1 -1
- package/dist/assets/dql-notebook/assets/upload-CI-ldXhC.js +6 -0
- package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Cv7Yq2hK.js +32 -0
- package/dist/assets/dql-notebook/assets/{user-round-BA074kiQ.js → user-round-Do21aFGo.js} +1 -1
- package/dist/assets/dql-notebook/assets/{wand-sparkles-CEYHLt9b.js → wand-sparkles-DT7u7F_x.js} +1 -1
- package/dist/assets/dql-notebook/assets/{workflow-CZtyrcR-.js → workflow-DHh2mMbn.js} +1 -1
- package/dist/assets/dql-notebook/assets/{wrench-Ong23-wu.js → wrench-7tYr_GKb.js} +1 -1
- package/dist/assets/dql-notebook/assets/{x-CSo1M3tu.js → x-CcYceFMz.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.d.ts.map +1 -1
- package/dist/commands/model.js +83 -3
- package/dist/commands/model.js.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.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 +75 -0
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +2015 -88
- 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-Cp0eh6zU.js +0 -5
- package/dist/assets/dql-notebook/assets/AiBuildResult-CyqTuQe5.js +0 -33
- package/dist/assets/dql-notebook/assets/AnalyticsHome-B7BkvA-R.js +0 -6
- package/dist/assets/dql-notebook/assets/AppsView-CUrqtEGC.js +0 -4583
- package/dist/assets/dql-notebook/assets/BlockStudio-Bl0aGQJR.js +0 -60
- package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-B0uxitnx.js +0 -22
- package/dist/assets/dql-notebook/assets/GlobalAiRail-DUwdy3fB.js +0 -1
- package/dist/assets/dql-notebook/assets/GovernedContextPage-D9f26ZFb.js +0 -7
- package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-Dj4AXAhT.js +0 -83
- package/dist/assets/dql-notebook/assets/chevron-up-HxU1l85K.js +0 -6
- package/dist/assets/dql-notebook/assets/index-DKu4CZt3.css +0 -1
- package/dist/assets/dql-notebook/assets/maximize-2-CCd5uRIZ.js +0 -6
- package/dist/assets/dql-notebook/assets/pencil-D_I14Wyk.js +0 -6
- package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-3rzkZrqc.js +0 -18
package/dist/local-runtime.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFileSync, execSync } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { gzip } from "node:zlib";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, watch, writeFileSync, } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync, } from "node:fs";
|
|
6
6
|
import { mkdir as mkdirAsync, rename as renameAsync, rm as rmAsync, writeFile as writeFileAsync, } from 'node:fs/promises';
|
|
7
7
|
import { createRequire } from "node:module";
|
|
8
8
|
import { homedir } from "node:os";
|
|
@@ -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, 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';
|
|
@@ -24,8 +24,8 @@ import { fetchLatestPublishedDqlVersion, resolveDqlRuntimeVersionStatus } from '
|
|
|
24
24
|
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
|
-
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, 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';
|
|
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, 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';
|
|
@@ -1503,59 +1512,6 @@ export async function startLocalServer(opts) {
|
|
|
1503
1512
|
});
|
|
1504
1513
|
return { domainContext, contextPack };
|
|
1505
1514
|
};
|
|
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
1515
|
const agentRunEvaluation = (id, label, passed, severity, message, evidence) => ({ id, label, passed, severity, message, evidence });
|
|
1560
1516
|
const agentRunArtifact = (kind, title, payload, ref, trustState = 'review_required') => ({
|
|
1561
1517
|
id: `${kind}:${Date.now()}`,
|
|
@@ -2412,7 +2368,12 @@ export async function startLocalServer(opts) {
|
|
|
2412
2368
|
: isTerminalFailure
|
|
2413
2369
|
? terminalFailureActions
|
|
2414
2370
|
: isGroundingGap
|
|
2415
|
-
?
|
|
2371
|
+
? governedAnswer.refusalCode === 'modeling_gap'
|
|
2372
|
+
? [
|
|
2373
|
+
{ id: 'fix-modeling-gap', label: 'Fix with Modeling AI', route: 'modeling_draft', artifactKind: 'modeling_change_proposal' },
|
|
2374
|
+
{ id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' },
|
|
2375
|
+
]
|
|
2376
|
+
: [{ id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' }]
|
|
2416
2377
|
: [
|
|
2417
2378
|
{ id: 'create-block', label: governedAnswer.dqlArtifact ? 'Review DQL draft' : 'Create DQL draft', route: 'dql_block_draft', artifactKind: 'dql_block_draft' },
|
|
2418
2379
|
{ id: 'research-gap', label: 'Research deeper', route: 'research' },
|
|
@@ -2585,11 +2546,399 @@ export async function startLocalServer(opts) {
|
|
|
2585
2546
|
nextActions,
|
|
2586
2547
|
};
|
|
2587
2548
|
};
|
|
2549
|
+
const contextProposalStore = new FileContextAuthoringProposalStore(projectRoot);
|
|
2550
|
+
/**
|
|
2551
|
+
* UI-019: authoring must never dead-end on a Domain/Area that does not exist
|
|
2552
|
+
* yet. Every modeling operation still belongs to exactly one Domain and Area
|
|
2553
|
+
* (`11-context-authoring-and-ai.md`); the difference is that DQL now creates
|
|
2554
|
+
* the missing scope as part of the same reviewable proposal instead of
|
|
2555
|
+
* blocking the author until they create it by hand. The synthesized
|
|
2556
|
+
* operations are ordinary reviewable patches, so nothing is written until the
|
|
2557
|
+
* proposal is explicitly committed.
|
|
2558
|
+
*/
|
|
2559
|
+
const ensureAuthoringScopeOperations = (operations, manifest) => {
|
|
2560
|
+
const modeling = manifest.modeling;
|
|
2561
|
+
if (!modeling)
|
|
2562
|
+
return operations;
|
|
2563
|
+
const existingDomains = new Set(Object.keys(modeling.packages ?? {}));
|
|
2564
|
+
const existingAreas = new Set(Object.values(modeling.areas ?? {}).flatMap((area) => [`${area.domain}::${area.localId}`, `${area.domain}::${area.qualifiedId}`]));
|
|
2565
|
+
// A scope created earlier in this same batch counts as existing.
|
|
2566
|
+
for (const operation of operations) {
|
|
2567
|
+
if (operation.kind !== 'modeling_change')
|
|
2568
|
+
continue;
|
|
2569
|
+
if (operation.change.operation === 'upsert_domain')
|
|
2570
|
+
existingDomains.add(operation.change.value.id);
|
|
2571
|
+
if (operation.change.operation === 'upsert_area')
|
|
2572
|
+
existingAreas.add(`${operation.change.value.domain}::${operation.change.value.id}`);
|
|
2573
|
+
}
|
|
2574
|
+
const created = [];
|
|
2575
|
+
const domainOperationId = new Map();
|
|
2576
|
+
const areaOperationId = new Map();
|
|
2577
|
+
const defaultAreaName = manifest.dbtProvenance?.projectName
|
|
2578
|
+
? titleCaseAuthoring(manifest.dbtProvenance.projectName)
|
|
2579
|
+
: 'Core models';
|
|
2580
|
+
const withScope = operations.map((operation) => {
|
|
2581
|
+
if (operation.kind !== 'modeling_change')
|
|
2582
|
+
return operation;
|
|
2583
|
+
const change = operation.change;
|
|
2584
|
+
if (change.operation !== 'upsert_entity' && change.operation !== 'upsert_relationship')
|
|
2585
|
+
return operation;
|
|
2586
|
+
const domain = change.value.domain;
|
|
2587
|
+
if (!domain)
|
|
2588
|
+
return operation;
|
|
2589
|
+
const dependsOn = [...(operation.dependsOn ?? [])];
|
|
2590
|
+
if (!existingDomains.has(domain)) {
|
|
2591
|
+
let id = domainOperationId.get(domain);
|
|
2592
|
+
if (!id) {
|
|
2593
|
+
id = `scope:domain:${domain}`;
|
|
2594
|
+
domainOperationId.set(domain, id);
|
|
2595
|
+
created.push({
|
|
2596
|
+
id,
|
|
2597
|
+
kind: 'modeling_change',
|
|
2598
|
+
evidence: ['created automatically so the requested modeling scope exists'],
|
|
2599
|
+
change: { operation: 'upsert_domain', value: { id: domain, name: titleCaseAuthoring(domain) } },
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
if (!dependsOn.includes(id))
|
|
2603
|
+
dependsOn.push(id);
|
|
2604
|
+
}
|
|
2605
|
+
const areaId = change.value.areaId;
|
|
2606
|
+
if (areaId && !existingAreas.has(`${domain}::${areaId}`)) {
|
|
2607
|
+
const key = `${domain}::${areaId}`;
|
|
2608
|
+
let id = areaOperationId.get(key);
|
|
2609
|
+
if (!id) {
|
|
2610
|
+
id = `scope:area:${domain}:${areaId}`;
|
|
2611
|
+
areaOperationId.set(key, id);
|
|
2612
|
+
created.push({
|
|
2613
|
+
id,
|
|
2614
|
+
kind: 'modeling_change',
|
|
2615
|
+
dependsOn: domainOperationId.has(domain) ? [domainOperationId.get(domain)] : [],
|
|
2616
|
+
evidence: ['created automatically so the requested modeling scope exists'],
|
|
2617
|
+
change: {
|
|
2618
|
+
operation: 'upsert_area',
|
|
2619
|
+
value: {
|
|
2620
|
+
id: areaId,
|
|
2621
|
+
domain,
|
|
2622
|
+
name: areaId === DEFAULT_MODEL_AREA_ID ? defaultAreaName : titleCaseAuthoring(areaId),
|
|
2623
|
+
description: 'Subject area created automatically when modeling started. Rename or split it at any time.',
|
|
2624
|
+
},
|
|
2625
|
+
},
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
if (!dependsOn.includes(id))
|
|
2629
|
+
dependsOn.push(id);
|
|
2630
|
+
}
|
|
2631
|
+
return dependsOn.length ? { ...operation, dependsOn } : operation;
|
|
2632
|
+
});
|
|
2633
|
+
return created.length ? [...created, ...withScope] : withScope;
|
|
2634
|
+
};
|
|
2635
|
+
/**
|
|
2636
|
+
* REL-004: when a batch binds dbt models, bring across the relationships dbt
|
|
2637
|
+
* already declares in its `relationships` tests. dbt states the exact key
|
|
2638
|
+
* pair and `dbt test` enforces it, so redrawing those edges by hand is pure
|
|
2639
|
+
* duplicated work.
|
|
2640
|
+
*
|
|
2641
|
+
* REL-001 is unchanged: a dbt test is evidence, not join authorization. Every
|
|
2642
|
+
* edge produced here is `status: 'draft'` with `fanout: 'unknown'` and no
|
|
2643
|
+
* validation receipt, so it can never prove an automatic join.
|
|
2644
|
+
*/
|
|
2645
|
+
const appendDbtDeclaredRelationships = (operations, manifest) => {
|
|
2646
|
+
const manifestPath = manifest.dbtProvenance?.manifestPath;
|
|
2647
|
+
if (!manifestPath || !existsSync(manifestPath))
|
|
2648
|
+
return operations;
|
|
2649
|
+
// Entities this batch creates, plus entities already bound in the project.
|
|
2650
|
+
const bound = new Map();
|
|
2651
|
+
for (const entity of Object.values(manifest.modeling?.entities ?? {})) {
|
|
2652
|
+
bound.set(entity.dbtUniqueId, { id: entity.localId, domain: entity.domain, areaId: modelAreaLocalId(entity.areaId) });
|
|
2653
|
+
}
|
|
2654
|
+
let batchBindings = 0;
|
|
2655
|
+
for (const operation of operations) {
|
|
2656
|
+
if (operation.kind !== 'modeling_change' || operation.change.operation !== 'upsert_entity')
|
|
2657
|
+
continue;
|
|
2658
|
+
const value = operation.change.value;
|
|
2659
|
+
bound.set(value.dbtModel, { id: value.id, domain: value.domain, areaId: value.areaId, operationId: operation.id });
|
|
2660
|
+
batchBindings += 1;
|
|
2661
|
+
}
|
|
2662
|
+
if (batchBindings === 0 || bound.size < 2)
|
|
2663
|
+
return operations;
|
|
2664
|
+
let rawManifest;
|
|
2665
|
+
try {
|
|
2666
|
+
rawManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
2667
|
+
}
|
|
2668
|
+
catch {
|
|
2669
|
+
return operations;
|
|
2670
|
+
}
|
|
2671
|
+
const existingRelationshipIds = new Set([
|
|
2672
|
+
...Object.values(manifest.modeling?.relationships ?? {}).map((relationship) => `${relationship.ownerDomain ?? ''}::${relationship.localId}`),
|
|
2673
|
+
...operations.flatMap((operation) => operation.kind === 'modeling_change' && operation.change.operation === 'upsert_relationship'
|
|
2674
|
+
? [`${operation.change.value.domain}::${operation.change.value.id}`]
|
|
2675
|
+
: []),
|
|
2676
|
+
]);
|
|
2677
|
+
const harvested = [];
|
|
2678
|
+
for (const edge of collectDbtRelationshipTests(rawManifest, new Set(bound.keys()))) {
|
|
2679
|
+
if (edge.fromColumn === 'unknown' || edge.toColumn === 'unknown')
|
|
2680
|
+
continue;
|
|
2681
|
+
const from = bound.get(edge.fromDbtUniqueId);
|
|
2682
|
+
const to = bound.get(edge.toDbtUniqueId);
|
|
2683
|
+
if (!from || !to || from.id === to.id)
|
|
2684
|
+
continue;
|
|
2685
|
+
// Only offer edges this batch actually introduces, and keep cross-domain
|
|
2686
|
+
// links on the explicit export/import path.
|
|
2687
|
+
if (!from.operationId && !to.operationId)
|
|
2688
|
+
continue;
|
|
2689
|
+
if (from.domain !== to.domain)
|
|
2690
|
+
continue;
|
|
2691
|
+
const id = `${from.id}_to_${to.id}`;
|
|
2692
|
+
const key = `${from.domain}::${id}`;
|
|
2693
|
+
if (existingRelationshipIds.has(key))
|
|
2694
|
+
continue;
|
|
2695
|
+
existingRelationshipIds.add(key);
|
|
2696
|
+
harvested.push({
|
|
2697
|
+
id: `dbt-test:${edge.testUniqueId}`,
|
|
2698
|
+
kind: 'modeling_change',
|
|
2699
|
+
dependsOn: [from.operationId, to.operationId].filter((value) => Boolean(value)),
|
|
2700
|
+
evidence: [edge.testUniqueId, ...(edge.sourcePath ? [edge.sourcePath] : [])],
|
|
2701
|
+
change: {
|
|
2702
|
+
operation: 'upsert_relationship',
|
|
2703
|
+
value: {
|
|
2704
|
+
id, domain: from.domain, areaId: from.areaId ?? to.areaId,
|
|
2705
|
+
from: from.id, to: to.id,
|
|
2706
|
+
keys: [{ from: edge.fromColumn, to: edge.toColumn }],
|
|
2707
|
+
cardinality: 'many_to_one', fanout: 'unknown', status: 'draft',
|
|
2708
|
+
rationale: `Declared by the dbt relationships test ${edge.testUniqueId}. Validate against the warehouse before certifying.`,
|
|
2709
|
+
},
|
|
2710
|
+
},
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
return harvested.length ? [...operations, ...harvested] : operations;
|
|
2714
|
+
};
|
|
2715
|
+
const previewContextAuthoring = (input) => {
|
|
2716
|
+
const snapshot = projectSnapshot();
|
|
2717
|
+
if (input.expectedSnapshotId && input.expectedSnapshotId !== snapshot.snapshotId) {
|
|
2718
|
+
throw Object.assign(new Error('Project sources changed before the authoring proposal was previewed.'), { code: 'PROPOSAL_STALE' });
|
|
2719
|
+
}
|
|
2720
|
+
if (input.operations.length === 0)
|
|
2721
|
+
throw Object.assign(new Error('At least one authoring operation is required.'), { code: 'INVALID_REQUEST' });
|
|
2722
|
+
const diagnostics = [...(input.diagnostics ?? [])];
|
|
2723
|
+
const operations = input.operations.map((operation) => {
|
|
2724
|
+
if (operation.kind === 'skill_change') {
|
|
2725
|
+
const value = { ...operation.value };
|
|
2726
|
+
value.status = 'draft';
|
|
2727
|
+
if (value.sourceRefs?.some((reference) => !contextSourceReferenceExists(snapshot.manifest, reference))) {
|
|
2728
|
+
diagnostics.push({ code: 'UNKNOWN_GOVERNED_REFERENCE', severity: 'blocking', operationId: operation.id, message: 'The Skill proposal references a model or relationship that is not present in the current snapshot.' });
|
|
2729
|
+
}
|
|
2730
|
+
return { ...operation, value };
|
|
2731
|
+
}
|
|
2732
|
+
if (operation.kind !== 'modeling_change')
|
|
2733
|
+
return operation;
|
|
2734
|
+
if ((input.origin === 'ai' || input.origin === 'correction') && operation.change.operation.startsWith('remove_')) {
|
|
2735
|
+
diagnostics.push({ code: 'AI_DELETE_FORBIDDEN', severity: 'blocking', operationId: operation.id, message: 'Modeling AI can create or update context, but it cannot delete governed source.' });
|
|
2736
|
+
}
|
|
2737
|
+
if (operation.change.operation === 'upsert_relationship') {
|
|
2738
|
+
const current = operation.change.value;
|
|
2739
|
+
if (current.crossDomain && (input.origin === 'ai' || input.origin === 'yaml_import')) {
|
|
2740
|
+
diagnostics.push({ code: 'CROSS_DOMAIN_IMPORT_FORBIDDEN', severity: 'blocking', operationId: operation.id, message: 'Cross-domain relationships require the explicit export, contract, and import workflow.' });
|
|
2741
|
+
}
|
|
2742
|
+
if (input.origin === 'ai' || input.origin === 'correction' || input.origin === 'yaml_import') {
|
|
2743
|
+
return {
|
|
2744
|
+
...operation,
|
|
2745
|
+
change: {
|
|
2746
|
+
...operation.change,
|
|
2747
|
+
value: {
|
|
2748
|
+
...current,
|
|
2749
|
+
status: 'draft',
|
|
2750
|
+
fanout: current.fanout === 'unsafe' ? 'forbidden' : current.fanout,
|
|
2751
|
+
certifiedAgainst: undefined,
|
|
2752
|
+
validation: undefined,
|
|
2753
|
+
},
|
|
2754
|
+
},
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
if (operation.change.operation === 'upsert_entity'
|
|
2759
|
+
&& (input.origin === 'ai' || input.origin === 'yaml_import' || input.origin === 'dbt_discovery')
|
|
2760
|
+
&& !operation.change.value.areaId) {
|
|
2761
|
+
// Every model still belongs to exactly one Domain and subject area. An
|
|
2762
|
+
// omitted area is filled with the default rather than blocking the
|
|
2763
|
+
// author; `ensureAuthoringScopeOperations` creates it if it is new.
|
|
2764
|
+
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.` });
|
|
2765
|
+
return { ...operation, change: { ...operation.change, value: { ...operation.change.value, areaId: DEFAULT_MODEL_AREA_ID } } };
|
|
2766
|
+
}
|
|
2767
|
+
return operation;
|
|
2768
|
+
});
|
|
2769
|
+
// dbt discovery and Modeling AI bind models; give them the edges dbt already
|
|
2770
|
+
// declares. Manual authoring and YAML import bring their own relationships.
|
|
2771
|
+
const withDeclaredEdges = input.origin === 'dbt_discovery' || input.origin === 'ai'
|
|
2772
|
+
? appendDbtDeclaredRelationships(operations, snapshot.manifest)
|
|
2773
|
+
: operations;
|
|
2774
|
+
const scopedOperations = ensureAuthoringScopeOperations(withDeclaredEdges, snapshot.manifest);
|
|
2775
|
+
const operationIds = new Set(scopedOperations.map((operation) => operation.id));
|
|
2776
|
+
for (const operation of scopedOperations) {
|
|
2777
|
+
for (const dependency of operation.dependsOn ?? []) {
|
|
2778
|
+
if (!operationIds.has(dependency))
|
|
2779
|
+
diagnostics.push({ code: 'DEPENDENCY_CONFLICT', severity: 'blocking', operationId: operation.id, message: `Required proposal operation is missing: ${dependency}` });
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
const patches = [];
|
|
2783
|
+
const modelingOperations = scopedOperations.filter((operation) => operation.kind === 'modeling_change');
|
|
2784
|
+
if (modelingOperations.length > 0) {
|
|
2785
|
+
const batch = previewModelingChanges(projectRoot, modelingOperations.map((operation) => operation.change));
|
|
2786
|
+
// The batch attributes each path while composing its shadow, so a change
|
|
2787
|
+
// that depends on an earlier one (an entity in a Domain this same batch
|
|
2788
|
+
// creates) is attributed correctly and without re-previewing per patch.
|
|
2789
|
+
const ownerByPath = new Map();
|
|
2790
|
+
batch.patchPathsByChange.forEach((paths, index) => {
|
|
2791
|
+
const operationId = modelingOperations[index]?.id;
|
|
2792
|
+
if (!operationId)
|
|
2793
|
+
return;
|
|
2794
|
+
for (const path of paths)
|
|
2795
|
+
if (!ownerByPath.has(path))
|
|
2796
|
+
ownerByPath.set(path, operationId);
|
|
2797
|
+
});
|
|
2798
|
+
for (const patch of batch.patches) {
|
|
2799
|
+
patches.push({ ...patch, owner: 'dql', operationId: ownerByPath.get(patch.path) ?? modelingOperations[0].id });
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
for (const operation of scopedOperations) {
|
|
2803
|
+
if (operation.kind === 'skill_change') {
|
|
2804
|
+
const preview = previewSkillChange(projectRoot, operation.value, operation.targetQualifiedId, operation.expectedSourceHash);
|
|
2805
|
+
for (const patch of preview.patches) {
|
|
2806
|
+
patches.push({
|
|
2807
|
+
path: relative(projectRoot, patch.path).replace(/\\/g, '/'),
|
|
2808
|
+
before: patch.before,
|
|
2809
|
+
after: patch.after,
|
|
2810
|
+
changed: patch.changed,
|
|
2811
|
+
owner: 'dql',
|
|
2812
|
+
operationId: operation.id,
|
|
2813
|
+
});
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
else if (operation.kind === 'dbt_source_change') {
|
|
2817
|
+
const { dbtProjectDir, manifestPath } = onboardingDbtPaths({});
|
|
2818
|
+
const preview = previewDbtSourcePatch(dbtProjectDir, manifestPath, operation.change);
|
|
2819
|
+
patches.push({
|
|
2820
|
+
path: relative(projectRoot, resolve(dbtProjectDir, preview.patch.path)).replace(/\\/g, '/'),
|
|
2821
|
+
before: preview.patch.before,
|
|
2822
|
+
after: preview.patch.after,
|
|
2823
|
+
changed: preview.patch.changed,
|
|
2824
|
+
owner: 'dbt',
|
|
2825
|
+
operationId: operation.id,
|
|
2826
|
+
});
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
const duplicatePaths = patches.map((patch) => patch.path).filter((path, index, values) => values.indexOf(path) !== index);
|
|
2830
|
+
for (const path of [...new Set(duplicatePaths)])
|
|
2831
|
+
diagnostics.push({ code: 'OWNERSHIP_CONFLICT', severity: 'blocking', message: `More than one proposal operation owns ${path}. Repreview after resolving the conflict.` });
|
|
2832
|
+
const dependencyFingerprints = Object.fromEntries(patches.map((patch) => [
|
|
2833
|
+
patch.path,
|
|
2834
|
+
createHash('sha256').update(patch.before).digest('hex'),
|
|
2835
|
+
]));
|
|
2836
|
+
const proposal = buildContextAuthoringProposal({
|
|
2837
|
+
origin: input.origin,
|
|
2838
|
+
baseSnapshotId: snapshot.snapshotId,
|
|
2839
|
+
dependencyFingerprints,
|
|
2840
|
+
operations: scopedOperations,
|
|
2841
|
+
patches,
|
|
2842
|
+
diagnostics,
|
|
2843
|
+
sourceRunId: input.sourceRunId,
|
|
2844
|
+
sourceArtifactId: input.sourceArtifactId,
|
|
2845
|
+
revision: input.revision,
|
|
2846
|
+
});
|
|
2847
|
+
contextProposalStore.save(proposal);
|
|
2848
|
+
return proposal;
|
|
2849
|
+
};
|
|
2850
|
+
const modelingAuthoringRunExecutor = async ({ runId, request }) => {
|
|
2851
|
+
const snapshot = projectSnapshot();
|
|
2852
|
+
// AGT-024: a configured provider designs the model; the deterministic
|
|
2853
|
+
// matcher is the documented provider-unavailable fallback, and the answer
|
|
2854
|
+
// says which one ran so an evidence-only suggestion is never mistaken for
|
|
2855
|
+
// an AI proposal.
|
|
2856
|
+
const provider = await createBlockStudioAssistProvider(projectRoot);
|
|
2857
|
+
let usedProvider = Boolean(provider);
|
|
2858
|
+
let operations;
|
|
2859
|
+
if (provider) {
|
|
2860
|
+
try {
|
|
2861
|
+
operations = await buildAiModelingOperations(projectRoot, snapshot.manifest, request, provider);
|
|
2862
|
+
}
|
|
2863
|
+
catch (error) {
|
|
2864
|
+
// Fall back only when the provider itself is unusable. A rejected
|
|
2865
|
+
// proposal (invented identifiers, nothing to propose) is a real result
|
|
2866
|
+
// and must surface, not be papered over by the template matcher.
|
|
2867
|
+
if (!/could not complete this modeling request/.test(error instanceof Error ? error.message : String(error)))
|
|
2868
|
+
throw error;
|
|
2869
|
+
usedProvider = false;
|
|
2870
|
+
operations = buildDeterministicModelingOperations(projectRoot, snapshot.manifest, request);
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
else {
|
|
2874
|
+
operations = buildDeterministicModelingOperations(projectRoot, snapshot.manifest, request);
|
|
2875
|
+
}
|
|
2876
|
+
const proposal = previewContextAuthoring({
|
|
2877
|
+
origin: request.workspaceContext?.correction === true ? 'correction' : 'ai',
|
|
2878
|
+
operations,
|
|
2879
|
+
// Base the draft on the snapshot it was just built from, not on the id the
|
|
2880
|
+
// client captured at page load. Gating construction on the client's id
|
|
2881
|
+
// rejected a proposal assembled from `snapshot.manifest` moments earlier,
|
|
2882
|
+
// so any unrelated project write (a block edit, a dbt reapply) made every
|
|
2883
|
+
// Modeling AI request fail with PROPOSAL_STALE until a manual refresh.
|
|
2884
|
+
// Drift is still caught where it matters: applying the proposal re-checks
|
|
2885
|
+
// its `baseSnapshotId` and `dependencyFingerprints` and fails SOURCE_CHANGED.
|
|
2886
|
+
expectedSnapshotId: snapshot.snapshotId,
|
|
2887
|
+
sourceRunId: runId,
|
|
2888
|
+
revision: Number(request.workspaceContext?.revision ?? 1),
|
|
2889
|
+
});
|
|
2890
|
+
const blocked = proposal.diagnostics.some((item) => item.severity === 'blocking');
|
|
2891
|
+
return {
|
|
2892
|
+
summary: `Prepared ${proposal.impact.modelingChanges} modeling change${proposal.impact.modelingChanges === 1 ? '' : 's'} for review.`,
|
|
2893
|
+
answer: blocked
|
|
2894
|
+
? 'I prepared a modeling proposal, but the blocking items must be resolved before it can be saved.'
|
|
2895
|
+
: usedProvider
|
|
2896
|
+
? '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.'
|
|
2897
|
+
: '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.',
|
|
2898
|
+
status: 'needs_review',
|
|
2899
|
+
trustState: 'review_required',
|
|
2900
|
+
stopReason: 'human_review_required',
|
|
2901
|
+
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 }],
|
|
2902
|
+
evaluations: proposal.diagnostics.map((diagnostic) => ({ id: `${proposal.id}:${diagnostic.code}`, label: diagnostic.code, passed: diagnostic.severity !== 'blocking', severity: diagnostic.severity, message: diagnostic.message })),
|
|
2903
|
+
nextActions: [{ id: 'review-modeling-proposal', label: 'Review in Modeling', artifactKind: 'modeling_change_proposal' }],
|
|
2904
|
+
};
|
|
2905
|
+
};
|
|
2906
|
+
const skillAuthoringRunExecutor = async ({ runId, request }) => {
|
|
2907
|
+
const snapshot = projectSnapshot();
|
|
2908
|
+
// SKILL-006: same contract as Modeling AI — a provider drafts the skill
|
|
2909
|
+
// body and vocabulary; the template is the labelled fallback.
|
|
2910
|
+
const provider = await createBlockStudioAssistProvider(projectRoot);
|
|
2911
|
+
const operations = provider
|
|
2912
|
+
? await buildAiSkillOperations(projectRoot, snapshot.manifest, request, provider)
|
|
2913
|
+
.catch(() => buildMetadataBoundSkillOperations(projectRoot, snapshot.manifest, request))
|
|
2914
|
+
: buildMetadataBoundSkillOperations(projectRoot, snapshot.manifest, request);
|
|
2915
|
+
const proposal = previewContextAuthoring({
|
|
2916
|
+
origin: request.workspaceContext?.correction === true ? 'correction' : 'ai',
|
|
2917
|
+
operations,
|
|
2918
|
+
// Same reasoning as the modeling executor above: base the draft on the
|
|
2919
|
+
// snapshot it was built from; apply-time re-validation catches real drift.
|
|
2920
|
+
expectedSnapshotId: snapshot.snapshotId,
|
|
2921
|
+
sourceRunId: runId,
|
|
2922
|
+
revision: Number(request.workspaceContext?.revision ?? 1),
|
|
2923
|
+
});
|
|
2924
|
+
return {
|
|
2925
|
+
summary: `Prepared ${proposal.impact.skillChanges} metadata-bound Skill change${proposal.impact.skillChanges === 1 ? '' : 's'} for review.`,
|
|
2926
|
+
answer: 'I prepared a scope-qualified Skill draft. It will not guide an agent unless you save it and later activate it explicitly.',
|
|
2927
|
+
status: 'needs_review',
|
|
2928
|
+
trustState: 'review_required',
|
|
2929
|
+
stopReason: 'human_review_required',
|
|
2930
|
+
artifacts: [{ id: `${runId}:skill-proposal`, kind: 'skill_change_proposal', title: 'Skill change proposal', trustState: 'review_required', ref: proposal.id, payload: proposal }],
|
|
2931
|
+
evaluations: proposal.diagnostics.map((diagnostic) => ({ id: `${proposal.id}:${diagnostic.code}`, label: diagnostic.code, passed: diagnostic.severity !== 'blocking', severity: diagnostic.severity, message: diagnostic.message })),
|
|
2932
|
+
nextActions: [{ id: 'review-skill-proposal', label: 'Review Skill draft', artifactKind: 'skill_change_proposal' }],
|
|
2933
|
+
};
|
|
2934
|
+
};
|
|
2588
2935
|
const agentRunExecutors = {
|
|
2589
2936
|
conversation: conversationRunExecutor,
|
|
2590
2937
|
certified_answer: answerRunExecutor,
|
|
2591
2938
|
semantic_answer: answerRunExecutor,
|
|
2592
2939
|
generated_answer: answerRunExecutor,
|
|
2940
|
+
modeling_draft: modelingAuthoringRunExecutor,
|
|
2941
|
+
skill_draft: skillAuthoringRunExecutor,
|
|
2593
2942
|
research: async (researchContext) => {
|
|
2594
2943
|
const { runId, request, routeDecision, emit } = researchContext;
|
|
2595
2944
|
const metrics = loadSemanticMetrics(projectRoot);
|
|
@@ -4004,6 +4353,48 @@ export async function startLocalServer(opts) {
|
|
|
4004
4353
|
return [];
|
|
4005
4354
|
}
|
|
4006
4355
|
};
|
|
4356
|
+
/**
|
|
4357
|
+
* CTX-008: the project's authored relationships, as plain join facts for the
|
|
4358
|
+
* relations a bounded repair can actually see.
|
|
4359
|
+
*
|
|
4360
|
+
* A repair prompt previously carried only the failure, the failed SQL, and a
|
|
4361
|
+
* bare relation list, so a repair could silently replace a correct governed
|
|
4362
|
+
* join with a shared-column guess. This is grounding only — it never widens
|
|
4363
|
+
* the relation scope and never authorizes a join (`REL-002` still governs
|
|
4364
|
+
* execution); trust is labelled so a draft edge reads as a draft.
|
|
4365
|
+
*/
|
|
4366
|
+
const governedJoinFactsForRelations = (relations) => {
|
|
4367
|
+
const modeling = (() => {
|
|
4368
|
+
try {
|
|
4369
|
+
return projectSnapshot().manifest.modeling;
|
|
4370
|
+
}
|
|
4371
|
+
catch {
|
|
4372
|
+
return undefined;
|
|
4373
|
+
}
|
|
4374
|
+
})();
|
|
4375
|
+
if (!modeling)
|
|
4376
|
+
return [];
|
|
4377
|
+
const manifest = projectSnapshot().manifest;
|
|
4378
|
+
const normalize = (value) => value.toLowerCase().replace(/"/g, '').split('.').at(-1) ?? value.toLowerCase();
|
|
4379
|
+
const visible = new Set(relations.map(normalize));
|
|
4380
|
+
const relationFor = (entityKey) => {
|
|
4381
|
+
const entity = modeling.entities[entityKey];
|
|
4382
|
+
if (!entity)
|
|
4383
|
+
return undefined;
|
|
4384
|
+
const node = manifest.dbtProvenance?.nodes[entity.dbtUniqueId];
|
|
4385
|
+
return node?.relation ?? node?.name;
|
|
4386
|
+
};
|
|
4387
|
+
return Object.values(modeling.relationships).flatMap((relationship) => {
|
|
4388
|
+
const from = relationFor(relationship.from);
|
|
4389
|
+
const to = relationFor(relationship.to);
|
|
4390
|
+
if (!from || !to || !visible.has(normalize(from)) || !visible.has(normalize(to)))
|
|
4391
|
+
return [];
|
|
4392
|
+
const keys = relationship.keys.map((key) => `${from}.${key.from} = ${to}.${key.to}`).join(' AND ');
|
|
4393
|
+
const trust = relationship.automaticJoinAllowed ? 'certified' : `${relationship.status} (review required)`;
|
|
4394
|
+
const meaning = [relationship.verb, relationship.description].filter(Boolean).join(' — ');
|
|
4395
|
+
return [`${keys} (${relationship.cardinality}, fanout ${relationship.fanout}, ${trust}${meaning ? `; ${meaning}` : ''})`];
|
|
4396
|
+
}).slice(0, 12);
|
|
4397
|
+
};
|
|
4007
4398
|
const boundedRepairError = (message, status, warehouseFailure) => Object.assign(new Error(message), { status, ...(warehouseFailure ? { warehouseFailure } : {}) });
|
|
4008
4399
|
/**
|
|
4009
4400
|
* Shared one-attempt SQL repair for Ask and Notebook. It never creates a
|
|
@@ -4040,6 +4431,9 @@ export async function startLocalServer(opts) {
|
|
|
4040
4431
|
relation: table.relation,
|
|
4041
4432
|
columns: table.columns.slice(0, 40).map((column) => ({ name: column.name, type: column.type })),
|
|
4042
4433
|
}));
|
|
4434
|
+
// CTX-008: a repair that re-derives joins from column names can undo a
|
|
4435
|
+
// join the original query got right. Give it the authored key pairs.
|
|
4436
|
+
const governedJoins = governedJoinFactsForRelations(schemaContext.slice(0, 16).map((table) => table.relation));
|
|
4043
4437
|
let raw;
|
|
4044
4438
|
try {
|
|
4045
4439
|
raw = await provider.generate([
|
|
@@ -4053,6 +4447,7 @@ export async function startLocalServer(opts) {
|
|
|
4053
4447
|
? 'Preserve every positional parameter placeholder exactly; do not add, remove, or renumber placeholders.'
|
|
4054
4448
|
: '',
|
|
4055
4449
|
'Use only relations and columns present in the supplied schema context.',
|
|
4450
|
+
'When governedJoins is present, join on those exact key pairs rather than inferring a join from column names.',
|
|
4056
4451
|
'Never emit internal DQL relation identities such as source::, model::, seed::, or snapshot::.',
|
|
4057
4452
|
'Do not add writes, DDL, multiple statements, or commentary outside the JSON fence.',
|
|
4058
4453
|
].join(' '),
|
|
@@ -4069,6 +4464,7 @@ export async function startLocalServer(opts) {
|
|
|
4069
4464
|
placeholder: `$${parameter.position}`,
|
|
4070
4465
|
})),
|
|
4071
4466
|
schema: boundedSchema,
|
|
4467
|
+
...(governedJoins.length ? { governedJoins } : {}),
|
|
4072
4468
|
}),
|
|
4073
4469
|
},
|
|
4074
4470
|
], { maxTokens: 1200, temperature: 0 });
|
|
@@ -4122,7 +4518,7 @@ export async function startLocalServer(opts) {
|
|
|
4122
4518
|
const executeBoundedDqlRepair = async (input) => {
|
|
4123
4519
|
const provider = await createBlockStudioAssistProvider(projectRoot);
|
|
4124
4520
|
if (!provider) {
|
|
4125
|
-
throw boundedRepairError('Configure an available AI provider in Settings to repair this DQL
|
|
4521
|
+
throw boundedRepairError('Configure an available AI provider in Settings to repair this DQL draft.', 409);
|
|
4126
4522
|
}
|
|
4127
4523
|
let schemaContext;
|
|
4128
4524
|
try {
|
|
@@ -4138,6 +4534,8 @@ export async function startLocalServer(opts) {
|
|
|
4138
4534
|
relation: table.relation,
|
|
4139
4535
|
columns: table.columns.slice(0, 40).map((column) => ({ name: column.name, type: column.type })),
|
|
4140
4536
|
}));
|
|
4537
|
+
// CTX-008: same as the SQL repair — keep authored joins out of the guesser.
|
|
4538
|
+
const governedJoins = governedJoinFactsForRelations(schemaContext.slice(0, 16).map((table) => table.relation));
|
|
4141
4539
|
let raw;
|
|
4142
4540
|
try {
|
|
4143
4541
|
raw = await provider.generate([
|
|
@@ -4150,6 +4548,7 @@ export async function startLocalServer(opts) {
|
|
|
4150
4548
|
'Preserve the block name, business fields, visualization, tests, requested grain, filters, output columns, and aggregation semantics.',
|
|
4151
4549
|
'Preserve every declared parameter and every ${parameter} interpolation exactly. Do not add or remove parameters.',
|
|
4152
4550
|
'Use only relations and columns present in the supplied schema context.',
|
|
4551
|
+
'When governedJoins is present, join on those exact key pairs rather than inferring a join from column names.',
|
|
4153
4552
|
'Never emit internal relation identities such as source::, model::, seed::, or snapshot::.',
|
|
4154
4553
|
'Return exactly one custom block. Never certify it, bind a saved block, add writes, DDL, or multiple SQL statements.',
|
|
4155
4554
|
].join(' '),
|
|
@@ -4163,6 +4562,7 @@ export async function startLocalServer(opts) {
|
|
|
4163
4562
|
dql: input.source.slice(0, 40_000),
|
|
4164
4563
|
parameters: input.parameters ?? {},
|
|
4165
4564
|
schema: boundedSchema,
|
|
4565
|
+
...(governedJoins.length ? { governedJoins } : {}),
|
|
4166
4566
|
}),
|
|
4167
4567
|
},
|
|
4168
4568
|
], { maxTokens: 2400, temperature: 0 });
|
|
@@ -4185,7 +4585,7 @@ export async function startLocalServer(opts) {
|
|
|
4185
4585
|
throw boundedRepairError('The AI repair did not return a complete DQL block.', 422);
|
|
4186
4586
|
}
|
|
4187
4587
|
if (repairedSource.length > 50_000) {
|
|
4188
|
-
throw boundedRepairError('The repaired DQL exceeded the safe editable-
|
|
4588
|
+
throw boundedRepairError('The repaired DQL exceeded the safe editable-draft size limit.', 422);
|
|
4189
4589
|
}
|
|
4190
4590
|
if (/^\s*@block\(/i.test(repairedSource) || /\btype\s*=\s*"semantic"/i.test(repairedSource)) {
|
|
4191
4591
|
throw boundedRepairError('The repair changed this editable custom block into a governed reference or semantic block.', 422);
|
|
@@ -5133,6 +5533,33 @@ export async function startLocalServer(opts) {
|
|
|
5133
5533
|
}
|
|
5134
5534
|
catch { /* dir not watchable */ }
|
|
5135
5535
|
}
|
|
5536
|
+
// A dbt repository commonly keeps its shared Skills folder beside the DQL
|
|
5537
|
+
// project (`../skills`). It is Git-owned context too, so watch the resolved
|
|
5538
|
+
// configured root even when it is not the default projectRoot/skills path.
|
|
5539
|
+
const configuredSkillsRoot = skillsDir(projectRoot);
|
|
5540
|
+
const defaultSkillsRoot = join(projectRoot, 'skills');
|
|
5541
|
+
if (existsSync(configuredSkillsRoot) && resolve(configuredSkillsRoot) !== resolve(defaultSkillsRoot)) {
|
|
5542
|
+
try {
|
|
5543
|
+
const watcher = watch(configuredSkillsRoot, { persistent: false, recursive: true }, (eventType, filename) => {
|
|
5544
|
+
if (runtimeClosing || !filename)
|
|
5545
|
+
return;
|
|
5546
|
+
const path = relative(projectRoot, join(configuredSkillsRoot, String(filename))).replace(/\\/g, '/');
|
|
5547
|
+
invalidateAgentProjectState(projectRoot);
|
|
5548
|
+
scheduleProjectRefresh(`skills:${path}`);
|
|
5549
|
+
const payload = JSON.stringify({ type: eventType === 'rename' ? 'file-added' : 'file-changed', path });
|
|
5550
|
+
for (const client of sseClients) {
|
|
5551
|
+
try {
|
|
5552
|
+
client.write(`event: change\ndata: ${payload}\n\n`);
|
|
5553
|
+
}
|
|
5554
|
+
catch {
|
|
5555
|
+
sseClients.delete(client);
|
|
5556
|
+
}
|
|
5557
|
+
}
|
|
5558
|
+
});
|
|
5559
|
+
projectWatchers.push(watcher);
|
|
5560
|
+
}
|
|
5561
|
+
catch { /* configured Skills folder is not watchable */ }
|
|
5562
|
+
}
|
|
5136
5563
|
const configuredDbtManifest = resolveDbtManifestPath(projectRoot, projectConfig);
|
|
5137
5564
|
if (configuredDbtManifest && existsSync(dirname(configuredDbtManifest))) {
|
|
5138
5565
|
try {
|
|
@@ -6385,6 +6812,227 @@ export async function startLocalServer(opts) {
|
|
|
6385
6812
|
}));
|
|
6386
6813
|
return;
|
|
6387
6814
|
}
|
|
6815
|
+
if (req.method === 'POST' && path === '/api/modeling/dbt-first/imports') {
|
|
6816
|
+
const requestId = apiRequestId('modeling-import');
|
|
6817
|
+
try {
|
|
6818
|
+
const body = await readJSON(req);
|
|
6819
|
+
const source = body.source && typeof body.source === 'object' ? body.source : body;
|
|
6820
|
+
const session = createModelingImportSession(projectRoot, gitRoot, loopback, source);
|
|
6821
|
+
res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6822
|
+
res.end(serializeJSON({ requestId, session: publicModelingImportSession(session) }));
|
|
6823
|
+
}
|
|
6824
|
+
catch (error) {
|
|
6825
|
+
const code = apiErrorCode(error, 'MODELING_IMPORT_INVALID');
|
|
6826
|
+
res.writeHead(code === 'PATH_ACCESS_FORBIDDEN' ? 403 : 400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6827
|
+
res.end(serializeJSON(apiErrorEnvelope({ requestId, code, message: apiErrorMessage(error), nextActions: ['Choose DQL or dbt YAML from the current Git workspace, or paste/upload the YAML.'] })));
|
|
6828
|
+
}
|
|
6829
|
+
return;
|
|
6830
|
+
}
|
|
6831
|
+
const modelingImportMatch = path.match(/^\/api\/modeling\/dbt-first\/imports\/([^/]+)$/);
|
|
6832
|
+
if (req.method === 'GET' && modelingImportMatch) {
|
|
6833
|
+
const session = readModelingImportSession(projectRoot, decodeURIComponent(modelingImportMatch[1]));
|
|
6834
|
+
if (!session) {
|
|
6835
|
+
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6836
|
+
res.end(serializeJSON({ error: 'Modeling import session not found.', code: 'MODELING_IMPORT_NOT_FOUND' }));
|
|
6837
|
+
return;
|
|
6838
|
+
}
|
|
6839
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6840
|
+
res.end(serializeJSON({ session: publicModelingImportSession(session) }));
|
|
6841
|
+
return;
|
|
6842
|
+
}
|
|
6843
|
+
const modelingImportPreviewMatch = path.match(/^\/api\/modeling\/dbt-first\/imports\/([^/]+)\/preview$/);
|
|
6844
|
+
if (req.method === 'POST' && modelingImportPreviewMatch) {
|
|
6845
|
+
const requestId = apiRequestId('modeling-import-preview');
|
|
6846
|
+
try {
|
|
6847
|
+
const session = readModelingImportSession(projectRoot, decodeURIComponent(modelingImportPreviewMatch[1]));
|
|
6848
|
+
if (!session)
|
|
6849
|
+
throw Object.assign(new Error('Modeling import session not found.'), { code: 'MODELING_IMPORT_NOT_FOUND' });
|
|
6850
|
+
const body = await readJSON(req);
|
|
6851
|
+
const snapshot = projectSnapshot();
|
|
6852
|
+
const selectedCandidateIds = Array.isArray(body.selectedCandidateIds)
|
|
6853
|
+
? body.selectedCandidateIds.filter((value) => typeof value === 'string')
|
|
6854
|
+
: session.candidates.filter((candidate) => candidate.dialect !== 'unsupported' && candidate.action !== 'open_existing').map((candidate) => candidate.id);
|
|
6855
|
+
// An unspecified scope is defaulted, not rejected: the proposal carries
|
|
6856
|
+
// the `upsert_domain`/`upsert_area` patches so the author reviews the
|
|
6857
|
+
// scope alongside the models it will hold.
|
|
6858
|
+
const domain = typeof body.domain === 'string' && body.domain.trim()
|
|
6859
|
+
? body.domain.trim()
|
|
6860
|
+
: defaultAuthoringDomainId(snapshot.manifest);
|
|
6861
|
+
const areaId = typeof body.areaId === 'string' && body.areaId.trim() ? body.areaId.trim() : DEFAULT_MODEL_AREA_ID;
|
|
6862
|
+
const planned = modelingImportOperations(session, selectedCandidateIds, domain, areaId, snapshot.manifest);
|
|
6863
|
+
const proposal = previewContextAuthoring({
|
|
6864
|
+
origin: 'yaml_import',
|
|
6865
|
+
operations: planned.operations,
|
|
6866
|
+
expectedSnapshotId: typeof body.expectedSnapshotId === 'string' ? body.expectedSnapshotId : snapshot.snapshotId,
|
|
6867
|
+
diagnostics: planned.diagnostics,
|
|
6868
|
+
});
|
|
6869
|
+
const updated = { ...session, proposalId: proposal.id, updatedAt: new Date().toISOString() };
|
|
6870
|
+
writeModelingImportSession(projectRoot, updated);
|
|
6871
|
+
res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6872
|
+
res.end(serializeJSON({ requestId, session: publicModelingImportSession(updated), proposal }));
|
|
6873
|
+
}
|
|
6874
|
+
catch (error) {
|
|
6875
|
+
const code = apiErrorCode(error, 'MODELING_IMPORT_PREVIEW_FAILED');
|
|
6876
|
+
res.writeHead(code === 'MODELING_IMPORT_NOT_FOUND' ? 404 : code === 'PROPOSAL_STALE' ? 409 : 400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6877
|
+
res.end(serializeJSON(apiErrorEnvelope({ requestId, code, message: apiErrorMessage(error), nextActions: ['Resolve unmatched models or choose another Domain and Area, then preview again.'] })));
|
|
6878
|
+
}
|
|
6879
|
+
return;
|
|
6880
|
+
}
|
|
6881
|
+
if (req.method === 'POST' && path === '/api/context-proposals') {
|
|
6882
|
+
const requestId = apiRequestId('context-proposal');
|
|
6883
|
+
try {
|
|
6884
|
+
const body = await readJSON(req);
|
|
6885
|
+
const origin = parseContextAuthoringOrigin(body.origin);
|
|
6886
|
+
const operations = parseContextAuthoringOperations(body.operations);
|
|
6887
|
+
const proposal = previewContextAuthoring({
|
|
6888
|
+
origin,
|
|
6889
|
+
operations,
|
|
6890
|
+
expectedSnapshotId: typeof body.expectedSnapshotId === 'string' ? body.expectedSnapshotId : undefined,
|
|
6891
|
+
sourceRunId: typeof body.sourceRunId === 'string' ? body.sourceRunId : undefined,
|
|
6892
|
+
sourceArtifactId: typeof body.sourceArtifactId === 'string' ? body.sourceArtifactId : undefined,
|
|
6893
|
+
revision: typeof body.revision === 'number' ? body.revision : undefined,
|
|
6894
|
+
});
|
|
6895
|
+
res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6896
|
+
res.end(serializeJSON({ requestId, proposal }));
|
|
6897
|
+
}
|
|
6898
|
+
catch (error) {
|
|
6899
|
+
const code = apiErrorCode(error, 'PROPOSAL_INVALID');
|
|
6900
|
+
res.writeHead(code === 'PROPOSAL_STALE' ? 409 : 400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6901
|
+
res.end(serializeJSON(apiErrorEnvelope({ requestId, code, message: apiErrorMessage(error), nextActions: ['Refresh Modeling and preview the proposal again.'] })));
|
|
6902
|
+
}
|
|
6903
|
+
return;
|
|
6904
|
+
}
|
|
6905
|
+
const contextProposalMatch = path.match(/^\/api\/context-proposals\/([^/]+)$/);
|
|
6906
|
+
if (req.method === 'GET' && contextProposalMatch) {
|
|
6907
|
+
const id = decodeURIComponent(contextProposalMatch[1]);
|
|
6908
|
+
const proposal = contextProposalStore.get(id);
|
|
6909
|
+
if (!proposal) {
|
|
6910
|
+
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6911
|
+
res.end(serializeJSON({ error: 'Context proposal not found.', code: 'PROPOSAL_NOT_FOUND' }));
|
|
6912
|
+
return;
|
|
6913
|
+
}
|
|
6914
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6915
|
+
res.end(serializeJSON({ proposal }));
|
|
6916
|
+
return;
|
|
6917
|
+
}
|
|
6918
|
+
const contextRepreviewMatch = path.match(/^\/api\/context-proposals\/([^/]+)\/repreview$/);
|
|
6919
|
+
if (req.method === 'POST' && contextRepreviewMatch) {
|
|
6920
|
+
const requestId = apiRequestId('context-repreview');
|
|
6921
|
+
try {
|
|
6922
|
+
const current = contextProposalStore.get(decodeURIComponent(contextRepreviewMatch[1]));
|
|
6923
|
+
if (!current)
|
|
6924
|
+
throw Object.assign(new Error('Context proposal not found.'), { code: 'PROPOSAL_NOT_FOUND' });
|
|
6925
|
+
const body = await readJSON(req);
|
|
6926
|
+
const selected = Array.isArray(body.selectedOperationIds)
|
|
6927
|
+
? body.selectedOperationIds.filter((value) => typeof value === 'string')
|
|
6928
|
+
: current.operations.map((operation) => operation.id);
|
|
6929
|
+
const operations = contextAuthoringDependencyClosure(current.operations, selected);
|
|
6930
|
+
const proposal = previewContextAuthoring({
|
|
6931
|
+
origin: current.origin,
|
|
6932
|
+
operations,
|
|
6933
|
+
expectedSnapshotId: body.rebase === true ? projectSnapshot().snapshotId : current.baseSnapshotId,
|
|
6934
|
+
sourceRunId: current.sourceRunId,
|
|
6935
|
+
sourceArtifactId: current.sourceArtifactId,
|
|
6936
|
+
revision: (current.revision ?? 1) + 1,
|
|
6937
|
+
});
|
|
6938
|
+
res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6939
|
+
res.end(serializeJSON({ requestId, proposal }));
|
|
6940
|
+
}
|
|
6941
|
+
catch (error) {
|
|
6942
|
+
const code = apiErrorCode(error, 'PROPOSAL_INVALID');
|
|
6943
|
+
res.writeHead(code === 'PROPOSAL_STALE' ? 409 : code === 'PROPOSAL_NOT_FOUND' ? 404 : 400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6944
|
+
res.end(serializeJSON(apiErrorEnvelope({ requestId, code, message: apiErrorMessage(error), nextActions: ['Refresh the proposal against the latest project snapshot.'] })));
|
|
6945
|
+
}
|
|
6946
|
+
return;
|
|
6947
|
+
}
|
|
6948
|
+
const contextCommitMatch = path.match(/^\/api\/context-proposals\/([^/]+)\/commit$/);
|
|
6949
|
+
if (req.method === 'POST' && contextCommitMatch) {
|
|
6950
|
+
const requestId = apiRequestId('context-commit');
|
|
6951
|
+
const proposalId = decodeURIComponent(contextCommitMatch[1]);
|
|
6952
|
+
try {
|
|
6953
|
+
const proposal = contextProposalStore.get(proposalId);
|
|
6954
|
+
if (!proposal)
|
|
6955
|
+
throw Object.assign(new Error('Context proposal not found.'), { code: 'PROPOSAL_NOT_FOUND' });
|
|
6956
|
+
const body = await readJSON(req);
|
|
6957
|
+
const unexpected = Object.keys(body).filter((key) => key !== 'expectedProposalHash' && key !== 'idempotencyKey');
|
|
6958
|
+
if (unexpected.length)
|
|
6959
|
+
throw Object.assign(new Error('Commit accepts only expectedProposalHash and idempotencyKey.'), { code: 'INVALID_REQUEST' });
|
|
6960
|
+
const expectedProposalHash = typeof body.expectedProposalHash === 'string' ? body.expectedProposalHash : '';
|
|
6961
|
+
const idempotencyKey = typeof body.idempotencyKey === 'string' ? body.idempotencyKey.trim() : '';
|
|
6962
|
+
if (!expectedProposalHash || !idempotencyKey)
|
|
6963
|
+
throw Object.assign(new Error('The reviewed proposal hash and idempotency key are required.'), { code: 'INVALID_REQUEST' });
|
|
6964
|
+
const receipt = contextProposalStore.getCommitReceipt(idempotencyKey);
|
|
6965
|
+
if (receipt) {
|
|
6966
|
+
if (receipt.proposalId !== proposal.id || receipt.proposalHash !== expectedProposalHash)
|
|
6967
|
+
throw Object.assign(new Error('The idempotency key belongs to another reviewed proposal request.'), { code: 'IDEMPOTENCY_CONFLICT' });
|
|
6968
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
6969
|
+
res.end(serializeJSON({ requestId, proposal: contextProposalStore.get(proposal.id), snapshotId: receipt.snapshotId, idempotent: true }));
|
|
6970
|
+
return;
|
|
6971
|
+
}
|
|
6972
|
+
if (proposal.status !== 'proposed')
|
|
6973
|
+
throw Object.assign(new Error('Only a proposed change can be committed.'), { code: 'PROPOSAL_STALE' });
|
|
6974
|
+
if (proposal.proposalHash !== expectedProposalHash)
|
|
6975
|
+
throw Object.assign(new Error('The proposal changed after review.'), { code: 'PROPOSAL_STALE' });
|
|
6976
|
+
if (proposal.diagnostics.some((diagnostic) => diagnostic.severity === 'blocking'))
|
|
6977
|
+
throw Object.assign(new Error('Resolve all blocking proposal diagnostics before saving.'), { code: 'PROPOSAL_BLOCKED' });
|
|
6978
|
+
const beforeSnapshot = projectSnapshot();
|
|
6979
|
+
if (beforeSnapshot.snapshotId !== proposal.baseSnapshotId)
|
|
6980
|
+
throw Object.assign(new Error('Project sources changed after this proposal was reviewed.'), { code: 'PROPOSAL_STALE' });
|
|
6981
|
+
const resolvedPatches = proposal.patches.map((patch) => ({ patch, absolute: resolveContextPatchPath(projectRoot, gitRoot, patch.path) }));
|
|
6982
|
+
for (const { patch, absolute } of resolvedPatches) {
|
|
6983
|
+
const current = existsSync(absolute) ? readFileSync(absolute, 'utf8') : '';
|
|
6984
|
+
if (current !== patch.before)
|
|
6985
|
+
throw Object.assign(new Error(`Source changed after preview: ${patch.path}`), { code: 'SOURCE_CHANGED' });
|
|
6986
|
+
}
|
|
6987
|
+
const written = [];
|
|
6988
|
+
try {
|
|
6989
|
+
for (const item of resolvedPatches) {
|
|
6990
|
+
if (!item.patch.changed)
|
|
6991
|
+
continue;
|
|
6992
|
+
if (!item.patch.after)
|
|
6993
|
+
rmSync(item.absolute, { force: true });
|
|
6994
|
+
else {
|
|
6995
|
+
mkdirSync(dirname(item.absolute), { recursive: true });
|
|
6996
|
+
writeFileSync(item.absolute, item.patch.after, 'utf8');
|
|
6997
|
+
}
|
|
6998
|
+
written.push(item);
|
|
6999
|
+
}
|
|
7000
|
+
projectSnapshots.invalidate();
|
|
7001
|
+
invalidateAgentProjectState(projectRoot);
|
|
7002
|
+
const nextSnapshot = projectSnapshot();
|
|
7003
|
+
if (nextSnapshot.error)
|
|
7004
|
+
throw Object.assign(new Error(nextSnapshot.error), { code: 'COMPILE_FAILED' });
|
|
7005
|
+
await refreshUnifiedProjectIndexes(projectRoot);
|
|
7006
|
+
const committed = { ...proposal, status: 'committed', committedAt: new Date().toISOString(), committedSnapshotId: nextSnapshot.snapshotId };
|
|
7007
|
+
contextProposalStore.save(committed);
|
|
7008
|
+
contextProposalStore.saveCommitReceipt(idempotencyKey, { proposalId: proposal.id, proposalHash: proposal.proposalHash, snapshotId: nextSnapshot.snapshotId });
|
|
7009
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
7010
|
+
res.end(serializeJSON({ requestId, proposal: committed, snapshotId: nextSnapshot.snapshotId, modeling: nextSnapshot.manifest.modeling }));
|
|
7011
|
+
}
|
|
7012
|
+
catch (error) {
|
|
7013
|
+
for (const { patch, absolute } of written.reverse()) {
|
|
7014
|
+
if (!patch.before)
|
|
7015
|
+
rmSync(absolute, { force: true });
|
|
7016
|
+
else {
|
|
7017
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
7018
|
+
writeFileSync(absolute, patch.before, 'utf8');
|
|
7019
|
+
}
|
|
7020
|
+
}
|
|
7021
|
+
projectSnapshots.invalidate();
|
|
7022
|
+
invalidateAgentProjectState(projectRoot);
|
|
7023
|
+
contextProposalStore.save(proposal);
|
|
7024
|
+
contextProposalStore.deleteCommitReceipt(idempotencyKey);
|
|
7025
|
+
throw error;
|
|
7026
|
+
}
|
|
7027
|
+
}
|
|
7028
|
+
catch (error) {
|
|
7029
|
+
const code = apiErrorCode(error, /changed after/i.test(apiErrorMessage(error)) ? 'SOURCE_CHANGED' : 'PROPOSAL_COMMIT_FAILED');
|
|
7030
|
+
const status = code === 'PROPOSAL_NOT_FOUND' ? 404 : ['PROPOSAL_STALE', 'SOURCE_CHANGED', 'IDEMPOTENCY_CONFLICT', 'OWNERSHIP_CONFLICT', 'DEPENDENCY_CONFLICT'].includes(code) ? 409 : code === 'PROPOSAL_BLOCKED' ? 422 : 400;
|
|
7031
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
7032
|
+
res.end(serializeJSON(apiErrorEnvelope({ requestId, code, message: apiErrorMessage(error), nextActions: ['Rebase or repreview the proposal before saving.'] })));
|
|
7033
|
+
}
|
|
7034
|
+
return;
|
|
7035
|
+
}
|
|
6388
7036
|
if (req.method === 'POST' && path === '/api/modeling/dbt-first/dbt-source/preview') {
|
|
6389
7037
|
const requestId = apiRequestId('modeling-dbt-source-preview');
|
|
6390
7038
|
const snapshot = projectSnapshot();
|
|
@@ -7419,14 +8067,22 @@ export async function startLocalServer(opts) {
|
|
|
7419
8067
|
const metrics = semanticLayer
|
|
7420
8068
|
? semanticLayer.listMetrics(undefined, { includeMeasures: false }).map((m) => m.name).filter(matches).sort().slice(0, limit)
|
|
7421
8069
|
: [];
|
|
7422
|
-
const manifest = buildManifest({
|
|
8070
|
+
const manifest = buildManifest({
|
|
8071
|
+
projectRoot,
|
|
8072
|
+
dqlVersion: 'notebook',
|
|
8073
|
+
dbtManifestPath: resolveDbtManifestPath(projectRoot) ?? undefined,
|
|
8074
|
+
});
|
|
7423
8075
|
const blocks = Object.values(manifest.blocks)
|
|
7424
8076
|
.filter((b) => b.status === 'certified')
|
|
7425
8077
|
.map((b) => b.name)
|
|
7426
8078
|
.filter(matches)
|
|
7427
8079
|
.sort();
|
|
8080
|
+
const modelingRefs = [
|
|
8081
|
+
...Object.values(manifest.modeling?.entities ?? {}).map((entity) => entity.qualifiedId),
|
|
8082
|
+
...Object.values(manifest.modeling?.relationships ?? {}).map((relationship) => relationship.qualifiedId),
|
|
8083
|
+
].filter(matches).sort();
|
|
7428
8084
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
7429
|
-
res.end(serializeJSON({ metrics, blocks: blocks.slice(0, limit) }));
|
|
8085
|
+
res.end(serializeJSON({ metrics, blocks: blocks.slice(0, limit), modelingRefs: modelingRefs.slice(0, limit) }));
|
|
7430
8086
|
}
|
|
7431
8087
|
catch (error) {
|
|
7432
8088
|
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
@@ -7444,6 +8100,9 @@ export async function startLocalServer(opts) {
|
|
|
7444
8100
|
return;
|
|
7445
8101
|
}
|
|
7446
8102
|
const skill = writeSkill(projectRoot, input);
|
|
8103
|
+
projectSnapshots.invalidate();
|
|
8104
|
+
invalidateAgentProjectState(projectRoot);
|
|
8105
|
+
await refreshUnifiedProjectIndexes(projectRoot);
|
|
7447
8106
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
7448
8107
|
res.end(serializeJSON({ skill: serializeSkill(skill) }));
|
|
7449
8108
|
}
|
|
@@ -7463,7 +8122,10 @@ export async function startLocalServer(opts) {
|
|
|
7463
8122
|
res.end(serializeJSON({ error: 'Provide { skill } with scope and body.' }));
|
|
7464
8123
|
return;
|
|
7465
8124
|
}
|
|
7466
|
-
const skill = writeSkill(projectRoot, input);
|
|
8125
|
+
const skill = writeSkill(projectRoot, { ...input, qualifiedId: id });
|
|
8126
|
+
projectSnapshots.invalidate();
|
|
8127
|
+
invalidateAgentProjectState(projectRoot);
|
|
8128
|
+
await refreshUnifiedProjectIndexes(projectRoot);
|
|
7467
8129
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
7468
8130
|
res.end(serializeJSON({ skill: serializeSkill(skill) }));
|
|
7469
8131
|
}
|
|
@@ -7477,6 +8139,9 @@ export async function startLocalServer(opts) {
|
|
|
7477
8139
|
try {
|
|
7478
8140
|
const id = decodeURIComponent(path.slice('/api/skills/'.length));
|
|
7479
8141
|
deleteSkill(projectRoot, id);
|
|
8142
|
+
projectSnapshots.invalidate();
|
|
8143
|
+
invalidateAgentProjectState(projectRoot);
|
|
8144
|
+
await refreshUnifiedProjectIndexes(projectRoot);
|
|
7480
8145
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
7481
8146
|
res.end(serializeJSON({ ok: true }));
|
|
7482
8147
|
}
|
|
@@ -8577,20 +9242,23 @@ export async function startLocalServer(opts) {
|
|
|
8577
9242
|
}));
|
|
8578
9243
|
return;
|
|
8579
9244
|
}
|
|
8580
|
-
const appDashRun = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/run$/);
|
|
9245
|
+
const appDashRun = path.match(/^\/api\/(apps|app-builds)\/([^/]+)\/dashboards\/([^/]+)\/run$/);
|
|
8581
9246
|
if (req.method === 'POST' && appDashRun) {
|
|
8582
9247
|
try {
|
|
8583
|
-
const
|
|
8584
|
-
const
|
|
9248
|
+
const runSurface = appDashRun[1];
|
|
9249
|
+
const appId = decodeURIComponent(appDashRun[2]);
|
|
9250
|
+
const dashboardId = decodeURIComponent(appDashRun[3]);
|
|
8585
9251
|
const body = await readJSON(req).catch(() => ({}));
|
|
8586
|
-
const loaded =
|
|
9252
|
+
const loaded = runSurface === 'app-builds'
|
|
9253
|
+
? loadAppBuildDraftDashboard(projectRoot, appId, dashboardId)
|
|
9254
|
+
: loadAppDashboard(projectRoot, appId, dashboardId);
|
|
8587
9255
|
if (!loaded) {
|
|
8588
9256
|
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
8589
|
-
res.end(serializeJSON({ error: `Dashboard "${dashboardId}" not found in app "${appId}"` }));
|
|
9257
|
+
res.end(serializeJSON({ error: `Dashboard "${dashboardId}" not found in ${runSurface === 'app-builds' ? 'local App draft' : 'App'} "${appId}"` }));
|
|
8590
9258
|
return;
|
|
8591
9259
|
}
|
|
8592
9260
|
const activeApp = activePersonaAppId();
|
|
8593
|
-
if (activeApp && activeApp !== appId) {
|
|
9261
|
+
if (runSurface === 'apps' && activeApp && activeApp !== appId) {
|
|
8594
9262
|
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
8595
9263
|
res.end(serializeJSON({ error: `The active persona belongs to App "${activeApp}", not "${appId}".` }));
|
|
8596
9264
|
return;
|
|
@@ -8721,8 +9389,14 @@ export async function startLocalServer(opts) {
|
|
|
8721
9389
|
let draftTargetConnection;
|
|
8722
9390
|
let draftRepairPlan;
|
|
8723
9391
|
try {
|
|
8724
|
-
const
|
|
8725
|
-
|
|
9392
|
+
const localDraftPath = loaded.draftArtifactsDir
|
|
9393
|
+
? join(loaded.draftArtifactsDir, item.draftAnalysis.ref)
|
|
9394
|
+
: undefined;
|
|
9395
|
+
const draftRoot = localDraftPath && existsSync(localDraftPath)
|
|
9396
|
+
? loaded.draftArtifactsDir
|
|
9397
|
+
: loaded.appDir;
|
|
9398
|
+
const draftPath = join(draftRoot, item.draftAnalysis.ref);
|
|
9399
|
+
draftAppRelativePath = relative(draftRoot, draftPath);
|
|
8726
9400
|
if (draftAppRelativePath.startsWith('..') || draftAppRelativePath.startsWith('/')) {
|
|
8727
9401
|
throw new Error('App-scoped analysis reference escapes the App directory.');
|
|
8728
9402
|
}
|
|
@@ -8897,9 +9571,7 @@ export async function startLocalServer(opts) {
|
|
|
8897
9571
|
throw new Error(`Semantic definition changed; missing model(s): ${missingModels.join(', ')}`);
|
|
8898
9572
|
const targetConnection = requireActiveConnection(isConnectionConfig(body.connection) ? body.connection : connection);
|
|
8899
9573
|
const tableMapping = await resolveSemanticTableMapping(executor, targetConnection, semanticLayer, projectRoot);
|
|
8900
|
-
const activeFilters =
|
|
8901
|
-
.filter(([field, value]) => item.semantic.dimensions?.includes(field) && value !== undefined && value !== null && value !== '')
|
|
8902
|
-
.map(([dimension, value]) => ({ dimension, operator: 'equals', values: Array.isArray(value) ? value.map(String) : [String(value)] }));
|
|
9574
|
+
const activeFilters = dashboardSemanticFiltersForTile(loaded.dashboard, item, dashboardVariables);
|
|
8903
9575
|
const staticFilters = (item.semantic.filters ?? []).map((filter) => ({
|
|
8904
9576
|
dimension: filter.field,
|
|
8905
9577
|
operator: filter.operator,
|
|
@@ -9085,6 +9757,8 @@ export async function startLocalServer(opts) {
|
|
|
9085
9757
|
variables: { ...(blockPlan?.variables ?? {}), ...dashboardVariables },
|
|
9086
9758
|
block,
|
|
9087
9759
|
dashboard: loaded.dashboard,
|
|
9760
|
+
tileId: item.i,
|
|
9761
|
+
tileFilterBindings: item.filterBindings,
|
|
9088
9762
|
});
|
|
9089
9763
|
const prepared = prepareLocalExecution(blockFilterApplication.sql, blockTargetConnection, projectRoot, projectConfig);
|
|
9090
9764
|
const result = await executor.executeQuery(prepared.sql, blockFilterApplication.sqlParams, runtimeVariables({ ...blockFilterApplication.variables, ...blockInvocation.values }), prepared.connection);
|
|
@@ -9225,6 +9899,36 @@ export async function startLocalServer(opts) {
|
|
|
9225
9899
|
}
|
|
9226
9900
|
}
|
|
9227
9901
|
localApps?.close();
|
|
9902
|
+
// Which of each tile's result columns a viewer may safely filter on.
|
|
9903
|
+
// Decided here, from a real dialect parse of the SQL that actually ran,
|
|
9904
|
+
// for the same reason Ask does it server-side: an aggregate output needs
|
|
9905
|
+
// HAVING, and picking such a column off the table would quietly change
|
|
9906
|
+
// the measure. The browser must never re-derive this.
|
|
9907
|
+
const runDialect = (() => {
|
|
9908
|
+
try {
|
|
9909
|
+
return requireActiveConnection().driver || 'duckdb';
|
|
9910
|
+
}
|
|
9911
|
+
catch {
|
|
9912
|
+
return 'duckdb';
|
|
9913
|
+
}
|
|
9914
|
+
})();
|
|
9915
|
+
const tilesWithFilters = tiles.map((tile) => {
|
|
9916
|
+
const executedSql = tile.artifact?.sql;
|
|
9917
|
+
const columns = tile.result?.columns ?? [];
|
|
9918
|
+
if (typeof executedSql !== 'string' || columns.length === 0)
|
|
9919
|
+
return tile;
|
|
9920
|
+
try {
|
|
9921
|
+
const dashboardItem = loaded.dashboard.layout.items.find((item) => item.i === tile.tileId);
|
|
9922
|
+
const block = dashboardItem ? resolveDashboardItemBlock(dashboardItem, manifest) : null;
|
|
9923
|
+
const declaredDimensions = block?.status === 'certified' ? block.dimensions ?? [] : [];
|
|
9924
|
+
const filterable = dashboardFilterableResultColumns(executedSql, columns, declaredDimensions, runDialect);
|
|
9925
|
+
return filterable.length > 0 ? { ...tile, filterableColumns: filterable } : tile;
|
|
9926
|
+
}
|
|
9927
|
+
catch {
|
|
9928
|
+
return tile; // Unparseable SQL simply offers no filter candidates.
|
|
9929
|
+
}
|
|
9930
|
+
});
|
|
9931
|
+
const filterOptions = collectDashboardFilterOptions(loaded.dashboard, tilesWithFilters);
|
|
9228
9932
|
const snapshot = projectSnapshot();
|
|
9229
9933
|
const filterFingerprint = createHash('sha256').update(JSON.stringify(dashboardVariables)).digest('hex');
|
|
9230
9934
|
const resultFingerprint = createHash('sha256').update(JSON.stringify(tiles.map((tile) => ({ tileId: tile.tileId, status: tile.status, result: tile.result })))).digest('hex');
|
|
@@ -9275,7 +9979,8 @@ export async function startLocalServer(opts) {
|
|
|
9275
9979
|
filterFingerprint,
|
|
9276
9980
|
resultFingerprint,
|
|
9277
9981
|
personaFingerprint,
|
|
9278
|
-
|
|
9982
|
+
filterOptions,
|
|
9983
|
+
tiles: tilesWithFilters,
|
|
9279
9984
|
facts: storyResult.facts,
|
|
9280
9985
|
story: storyResult.story,
|
|
9281
9986
|
}));
|
|
@@ -9287,7 +9992,11 @@ export async function startLocalServer(opts) {
|
|
|
9287
9992
|
return;
|
|
9288
9993
|
}
|
|
9289
9994
|
// Apps, dashboards, persona — see apps-api.ts. Returns true if handled.
|
|
9290
|
-
if (path.startsWith('/api/apps')
|
|
9995
|
+
if (path.startsWith('/api/apps')
|
|
9996
|
+
|| path.startsWith('/api/app-builds')
|
|
9997
|
+
|| path.startsWith('/api/app-recoveries')
|
|
9998
|
+
|| path.startsWith('/api/visualizations')
|
|
9999
|
+
|| path === '/api/persona') {
|
|
9291
10000
|
try {
|
|
9292
10001
|
const handled = await handleAppsApi({
|
|
9293
10002
|
req,
|
|
@@ -9333,6 +10042,18 @@ export async function startLocalServer(opts) {
|
|
|
9333
10042
|
}
|
|
9334
10043
|
return { ok: true, snapshotId: evidence.snapshotId, resultFingerprint: evidence.resultFingerprint };
|
|
9335
10044
|
},
|
|
10045
|
+
verifyAppBuildPreview: ({ runId, draftId, dashboardId, tileIds }) => {
|
|
10046
|
+
const evidence = dashboardRunEvidence.get(runId);
|
|
10047
|
+
if (!evidence || evidence.expiresAt < Date.now())
|
|
10048
|
+
return { ok: false, error: 'The App preview receipt expired. Run the local draft again.' };
|
|
10049
|
+
if (evidence.appId !== draftId || evidence.dashboardId !== dashboardId)
|
|
10050
|
+
return { ok: false, error: 'The App preview receipt does not match this local draft page.' };
|
|
10051
|
+
const successful = new Set(evidence.successfulTileIds);
|
|
10052
|
+
const failed = tileIds.filter((tileId) => !successful.has(tileId));
|
|
10053
|
+
if (failed.length > 0)
|
|
10054
|
+
return { ok: false, error: `These App components did not complete successfully: ${failed.join(', ')}` };
|
|
10055
|
+
return { ok: true, snapshotId: evidence.snapshotId, resultFingerprint: evidence.resultFingerprint };
|
|
10056
|
+
},
|
|
9336
10057
|
// Story narration for commit — LLM-backed with deterministic fallback.
|
|
9337
10058
|
narrate: narrateForAgentRun,
|
|
9338
10059
|
});
|
|
@@ -11906,6 +12627,20 @@ export async function startLocalServer(opts) {
|
|
|
11906
12627
|
res.end(serializeJSON({ error: 'Block name is required.' }));
|
|
11907
12628
|
return;
|
|
11908
12629
|
}
|
|
12630
|
+
const validation = validateBlockStudioSource(source, semanticLayer);
|
|
12631
|
+
if (!blockStudioValidationAllowsDraftSave(validation)) {
|
|
12632
|
+
const errors = validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'
|
|
12633
|
+
&& !BLOCK_STUDIO_DRAFT_RUNTIME_DIAGNOSTICS.has(diagnostic.code ?? ''));
|
|
12634
|
+
res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
12635
|
+
res.end(serializeJSON({
|
|
12636
|
+
error: errors[0]?.message ?? 'Fix the block errors before saving.',
|
|
12637
|
+
code: 'BLOCK_VALIDATION_FAILED',
|
|
12638
|
+
recoverable: true,
|
|
12639
|
+
details: { validation: { ...validation, saveable: false } },
|
|
12640
|
+
nextActions: ['Edit the DQL source', 'Fix with AI and run'],
|
|
12641
|
+
}));
|
|
12642
|
+
return;
|
|
12643
|
+
}
|
|
11909
12644
|
const currentPath = typeof body.path === 'string' ? body.path : undefined;
|
|
11910
12645
|
const saveOptions = {
|
|
11911
12646
|
currentPath,
|
|
@@ -13561,11 +14296,12 @@ export async function startLocalServer(opts) {
|
|
|
13561
14296
|
return;
|
|
13562
14297
|
}
|
|
13563
14298
|
/**
|
|
13564
|
-
* Repair an editable notebook cell
|
|
13565
|
-
* The returned source is applied only to the in-memory
|
|
13566
|
-
* client; this endpoint never writes
|
|
14299
|
+
* Repair an editable notebook cell or Block Studio draft without starting
|
|
14300
|
+
* an AI conversation. The returned source is applied only to the in-memory
|
|
14301
|
+
* editor by the client; this endpoint never writes governed source files.
|
|
13567
14302
|
*/
|
|
13568
|
-
if (req.method === 'POST' && path === '/api/notebook/repair-execution'
|
|
14303
|
+
if (req.method === 'POST' && (path === '/api/notebook/repair-execution'
|
|
14304
|
+
|| path === '/api/block-studio/repair-and-run')) {
|
|
13569
14305
|
let body = {};
|
|
13570
14306
|
let execContext = null;
|
|
13571
14307
|
const start = Date.now();
|
|
@@ -15442,6 +16178,61 @@ function loadAppDashboard(projectRoot, appId, dashboardId) {
|
|
|
15442
16178
|
}
|
|
15443
16179
|
return null;
|
|
15444
16180
|
}
|
|
16181
|
+
/**
|
|
16182
|
+
* Resolves an App Studio draft directly from local SQLite for read-only preview.
|
|
16183
|
+
* The draft is never copied into the Git-owned `apps/` tree. This gives manual
|
|
16184
|
+
* and AI authoring the same execution path and the same filter/story receipts
|
|
16185
|
+
* as a published App while keeping publication explicit.
|
|
16186
|
+
*/
|
|
16187
|
+
function loadAppBuildDraftDashboard(projectRoot, draftId, dashboardId) {
|
|
16188
|
+
const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
16189
|
+
try {
|
|
16190
|
+
const draft = storage.getAppBuildDraft(draftId);
|
|
16191
|
+
if (!draft)
|
|
16192
|
+
return null;
|
|
16193
|
+
const dashboard = draft.pages.find((page) => page.id === dashboardId);
|
|
16194
|
+
if (!dashboard)
|
|
16195
|
+
return null;
|
|
16196
|
+
const base = draft.baseApp ? findAppById(projectRoot, draft.baseApp.appId) : null;
|
|
16197
|
+
const app = base?.app ?? {
|
|
16198
|
+
version: 1,
|
|
16199
|
+
id: draft.appId,
|
|
16200
|
+
name: draft.name,
|
|
16201
|
+
description: draft.frame.goal,
|
|
16202
|
+
visibility: 'private',
|
|
16203
|
+
publicationIntent: 'personal',
|
|
16204
|
+
domain: dashboard.metadata.domain ?? 'general',
|
|
16205
|
+
usesDomains: [],
|
|
16206
|
+
requiredExports: [],
|
|
16207
|
+
audience: draft.frame.audience || 'stakeholders',
|
|
16208
|
+
lifecycle: 'draft',
|
|
16209
|
+
owners: ['local-author'],
|
|
16210
|
+
tags: ['app-studio', 'local-draft'],
|
|
16211
|
+
members: [],
|
|
16212
|
+
roles: [],
|
|
16213
|
+
policies: [],
|
|
16214
|
+
homepage: { type: 'dashboard', id: dashboard.id },
|
|
16215
|
+
};
|
|
16216
|
+
return {
|
|
16217
|
+
app: { ...app, id: draft.appId, name: draft.name, visibility: 'private', lifecycle: 'draft' },
|
|
16218
|
+
dashboard,
|
|
16219
|
+
appDir: base?.appDir ?? join(projectRoot, '.dql', 'local', 'app-builds', draft.id),
|
|
16220
|
+
draftArtifactsDir: join(projectRoot, '.dql', 'local', 'app-builds', draft.id),
|
|
16221
|
+
};
|
|
16222
|
+
}
|
|
16223
|
+
finally {
|
|
16224
|
+
storage.close();
|
|
16225
|
+
}
|
|
16226
|
+
}
|
|
16227
|
+
function findAppById(projectRoot, appId) {
|
|
16228
|
+
for (const path of findAppDocuments(projectRoot)) {
|
|
16229
|
+
const { document: app } = loadAppDocument(path);
|
|
16230
|
+
if (app?.id !== appId)
|
|
16231
|
+
continue;
|
|
16232
|
+
return { app, appDir: path.slice(0, -'/dql.app.json'.length) };
|
|
16233
|
+
}
|
|
16234
|
+
return null;
|
|
16235
|
+
}
|
|
15445
16236
|
function resolveDashboardItemBlock(item, manifest) {
|
|
15446
16237
|
if (!item.block)
|
|
15447
16238
|
return null;
|
|
@@ -15542,6 +16333,7 @@ function serializeSkill(skill) {
|
|
|
15542
16333
|
clarifyWhen: skill.clarifyWhen,
|
|
15543
16334
|
examples: skill.examples,
|
|
15544
16335
|
sourceRefs: skill.sourceRefs,
|
|
16336
|
+
analyticalPolicy: skill.analyticalPolicy,
|
|
15545
16337
|
vocabulary: skill.vocabulary,
|
|
15546
16338
|
sourcePath: skill.sourcePath,
|
|
15547
16339
|
isStarter: skill.isStarter,
|
|
@@ -15597,9 +16389,27 @@ function parseSkillInput(raw, fallbackId) {
|
|
|
15597
16389
|
examples: asStrings(skill.examples),
|
|
15598
16390
|
sourceRefs: asStrings(skill.sourceRefs),
|
|
15599
16391
|
vocabulary: asMap(skill.vocabulary),
|
|
16392
|
+
analyticalPolicy: parseSkillAnalyticalPolicyInput(skill.analyticalPolicy),
|
|
15600
16393
|
isStarter: skill.isStarter === true ? true : undefined,
|
|
15601
16394
|
};
|
|
15602
16395
|
}
|
|
16396
|
+
function parseSkillAnalyticalPolicyInput(value) {
|
|
16397
|
+
const policy = contextRecord(value);
|
|
16398
|
+
if (!Object.keys(policy).length)
|
|
16399
|
+
return undefined;
|
|
16400
|
+
const strings = (input) => Array.isArray(input) ? input.filter((item) => typeof item === 'string') : [];
|
|
16401
|
+
const pick = (input) => typeof input === 'string' && input.trim() ? input.trim() : undefined;
|
|
16402
|
+
return {
|
|
16403
|
+
metricIds: strings(policy.metricIds ?? policy.metric_ids),
|
|
16404
|
+
timeRole: pick(policy.timeRole ?? policy.time_role),
|
|
16405
|
+
calendarId: pick(policy.calendarId ?? policy.calendar_id),
|
|
16406
|
+
timezone: pick(policy.timezone),
|
|
16407
|
+
completenessPolicy: ['partial_current', 'latest_complete', 'closed_period'].includes(String(policy.completenessPolicy ?? policy.completeness_policy)) ? String(policy.completenessPolicy ?? policy.completeness_policy) : undefined,
|
|
16408
|
+
comparisonAlignment: ['elapsed_period', 'calendar_period', 'fiscal_period'].includes(String(policy.comparisonAlignment ?? policy.comparison_alignment)) ? String(policy.comparisonAlignment ?? policy.comparison_alignment) : undefined,
|
|
16409
|
+
defaultRankingPeriod: ['current', 'comparison'].includes(String(policy.defaultRankingPeriod ?? policy.default_ranking_period)) ? String(policy.defaultRankingPeriod ?? policy.default_ranking_period) : undefined,
|
|
16410
|
+
narrativeGuidance: strings(policy.narrativeGuidance ?? policy.narrative_guidance),
|
|
16411
|
+
};
|
|
16412
|
+
}
|
|
15603
16413
|
/** Loose, case/slug-insensitive domain key for counting membership. */
|
|
15604
16414
|
function domainKey(value) {
|
|
15605
16415
|
if (!value)
|
|
@@ -15759,7 +16569,947 @@ export function parseDomainInput(raw, fallbackId) {
|
|
|
15759
16569
|
sourcePath: typeof domain.sourcePath === 'string' ? domain.sourcePath : undefined,
|
|
15760
16570
|
};
|
|
15761
16571
|
}
|
|
15762
|
-
|
|
16572
|
+
/** Bounded discovery: an import scan reads at most this many YAML files. */
|
|
16573
|
+
const MODELING_IMPORT_FILE_LIMIT = 100;
|
|
16574
|
+
function modelingImportDirectory(projectRoot) {
|
|
16575
|
+
return join(projectRoot, '.dql', 'local', 'modeling-imports');
|
|
16576
|
+
}
|
|
16577
|
+
function modelingImportSessionPath(projectRoot, id) {
|
|
16578
|
+
const safe = id.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
16579
|
+
if (!safe || safe !== id)
|
|
16580
|
+
throw new Error('Invalid modeling import id.');
|
|
16581
|
+
return join(modelingImportDirectory(projectRoot), `${safe}.json`);
|
|
16582
|
+
}
|
|
16583
|
+
function writeModelingImportSession(projectRoot, session) {
|
|
16584
|
+
mkdirSync(modelingImportDirectory(projectRoot), { recursive: true });
|
|
16585
|
+
writeFileSync(modelingImportSessionPath(projectRoot, session.id), `${JSON.stringify(session, null, 2)}\n`, 'utf8');
|
|
16586
|
+
}
|
|
16587
|
+
function readModelingImportSession(projectRoot, id) {
|
|
16588
|
+
const path = modelingImportSessionPath(projectRoot, id);
|
|
16589
|
+
if (!existsSync(path))
|
|
16590
|
+
return null;
|
|
16591
|
+
try {
|
|
16592
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
16593
|
+
return parsed.version === 1 && parsed.id === id ? parsed : null;
|
|
16594
|
+
}
|
|
16595
|
+
catch {
|
|
16596
|
+
return null;
|
|
16597
|
+
}
|
|
16598
|
+
}
|
|
16599
|
+
function publicModelingImportSession(session) {
|
|
16600
|
+
return {
|
|
16601
|
+
...session,
|
|
16602
|
+
candidates: session.candidates.map(({ document: _document, ...candidate }) => candidate),
|
|
16603
|
+
};
|
|
16604
|
+
}
|
|
16605
|
+
function createModelingImportSession(projectRoot, gitRoot, loopback, source) {
|
|
16606
|
+
const mode = source.mode;
|
|
16607
|
+
if (mode !== 'current_project' && mode !== 'path' && mode !== 'upload' && mode !== 'paste')
|
|
16608
|
+
throw Object.assign(new Error('Choose current_project, path, upload, or paste.'), { code: 'INVALID_REQUEST' });
|
|
16609
|
+
const files = [];
|
|
16610
|
+
const truncationWarnings = [];
|
|
16611
|
+
if (mode === 'paste' || mode === 'upload') {
|
|
16612
|
+
const content = typeof source.content === 'string' ? source.content : '';
|
|
16613
|
+
if (!content.trim() || Buffer.byteLength(content) > 1_000_000)
|
|
16614
|
+
throw Object.assign(new Error('YAML content is required and must be 1 MB or smaller.'), { code: 'MODELING_IMPORT_LIMIT' });
|
|
16615
|
+
files.push({ name: typeof source.filename === 'string' ? source.filename : mode === 'paste' ? 'pasted-modeling.yaml' : 'uploaded-modeling.yaml', content, inProject: false });
|
|
16616
|
+
}
|
|
16617
|
+
else {
|
|
16618
|
+
if (mode === 'path' && !loopback)
|
|
16619
|
+
throw Object.assign(new Error('Local path import is available only on the loopback Notebook runtime.'), { code: 'PATH_ACCESS_FORBIDDEN' });
|
|
16620
|
+
const allowedRoot = realpathSync(resolve(gitRoot ?? projectRoot));
|
|
16621
|
+
const requestedInput = mode === 'current_project'
|
|
16622
|
+
? resolve(projectRoot)
|
|
16623
|
+
: resolve(projectRoot, typeof source.path === 'string' ? source.path : '');
|
|
16624
|
+
if (!existsSync(requestedInput))
|
|
16625
|
+
throw Object.assign(new Error('The selected YAML path does not exist.'), { code: 'MODELING_IMPORT_NOT_FOUND' });
|
|
16626
|
+
const requested = realpathSync(requestedInput);
|
|
16627
|
+
if (requested !== allowedRoot && !requested.startsWith(`${allowedRoot}${sep}`))
|
|
16628
|
+
throw Object.assign(new Error('Import path must stay inside the current Git workspace.'), { code: 'PATH_ACCESS_FORBIDDEN' });
|
|
16629
|
+
const paths = collectModelingYamlPaths(requested, MODELING_IMPORT_FILE_LIMIT);
|
|
16630
|
+
// Truncation used to be silent, which reads as "DQL did not find my file".
|
|
16631
|
+
if (paths.length >= MODELING_IMPORT_FILE_LIMIT) {
|
|
16632
|
+
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.`);
|
|
16633
|
+
}
|
|
16634
|
+
for (const path of paths) {
|
|
16635
|
+
const size = statSync(path).size;
|
|
16636
|
+
if (size > 1_000_000) {
|
|
16637
|
+
truncationWarnings.push(`Skipped ${relative(requested, path).replace(/\\/g, '/')}: larger than the 1 MB import limit.`);
|
|
16638
|
+
continue;
|
|
16639
|
+
}
|
|
16640
|
+
const resolvedPath = realpathSync(path);
|
|
16641
|
+
if (resolvedPath !== allowedRoot && !resolvedPath.startsWith(`${allowedRoot}${sep}`))
|
|
16642
|
+
continue;
|
|
16643
|
+
const resolvedProject = realpathSync(projectRoot);
|
|
16644
|
+
files.push({ name: relative(requested, resolvedPath).replace(/\\/g, '/') || resolvedPath.split(sep).at(-1), path: relative(resolvedProject, resolvedPath).replace(/\\/g, '/'), content: readFileSync(resolvedPath, 'utf8'), inProject: resolvedPath === resolvedProject || resolvedPath.startsWith(`${resolvedProject}${sep}`) });
|
|
16645
|
+
}
|
|
16646
|
+
}
|
|
16647
|
+
if (files.length === 0)
|
|
16648
|
+
throw Object.assign(new Error('No YAML files were found in the selected source.'), { code: 'MODELING_IMPORT_EMPTY' });
|
|
16649
|
+
const candidates = files.map((file, index) => classifyModelingYamlCandidate(file, index));
|
|
16650
|
+
const now = new Date().toISOString();
|
|
16651
|
+
const session = {
|
|
16652
|
+
version: 1,
|
|
16653
|
+
id: apiRequestId('import'),
|
|
16654
|
+
createdAt: now,
|
|
16655
|
+
updatedAt: now,
|
|
16656
|
+
source: { mode, label: mode === 'path' ? String(source.path ?? '') : mode === 'current_project' ? 'Current project' : files[0].name },
|
|
16657
|
+
candidates,
|
|
16658
|
+
...(truncationWarnings.length ? { warnings: truncationWarnings } : {}),
|
|
16659
|
+
};
|
|
16660
|
+
writeModelingImportSession(projectRoot, session);
|
|
16661
|
+
return session;
|
|
16662
|
+
}
|
|
16663
|
+
function collectModelingYamlPaths(start, limit) {
|
|
16664
|
+
if (!existsSync(start))
|
|
16665
|
+
throw Object.assign(new Error('The selected YAML path does not exist.'), { code: 'MODELING_IMPORT_NOT_FOUND' });
|
|
16666
|
+
if (statSync(start).isFile())
|
|
16667
|
+
return /\.ya?ml$/i.test(start) ? [start] : [];
|
|
16668
|
+
const output = [];
|
|
16669
|
+
const visit = (directory) => {
|
|
16670
|
+
if (output.length >= limit)
|
|
16671
|
+
return;
|
|
16672
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
16673
|
+
if (output.length >= limit)
|
|
16674
|
+
return;
|
|
16675
|
+
if (entry.name.startsWith('.') || ['node_modules', 'target', 'dist', 'build'].includes(entry.name))
|
|
16676
|
+
continue;
|
|
16677
|
+
const path = join(directory, entry.name);
|
|
16678
|
+
if (entry.isDirectory())
|
|
16679
|
+
visit(path);
|
|
16680
|
+
else if (entry.isFile() && /\.ya?ml$/i.test(entry.name))
|
|
16681
|
+
output.push(path);
|
|
16682
|
+
}
|
|
16683
|
+
};
|
|
16684
|
+
visit(start);
|
|
16685
|
+
return output.sort();
|
|
16686
|
+
}
|
|
16687
|
+
function classifyModelingYamlCandidate(file, index) {
|
|
16688
|
+
let document = {};
|
|
16689
|
+
try {
|
|
16690
|
+
const parsed = loadYaml(file.content);
|
|
16691
|
+
document = contextRecord(parsed);
|
|
16692
|
+
}
|
|
16693
|
+
catch (error) {
|
|
16694
|
+
return { id: `file-${index + 1}`, name: file.name, path: file.path, dialect: 'unsupported', action: 'unsupported', summary: 'Malformed YAML', warnings: [error instanceof Error ? error.message : String(error)] };
|
|
16695
|
+
}
|
|
16696
|
+
const keys = new Set(Object.keys(document));
|
|
16697
|
+
const dialect = keys.has('entities') || keys.has('relationships') || keys.has('area') || keys.has('domain')
|
|
16698
|
+
? 'dql_modeling'
|
|
16699
|
+
: keys.has('semantic_models') || keys.has('metrics')
|
|
16700
|
+
? 'dbt_semantic'
|
|
16701
|
+
: keys.has('models') || keys.has('sources')
|
|
16702
|
+
? 'dbt_resource'
|
|
16703
|
+
: 'unsupported';
|
|
16704
|
+
const objectCount = ['entities', 'relationships', 'models', 'sources', 'semantic_models', 'metrics']
|
|
16705
|
+
.reduce((total, key) => total + (Array.isArray(document[key]) ? document[key].length : 0), 0);
|
|
16706
|
+
const openExisting = file.inProject && dialect === 'dql_modeling' && Boolean(file.path?.startsWith('domains/'));
|
|
16707
|
+
return {
|
|
16708
|
+
id: `file-${index + 1}`,
|
|
16709
|
+
name: file.name,
|
|
16710
|
+
path: file.path,
|
|
16711
|
+
dialect,
|
|
16712
|
+
action: dialect === 'unsupported' ? 'unsupported' : openExisting ? 'open_existing' : 'import',
|
|
16713
|
+
summary: dialect === 'unsupported' ? 'Unsupported generic YAML' : `${objectCount || 1} ${dialect.replace('_', ' ')} candidate${objectCount === 1 ? '' : 's'}`,
|
|
16714
|
+
warnings: dialect === 'unsupported' ? ['v1 supports DQL modeling, dbt resource, and dbt semantic YAML only.'] : openExisting ? ['This is already governed by the current project and should be opened in place.'] : [],
|
|
16715
|
+
document,
|
|
16716
|
+
};
|
|
16717
|
+
}
|
|
16718
|
+
function modelingImportOperations(session, selectedIds, domain, areaId, manifest) {
|
|
16719
|
+
const diagnostics = [];
|
|
16720
|
+
const operations = [];
|
|
16721
|
+
const selected = session.candidates.filter((candidate) => selectedIds.includes(candidate.id));
|
|
16722
|
+
if (!selected.length)
|
|
16723
|
+
throw Object.assign(new Error('Select at least one supported YAML candidate.'), { code: 'MODELING_IMPORT_EMPTY' });
|
|
16724
|
+
const packageExists = Boolean(manifest.modeling?.packages?.[domain]);
|
|
16725
|
+
const area = Object.values(manifest.modeling?.areas ?? {}).find((item) => item.domain === domain && (item.localId === areaId || item.qualifiedId === areaId));
|
|
16726
|
+
// A missing Domain/subject area is created by `ensureAuthoringScopeOperations`
|
|
16727
|
+
// as part of this same proposal, so it is reported, not blocked.
|
|
16728
|
+
if (!packageExists)
|
|
16729
|
+
diagnostics.push({ code: 'DOMAIN_CREATED', severity: 'info', message: `Domain "${domain}" does not exist yet and will be created with these models.` });
|
|
16730
|
+
if (!area)
|
|
16731
|
+
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.` });
|
|
16732
|
+
const nodes = Object.values(manifest.dbtProvenance?.nodes ?? {});
|
|
16733
|
+
const resolveDbt = (reference, operationId) => {
|
|
16734
|
+
const cleaned = reference.replace(/^ref\(['"]|['"]\)$/g, '').replace(/^model\./, '');
|
|
16735
|
+
const matches = nodes.filter((node) => node.uniqueId === reference || node.name === reference || node.name === cleaned || node.uniqueId.endsWith(`.${cleaned}`));
|
|
16736
|
+
if (matches.length !== 1)
|
|
16737
|
+
diagnostics.push({ code: matches.length ? 'AMBIGUOUS_DBT_MODEL' : 'DBT_MODEL_NOT_FOUND', severity: 'blocking', operationId, message: `${reference} ${matches.length ? 'matches multiple dbt models' : 'was not found in the active dbt manifest'}.` });
|
|
16738
|
+
return matches.length === 1 ? matches[0].uniqueId : undefined;
|
|
16739
|
+
};
|
|
16740
|
+
const used = new Set(Object.values(manifest.modeling?.entities ?? {}).filter((entity) => entity.domain === domain).map((entity) => entity.localId));
|
|
16741
|
+
/**
|
|
16742
|
+
* REL-004: dbt already declares its relationships. Every model this import
|
|
16743
|
+
* binds is recorded here so the dbt `relationships` tests and MetricFlow
|
|
16744
|
+
* `entities:` blocks covering those models become draft edges instead of
|
|
16745
|
+
* leaving the author to redraw connections they already wrote down.
|
|
16746
|
+
*/
|
|
16747
|
+
const boundEntityByDbtId = new Map();
|
|
16748
|
+
const semanticPrimaryByName = new Map();
|
|
16749
|
+
const semanticForeignRefs = [];
|
|
16750
|
+
const allocate = (value) => {
|
|
16751
|
+
const base = authoringSlug(value).replace(/-/g, '_') || 'model';
|
|
16752
|
+
let id = base;
|
|
16753
|
+
let suffix = 2;
|
|
16754
|
+
while (used.has(id))
|
|
16755
|
+
id = `${base}_${suffix++}`;
|
|
16756
|
+
used.add(id);
|
|
16757
|
+
return id;
|
|
16758
|
+
};
|
|
16759
|
+
for (const candidate of selected) {
|
|
16760
|
+
if (candidate.action === 'open_existing') {
|
|
16761
|
+
diagnostics.push({ code: 'OPEN_EXISTING_SOURCE', severity: 'info', message: `${candidate.path ?? candidate.name} is already in the current Domain Package and was not copied.` });
|
|
16762
|
+
continue;
|
|
16763
|
+
}
|
|
16764
|
+
if (candidate.dialect === 'unsupported' || !candidate.document) {
|
|
16765
|
+
diagnostics.push({ code: 'UNSUPPORTED_YAML', severity: 'blocking', message: `${candidate.name} is not DQL or dbt modeling YAML.` });
|
|
16766
|
+
continue;
|
|
16767
|
+
}
|
|
16768
|
+
const document = candidate.document;
|
|
16769
|
+
if (candidate.dialect === 'dql_modeling') {
|
|
16770
|
+
const importedEntities = new Map();
|
|
16771
|
+
for (const [index, raw] of contextArray(document.entities).entries()) {
|
|
16772
|
+
const entity = contextRecord(raw);
|
|
16773
|
+
const operationId = `${candidate.id}:entity:${index + 1}`;
|
|
16774
|
+
const dbtModel = resolveDbt(String(entity.dbt_model ?? entity.dbtModel ?? ''), operationId);
|
|
16775
|
+
if (!dbtModel)
|
|
16776
|
+
continue;
|
|
16777
|
+
const sourceId = String(entity.id ?? nodes.find((node) => node.uniqueId === dbtModel)?.name ?? `model_${index + 1}`);
|
|
16778
|
+
const id = allocate(sourceId);
|
|
16779
|
+
boundEntityByDbtId.set(dbtModel, { id, operationId });
|
|
16780
|
+
importedEntities.set(sourceId, { id, operationId });
|
|
16781
|
+
importedEntities.set(String(entity.qualified_id ?? entity.qualifiedId ?? ''), { id, operationId });
|
|
16782
|
+
importedEntities.set(dbtModel, { id, operationId });
|
|
16783
|
+
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 entity.business_name === 'string' ? entity.business_name : undefined, businessContext: typeof entity.business_context === 'string' ? entity.business_context : undefined, conceptRefs: contextStrings(entity.concept_refs), analyticalRole: parseImportedAnalyticalRole(entity.analytical_role), status: 'draft', owner: typeof entity.owner === 'string' ? entity.owner : undefined, grain: typeof entity.grain === 'string' ? entity.grain : undefined, keys: contextStrings(entity.keys) } } });
|
|
16784
|
+
}
|
|
16785
|
+
for (const [index, raw] of contextArray(document.relationships).entries()) {
|
|
16786
|
+
const relationship = contextRecord(raw);
|
|
16787
|
+
const operationId = `${candidate.id}:relationship:${index + 1}`;
|
|
16788
|
+
const keys = contextArray(relationship.keys).flatMap((value) => {
|
|
16789
|
+
const key = contextRecord(value);
|
|
16790
|
+
return typeof key.from === 'string' && typeof key.to === 'string' ? [{ from: key.from, to: key.to }] : [];
|
|
16791
|
+
});
|
|
16792
|
+
if (!relationship.from || !relationship.to || !keys.length) {
|
|
16793
|
+
diagnostics.push({ code: 'RELATIONSHIP_INCOMPLETE', severity: 'blocking', operationId, message: 'Imported relationship requires from, to, and at least one key pair.' });
|
|
16794
|
+
continue;
|
|
16795
|
+
}
|
|
16796
|
+
const resolveEndpoint = (rawRef) => {
|
|
16797
|
+
const ref = String(rawRef);
|
|
16798
|
+
const imported = importedEntities.get(ref);
|
|
16799
|
+
if (imported)
|
|
16800
|
+
return { id: imported.id, dependency: imported.operationId };
|
|
16801
|
+
const matches = Object.values(manifest.modeling?.entities ?? {}).filter((entity) => entity.domain === domain && (entity.localId === ref || entity.id === ref || entity.qualifiedId === ref));
|
|
16802
|
+
return matches.length === 1 ? { id: matches[0].localId } : {};
|
|
16803
|
+
};
|
|
16804
|
+
const from = resolveEndpoint(relationship.from);
|
|
16805
|
+
const to = resolveEndpoint(relationship.to);
|
|
16806
|
+
if (!from.id || !to.id) {
|
|
16807
|
+
diagnostics.push({ code: 'RELATIONSHIP_REFERENCE_UNRESOLVED', severity: 'blocking', operationId, message: `Relationship endpoints must resolve inside ${domain}; simple import cannot create cross-domain relationships.` });
|
|
16808
|
+
continue;
|
|
16809
|
+
}
|
|
16810
|
+
operations.push({ id: operationId, kind: 'modeling_change', dependsOn: [...new Set([from.dependency, to.dependency].filter((value) => Boolean(value)))], evidence: [candidate.path ?? candidate.name], change: { operation: 'upsert_relationship', value: { id: String(relationship.id ?? `${relationship.from}_to_${relationship.to}`), domain, areaId: area?.localId ?? areaId, from: from.id, to: to.id, keys, cardinality: parseImportedCardinality(relationship.cardinality), fanout: parseImportedFanout(relationship.fanout), status: 'draft', description: typeof relationship.description === 'string' ? relationship.description : undefined, rationale: 'Imported as a draft; certification and warehouse proof were intentionally removed.' } } });
|
|
16811
|
+
}
|
|
16812
|
+
}
|
|
16813
|
+
else {
|
|
16814
|
+
const modelRecords = candidate.dialect === 'dbt_resource'
|
|
16815
|
+
? contextArray(document.models)
|
|
16816
|
+
: contextArray(document.semantic_models);
|
|
16817
|
+
for (const [index, raw] of modelRecords.entries()) {
|
|
16818
|
+
const model = contextRecord(raw);
|
|
16819
|
+
const operationId = `${candidate.id}:model:${index + 1}`;
|
|
16820
|
+
const reference = String(model.model ?? model.name ?? '');
|
|
16821
|
+
const dbtModel = resolveDbt(reference, operationId);
|
|
16822
|
+
if (!dbtModel)
|
|
16823
|
+
continue;
|
|
16824
|
+
const existing = Object.values(manifest.modeling?.entities ?? {}).find((entity) => entity.dbtUniqueId === dbtModel);
|
|
16825
|
+
if (existing && existing.domain !== domain) {
|
|
16826
|
+
diagnostics.push({ code: 'OWNERSHIP_CONFLICT', severity: 'blocking', operationId, message: `${dbtModel} is already owned by ${existing.domain}; import cannot move it implicitly.` });
|
|
16827
|
+
continue;
|
|
16828
|
+
}
|
|
16829
|
+
const id = existing?.localId ?? allocate(String(model.name ?? nodes.find((node) => node.uniqueId === dbtModel)?.name ?? `model_${index + 1}`));
|
|
16830
|
+
boundEntityByDbtId.set(dbtModel, { id, operationId });
|
|
16831
|
+
// MetricFlow states its own join graph in `entities:`; keep the primary
|
|
16832
|
+
// key so the semantic branch can pair it with foreign references below.
|
|
16833
|
+
const semanticEntities = contextArray(model.entities).map(contextRecord);
|
|
16834
|
+
const primary = semanticEntities.find((entry) => entry.type === 'primary');
|
|
16835
|
+
if (primary?.name)
|
|
16836
|
+
semanticPrimaryByName.set(String(primary.name), { dbtModel, column: String(primary.expr ?? primary.name) });
|
|
16837
|
+
for (const entry of semanticEntities) {
|
|
16838
|
+
if (entry.type !== 'foreign' || !entry.name)
|
|
16839
|
+
continue;
|
|
16840
|
+
semanticForeignRefs.push({ name: String(entry.name), column: String(entry.expr ?? entry.name), dbtModel, operationId });
|
|
16841
|
+
}
|
|
16842
|
+
const aggTimeDimension = contextRecord(model.defaults).agg_time_dimension;
|
|
16843
|
+
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)] } : {}) } } });
|
|
16844
|
+
}
|
|
16845
|
+
}
|
|
16846
|
+
}
|
|
16847
|
+
// REL-004: pair MetricFlow foreign references against the primary entity of
|
|
16848
|
+
// the same name. This is dbt's own declared join graph, so it needs no
|
|
16849
|
+
// guessing — but it is still only a draft candidate (REL-001).
|
|
16850
|
+
for (const reference of semanticForeignRefs) {
|
|
16851
|
+
const primary = semanticPrimaryByName.get(reference.name);
|
|
16852
|
+
if (!primary || primary.dbtModel === reference.dbtModel)
|
|
16853
|
+
continue;
|
|
16854
|
+
const from = boundEntityByDbtId.get(reference.dbtModel);
|
|
16855
|
+
const to = boundEntityByDbtId.get(primary.dbtModel);
|
|
16856
|
+
if (!from || !to)
|
|
16857
|
+
continue;
|
|
16858
|
+
operations.push({
|
|
16859
|
+
id: `semantic-entity:${from.id}:${to.id}:${reference.name}`,
|
|
16860
|
+
kind: 'modeling_change',
|
|
16861
|
+
dependsOn: [...new Set([from.operationId, to.operationId])],
|
|
16862
|
+
evidence: [`MetricFlow entity "${reference.name}" declared foreign in ${reference.dbtModel} and primary in ${primary.dbtModel}`],
|
|
16863
|
+
change: {
|
|
16864
|
+
operation: 'upsert_relationship',
|
|
16865
|
+
value: {
|
|
16866
|
+
id: `${from.id}_to_${to.id}`, domain, areaId: area?.localId ?? areaId,
|
|
16867
|
+
from: from.id, to: to.id,
|
|
16868
|
+
keys: [{ from: reference.column, to: primary.column }],
|
|
16869
|
+
cardinality: 'many_to_one', fanout: 'unknown', status: 'draft',
|
|
16870
|
+
verb: reference.name,
|
|
16871
|
+
rationale: 'Declared by the MetricFlow semantic entity graph. Validate against the warehouse before certifying.',
|
|
16872
|
+
},
|
|
16873
|
+
},
|
|
16874
|
+
});
|
|
16875
|
+
}
|
|
16876
|
+
// REL-004: dbt `relationships` tests state an exact key pair and are already
|
|
16877
|
+
// enforced by `dbt test`, which makes them the strongest available candidate.
|
|
16878
|
+
// They remain drafts: a dbt test is evidence, never join authorization.
|
|
16879
|
+
const manifestPath = manifest.dbtProvenance?.manifestPath;
|
|
16880
|
+
if (manifestPath && boundEntityByDbtId.size > 1) {
|
|
16881
|
+
let rawManifest;
|
|
16882
|
+
try {
|
|
16883
|
+
rawManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
16884
|
+
}
|
|
16885
|
+
catch {
|
|
16886
|
+
rawManifest = undefined;
|
|
16887
|
+
}
|
|
16888
|
+
if (rawManifest) {
|
|
16889
|
+
for (const edge of collectDbtRelationshipTests(rawManifest, new Set(boundEntityByDbtId.keys()))) {
|
|
16890
|
+
if (edge.fromColumn === 'unknown' || edge.toColumn === 'unknown')
|
|
16891
|
+
continue;
|
|
16892
|
+
const from = boundEntityByDbtId.get(edge.fromDbtUniqueId);
|
|
16893
|
+
const to = boundEntityByDbtId.get(edge.toDbtUniqueId);
|
|
16894
|
+
if (!from || !to || from.id === to.id)
|
|
16895
|
+
continue;
|
|
16896
|
+
const id = `${from.id}_to_${to.id}`;
|
|
16897
|
+
if (operations.some((operation) => operation.kind === 'modeling_change' && operation.change.operation === 'upsert_relationship' && operation.change.value.id === id))
|
|
16898
|
+
continue;
|
|
16899
|
+
operations.push({
|
|
16900
|
+
id: `dbt-test:${edge.testUniqueId}`,
|
|
16901
|
+
kind: 'modeling_change',
|
|
16902
|
+
dependsOn: [...new Set([from.operationId, to.operationId])],
|
|
16903
|
+
evidence: [edge.testUniqueId, ...(edge.sourcePath ? [edge.sourcePath] : [])],
|
|
16904
|
+
change: {
|
|
16905
|
+
operation: 'upsert_relationship',
|
|
16906
|
+
value: {
|
|
16907
|
+
id, domain, areaId: area?.localId ?? areaId,
|
|
16908
|
+
from: from.id, to: to.id,
|
|
16909
|
+
keys: [{ from: edge.fromColumn, to: edge.toColumn }],
|
|
16910
|
+
cardinality: 'many_to_one', fanout: 'unknown', status: 'draft',
|
|
16911
|
+
rationale: `Declared by the dbt relationships test ${edge.testUniqueId}. Validate against the warehouse before certifying.`,
|
|
16912
|
+
},
|
|
16913
|
+
},
|
|
16914
|
+
});
|
|
16915
|
+
}
|
|
16916
|
+
}
|
|
16917
|
+
}
|
|
16918
|
+
if (!operations.length)
|
|
16919
|
+
throw Object.assign(new Error('The selected YAML did not produce any resolvable modeling changes.'), { code: 'MODELING_IMPORT_EMPTY', diagnostics });
|
|
16920
|
+
return { operations, diagnostics };
|
|
16921
|
+
}
|
|
16922
|
+
function contextRecord(value) {
|
|
16923
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
16924
|
+
}
|
|
16925
|
+
function contextArray(value) {
|
|
16926
|
+
return Array.isArray(value) ? value : [];
|
|
16927
|
+
}
|
|
16928
|
+
function contextStrings(value) {
|
|
16929
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
16930
|
+
}
|
|
16931
|
+
function parseImportedAnalyticalRole(value) {
|
|
16932
|
+
return value === 'event' || value === 'dimension' || value === 'snapshot' || value === 'bridge' ? value : 'unknown';
|
|
16933
|
+
}
|
|
16934
|
+
function parseImportedCardinality(value) {
|
|
16935
|
+
return value === 'one_to_one' || value === 'one_to_many' || value === 'many_to_one' || value === 'many_to_many' ? value : 'unknown';
|
|
16936
|
+
}
|
|
16937
|
+
function parseImportedFanout(value) {
|
|
16938
|
+
if (value === 'unsafe' || value === 'forbidden')
|
|
16939
|
+
return 'forbidden';
|
|
16940
|
+
return value === 'safe' || value === 'dedupe_required' || value === 'attribution_required' ? value : 'unknown';
|
|
16941
|
+
}
|
|
16942
|
+
function contextSourceReferenceExists(manifest, reference) {
|
|
16943
|
+
if (!reference.includes('::entity::') && !reference.includes('::relationship::'))
|
|
16944
|
+
return true;
|
|
16945
|
+
return Object.values(manifest.modeling?.entities ?? {}).some((entity) => entity.qualifiedId === reference)
|
|
16946
|
+
|| Object.values(manifest.modeling?.relationships ?? {}).some((relationship) => relationship.qualifiedId === reference);
|
|
16947
|
+
}
|
|
16948
|
+
function parseContextAuthoringOrigin(value) {
|
|
16949
|
+
if (value === 'manual' || value === 'yaml_import' || value === 'dbt_discovery' || value === 'ai' || value === 'correction')
|
|
16950
|
+
return value;
|
|
16951
|
+
throw Object.assign(new Error('A valid context proposal origin is required.'), { code: 'INVALID_REQUEST' });
|
|
16952
|
+
}
|
|
16953
|
+
function parseContextAuthoringOperations(value) {
|
|
16954
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 100)
|
|
16955
|
+
throw Object.assign(new Error('Provide 1-100 authoring operations.'), { code: 'INVALID_REQUEST' });
|
|
16956
|
+
const ids = new Set();
|
|
16957
|
+
return value.map((raw, index) => {
|
|
16958
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
16959
|
+
throw Object.assign(new Error(`Operation ${index + 1} is invalid.`), { code: 'INVALID_REQUEST' });
|
|
16960
|
+
const operation = raw;
|
|
16961
|
+
const id = typeof operation.id === 'string' ? operation.id.trim() : '';
|
|
16962
|
+
if (!id || ids.has(id))
|
|
16963
|
+
throw Object.assign(new Error(`Operation ${index + 1} requires a unique id.`), { code: 'INVALID_REQUEST' });
|
|
16964
|
+
ids.add(id);
|
|
16965
|
+
const dependsOn = Array.isArray(operation.dependsOn) ? operation.dependsOn.filter((item) => typeof item === 'string') : undefined;
|
|
16966
|
+
const evidence = Array.isArray(operation.evidence) ? operation.evidence.filter((item) => typeof item === 'string').slice(0, 32) : undefined;
|
|
16967
|
+
if (operation.kind === 'modeling_change') {
|
|
16968
|
+
const change = operation.change;
|
|
16969
|
+
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']);
|
|
16970
|
+
if (!change || typeof change !== 'object' || !allowed.has(change.operation) || !('value' in change) || !change.value || typeof change.value !== 'object')
|
|
16971
|
+
throw Object.assign(new Error(`Modeling operation ${id} is invalid.`), { code: 'INVALID_REQUEST' });
|
|
16972
|
+
return { id, kind: 'modeling_change', change, dependsOn, evidence };
|
|
16973
|
+
}
|
|
16974
|
+
if (operation.kind === 'skill_change') {
|
|
16975
|
+
const skillOperation = operation.operation;
|
|
16976
|
+
const skill = operation.value;
|
|
16977
|
+
if ((skillOperation !== 'create' && skillOperation !== 'update' && skillOperation !== 'move') || !skill || typeof skill.id !== 'string' || !skill.id.trim() || typeof skill.body !== 'string')
|
|
16978
|
+
throw Object.assign(new Error(`Skill operation ${id} is invalid.`), { code: 'INVALID_REQUEST' });
|
|
16979
|
+
return {
|
|
16980
|
+
id,
|
|
16981
|
+
kind: 'skill_change',
|
|
16982
|
+
operation: skillOperation,
|
|
16983
|
+
value: { ...skill, scope: 'project', status: 'draft' },
|
|
16984
|
+
targetQualifiedId: typeof operation.targetQualifiedId === 'string' ? operation.targetQualifiedId : undefined,
|
|
16985
|
+
expectedSourceHash: typeof operation.expectedSourceHash === 'string' ? operation.expectedSourceHash : undefined,
|
|
16986
|
+
dependsOn,
|
|
16987
|
+
evidence,
|
|
16988
|
+
};
|
|
16989
|
+
}
|
|
16990
|
+
if (operation.kind === 'dbt_source_change') {
|
|
16991
|
+
const change = operation.change;
|
|
16992
|
+
if (!change || typeof change.uniqueId !== 'string' || !change.uniqueId.trim())
|
|
16993
|
+
throw Object.assign(new Error(`dbt source operation ${id} is invalid.`), { code: 'INVALID_REQUEST' });
|
|
16994
|
+
return { id, kind: 'dbt_source_change', change, dependsOn, evidence };
|
|
16995
|
+
}
|
|
16996
|
+
throw Object.assign(new Error(`Unsupported context operation kind for ${id}.`), { code: 'INVALID_REQUEST' });
|
|
16997
|
+
});
|
|
16998
|
+
}
|
|
16999
|
+
function resolveContextPatchPath(projectRoot, gitRoot, relativePath) {
|
|
17000
|
+
const allowedRoot = resolve(gitRoot ?? projectRoot);
|
|
17001
|
+
const absolute = resolve(projectRoot, relativePath);
|
|
17002
|
+
if (absolute !== allowedRoot && !absolute.startsWith(`${allowedRoot}${sep}`)) {
|
|
17003
|
+
throw Object.assign(new Error(`Proposal path escapes the Git workspace: ${relativePath}`), { code: 'OWNERSHIP_CONFLICT' });
|
|
17004
|
+
}
|
|
17005
|
+
return absolute;
|
|
17006
|
+
}
|
|
17007
|
+
/**
|
|
17008
|
+
* Modeling AI: propose modeling changes from repository metadata.
|
|
17009
|
+
*
|
|
17010
|
+
* `AGT-024` — the model receives manifest identifiers, columns, lineage, tests,
|
|
17011
|
+
* current modeling, and source locations, and never query rows. It proposes;
|
|
17012
|
+
* this function then validates every identifier against the live snapshot and
|
|
17013
|
+
* drops anything invented, so a hallucinated model or column cannot enter a
|
|
17014
|
+
* proposal. `previewContextAuthoring` independently forces `status: 'draft'`,
|
|
17015
|
+
* strips certification and validation, and blocks deletes and cross-domain
|
|
17016
|
+
* relationships, so nothing here can certify or authorize a join.
|
|
17017
|
+
*/
|
|
17018
|
+
async function buildAiModelingOperations(projectRoot, manifest, request, provider) {
|
|
17019
|
+
const modeling = manifest.modeling;
|
|
17020
|
+
if (!modeling || !manifest.dbtProvenance)
|
|
17021
|
+
throw new Error('Modeling AI requires a compiled dbt-first project snapshot.');
|
|
17022
|
+
const context = request.workspaceContext ?? {};
|
|
17023
|
+
const requestedDomain = typeof context.domain === 'string'
|
|
17024
|
+
? context.domain
|
|
17025
|
+
: request.selectedObject?.kind === 'domain' ? request.selectedObject.id : undefined;
|
|
17026
|
+
const domain = requestedDomain ?? Object.keys(modeling.packages)[0] ?? defaultAuthoringDomainId(manifest);
|
|
17027
|
+
const requestedArea = typeof context.modelAreaId === 'string' ? context.modelAreaId : typeof context.areaId === 'string' ? context.areaId : undefined;
|
|
17028
|
+
const area = Object.values(modeling.areas).find((candidate) => candidate.domain === domain
|
|
17029
|
+
&& (!requestedArea || candidate.qualifiedId === requestedArea || candidate.localId === requestedArea));
|
|
17030
|
+
const areaId = area?.localId ?? modelAreaLocalId(requestedArea) ?? DEFAULT_MODEL_AREA_ID;
|
|
17031
|
+
const bound = new Set(Object.values(modeling.entities).map((entity) => entity.dbtUniqueId));
|
|
17032
|
+
const manifestPath = resolveDbtManifestPath(projectRoot);
|
|
17033
|
+
const describe = (uniqueId) => {
|
|
17034
|
+
const detail = manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, uniqueId) : undefined;
|
|
17035
|
+
return {
|
|
17036
|
+
dbtUniqueId: uniqueId,
|
|
17037
|
+
name: manifest.dbtProvenance.nodes[uniqueId]?.name,
|
|
17038
|
+
relation: manifest.dbtProvenance.nodes[uniqueId]?.relation,
|
|
17039
|
+
description: detail?.description,
|
|
17040
|
+
columns: (detail?.columns ?? []).slice(0, 60).map((column) => ({ name: column.name, type: column.type, description: column.description })),
|
|
17041
|
+
tests: (detail?.tests ?? []).slice(0, 40),
|
|
17042
|
+
};
|
|
17043
|
+
};
|
|
17044
|
+
// Bound as candidates so a large project still fits a prompt.
|
|
17045
|
+
const unbound = Object.values(manifest.dbtProvenance.nodes).filter((node) => !bound.has(node.uniqueId));
|
|
17046
|
+
const tokens = authoringTokens(request.question);
|
|
17047
|
+
const ranked = [
|
|
17048
|
+
...unbound.filter((node) => tokens.some((token) => `${node.name} ${node.uniqueId} ${node.sourcePath ?? ''}`.toLowerCase().includes(token))),
|
|
17049
|
+
...unbound,
|
|
17050
|
+
].filter((node, index, values) => values.findIndex((other) => other.uniqueId === node.uniqueId) === index).slice(0, 24);
|
|
17051
|
+
const payload = {
|
|
17052
|
+
request: request.question.trim(),
|
|
17053
|
+
targetDomain: domain,
|
|
17054
|
+
targetAreaId: areaId,
|
|
17055
|
+
currentModels: Object.values(modeling.entities).filter((entity) => entity.domain === domain).map((entity) => ({
|
|
17056
|
+
id: entity.localId, dbtUniqueId: entity.dbtUniqueId, businessName: entity.businessName,
|
|
17057
|
+
businessContext: entity.businessContext, grain: entity.grain, keys: entity.keys, analyticalRole: entity.analyticalRole,
|
|
17058
|
+
})),
|
|
17059
|
+
currentRelationships: Object.values(modeling.relationships).filter((relationship) => relationship.ownerDomain === domain).map((relationship) => ({
|
|
17060
|
+
id: relationship.localId, from: modeling.entities[relationship.from]?.localId, to: modeling.entities[relationship.to]?.localId,
|
|
17061
|
+
keys: relationship.keys, cardinality: relationship.cardinality, fanout: relationship.fanout, status: relationship.status,
|
|
17062
|
+
})),
|
|
17063
|
+
availableDbtModels: ranked.map((node) => describe(node.uniqueId)),
|
|
17064
|
+
focusedModel: request.selectedObject?.kind === 'model'
|
|
17065
|
+
? Object.values(modeling.entities).find((entity) => entity.qualifiedId === request.selectedObject.id)
|
|
17066
|
+
: undefined,
|
|
17067
|
+
};
|
|
17068
|
+
let raw;
|
|
17069
|
+
try {
|
|
17070
|
+
raw = await provider.generate([
|
|
17071
|
+
{
|
|
17072
|
+
role: 'system',
|
|
17073
|
+
content: [
|
|
17074
|
+
'You design analytical data models. Propose modeling changes for review; you are never applying them.',
|
|
17075
|
+
'Return exactly one JSON object in a ```json fence: {"operations":[...]}. No prose outside the fence.',
|
|
17076
|
+
'Each operation is {"kind":"upsert_entity","id":"<snake_case>","dbtModel":"<exact dbt unique id>","businessName":"...","businessContext":"...","grain":"...","keys":["..."],"analyticalRole":"event|dimension|snapshot|bridge|unknown"}',
|
|
17077
|
+
'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":"..."}.',
|
|
17078
|
+
'Use ONLY dbt unique ids and column names present in the supplied metadata. Never invent an identifier.',
|
|
17079
|
+
'businessContext states what one row means in business terms. Never restate the request back as business context.',
|
|
17080
|
+
'Set cardinality and fanout from the evidence; use "unknown" when the metadata does not establish them. Never claim a relationship is certified or validated.',
|
|
17081
|
+
'Do not propose deletions, cross-domain relationships, or changes to dbt-owned SQL, columns, tests, or descriptions.',
|
|
17082
|
+
].join(' '),
|
|
17083
|
+
},
|
|
17084
|
+
{ role: 'user', content: JSON.stringify(payload) },
|
|
17085
|
+
], { maxTokens: 3000, temperature: 0 });
|
|
17086
|
+
}
|
|
17087
|
+
catch (error) {
|
|
17088
|
+
throw new Error(`The AI provider could not complete this modeling request: ${error instanceof Error ? error.message : String(error)}`);
|
|
17089
|
+
}
|
|
17090
|
+
const parsed = parseAiModelingOperations(raw);
|
|
17091
|
+
if (parsed.length === 0)
|
|
17092
|
+
throw new Error('Modeling AI did not return any usable modeling changes. Try naming the models or the relationship you want.');
|
|
17093
|
+
// Validate every identifier against the live snapshot. An operation that
|
|
17094
|
+
// references something the project does not have is dropped, not repaired.
|
|
17095
|
+
const knownDbtIds = new Set(Object.keys(manifest.dbtProvenance.nodes));
|
|
17096
|
+
const columnsFor = new Map();
|
|
17097
|
+
const proposedEntityIds = new Set(Object.values(modeling.entities).filter((entity) => entity.domain === domain).map((entity) => entity.localId));
|
|
17098
|
+
const operations = [];
|
|
17099
|
+
for (const candidate of parsed) {
|
|
17100
|
+
if (candidate.kind === 'upsert_entity') {
|
|
17101
|
+
if (!candidate.dbtModel || !knownDbtIds.has(candidate.dbtModel))
|
|
17102
|
+
continue;
|
|
17103
|
+
const id = authoringSlug(candidate.id || candidate.dbtModel.split('.').at(-1) || 'model').replace(/-/g, '_');
|
|
17104
|
+
if (!id)
|
|
17105
|
+
continue;
|
|
17106
|
+
proposedEntityIds.add(id);
|
|
17107
|
+
const detail = manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, candidate.dbtModel) : undefined;
|
|
17108
|
+
const columns = new Set((detail?.columns ?? []).map((column) => column.name.toLowerCase()));
|
|
17109
|
+
columnsFor.set(id, columns);
|
|
17110
|
+
operations.push({
|
|
17111
|
+
id: `entity:${domain}:${id}`,
|
|
17112
|
+
kind: 'modeling_change',
|
|
17113
|
+
evidence: [candidate.dbtModel, ...(detail?.sourcePath ? [detail.sourcePath] : [])],
|
|
17114
|
+
change: {
|
|
17115
|
+
operation: 'upsert_entity',
|
|
17116
|
+
value: {
|
|
17117
|
+
id, domain, areaId, dbtModel: candidate.dbtModel,
|
|
17118
|
+
businessName: candidate.businessName?.trim() || titleCaseAuthoring(id),
|
|
17119
|
+
businessContext: candidate.businessContext?.trim() || undefined,
|
|
17120
|
+
grain: candidate.grain && columns.has(candidate.grain.toLowerCase()) ? candidate.grain : undefined,
|
|
17121
|
+
keys: (candidate.keys ?? []).filter((key) => columns.has(key.toLowerCase())),
|
|
17122
|
+
analyticalRole: parseImportedAnalyticalRole(candidate.analyticalRole) ?? 'unknown',
|
|
17123
|
+
status: 'draft',
|
|
17124
|
+
},
|
|
17125
|
+
},
|
|
17126
|
+
});
|
|
17127
|
+
continue;
|
|
17128
|
+
}
|
|
17129
|
+
const from = authoringSlug(candidate.from ?? '').replace(/-/g, '_');
|
|
17130
|
+
const to = authoringSlug(candidate.to ?? '').replace(/-/g, '_');
|
|
17131
|
+
if (!proposedEntityIds.has(from) || !proposedEntityIds.has(to) || from === to)
|
|
17132
|
+
continue;
|
|
17133
|
+
const knownColumns = (entityId) => {
|
|
17134
|
+
const cached = columnsFor.get(entityId);
|
|
17135
|
+
if (cached)
|
|
17136
|
+
return cached;
|
|
17137
|
+
const entity = Object.values(modeling.entities).find((item) => item.domain === domain && item.localId === entityId);
|
|
17138
|
+
const detail = entity && manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, entity.dbtUniqueId) : undefined;
|
|
17139
|
+
const columns = new Set((detail?.columns ?? []).map((column) => column.name.toLowerCase()));
|
|
17140
|
+
columnsFor.set(entityId, columns);
|
|
17141
|
+
return columns;
|
|
17142
|
+
};
|
|
17143
|
+
const fromColumns = knownColumns(from);
|
|
17144
|
+
const toColumns = knownColumns(to);
|
|
17145
|
+
const keys = (candidate.keys ?? []).filter((key) => key?.from && key?.to && fromColumns.has(key.from.toLowerCase()) && toColumns.has(key.to.toLowerCase()));
|
|
17146
|
+
if (keys.length === 0)
|
|
17147
|
+
continue;
|
|
17148
|
+
const id = authoringSlug(candidate.id || `${from}_to_${to}`).replace(/-/g, '_');
|
|
17149
|
+
operations.push({
|
|
17150
|
+
id: `relationship:${domain}:${id}`,
|
|
17151
|
+
kind: 'modeling_change',
|
|
17152
|
+
dependsOn: [`entity:${domain}:${from}`, `entity:${domain}:${to}`].filter((dependency) => operations.some((operation) => operation.id === dependency)),
|
|
17153
|
+
evidence: ['Modeling AI proposal from dbt metadata; requires warehouse validation before certification'],
|
|
17154
|
+
change: {
|
|
17155
|
+
operation: 'upsert_relationship',
|
|
17156
|
+
value: {
|
|
17157
|
+
id, domain, areaId, from, to, keys,
|
|
17158
|
+
cardinality: parseImportedCardinality(candidate.cardinality),
|
|
17159
|
+
fanout: parseImportedFanout(candidate.fanout),
|
|
17160
|
+
status: 'draft',
|
|
17161
|
+
verb: candidate.verb?.trim() || undefined,
|
|
17162
|
+
description: candidate.description?.trim() || undefined,
|
|
17163
|
+
rationale: candidate.rationale?.trim() || undefined,
|
|
17164
|
+
},
|
|
17165
|
+
},
|
|
17166
|
+
});
|
|
17167
|
+
}
|
|
17168
|
+
if (operations.length === 0)
|
|
17169
|
+
throw new Error('Modeling AI referenced models or columns that are not in the current dbt snapshot, so nothing was proposed.');
|
|
17170
|
+
return operations;
|
|
17171
|
+
}
|
|
17172
|
+
/** Tolerant JSON extraction; a malformed provider reply yields no operations. */
|
|
17173
|
+
export function parseAiModelingOperations(raw) {
|
|
17174
|
+
const fenced = raw.match(/```json\s*([\s\S]*?)```/i)?.[1] ?? raw.match(/```\s*([\s\S]*?)```/)?.[1] ?? raw;
|
|
17175
|
+
let parsed;
|
|
17176
|
+
try {
|
|
17177
|
+
parsed = JSON.parse(fenced.trim());
|
|
17178
|
+
}
|
|
17179
|
+
catch {
|
|
17180
|
+
return [];
|
|
17181
|
+
}
|
|
17182
|
+
const text = (value) => (typeof value === 'string' ? value : undefined);
|
|
17183
|
+
return contextArray(contextRecord(parsed).operations).flatMap((value) => {
|
|
17184
|
+
const record = contextRecord(value);
|
|
17185
|
+
if (record.kind === 'upsert_entity') {
|
|
17186
|
+
return [{
|
|
17187
|
+
kind: 'upsert_entity',
|
|
17188
|
+
id: text(record.id),
|
|
17189
|
+
dbtModel: text(record.dbtModel),
|
|
17190
|
+
businessName: text(record.businessName),
|
|
17191
|
+
businessContext: text(record.businessContext),
|
|
17192
|
+
grain: text(record.grain),
|
|
17193
|
+
analyticalRole: record.analyticalRole,
|
|
17194
|
+
keys: contextStrings(record.keys),
|
|
17195
|
+
}];
|
|
17196
|
+
}
|
|
17197
|
+
if (record.kind === 'upsert_relationship') {
|
|
17198
|
+
return [{
|
|
17199
|
+
kind: 'upsert_relationship',
|
|
17200
|
+
id: text(record.id),
|
|
17201
|
+
from: text(record.from),
|
|
17202
|
+
to: text(record.to),
|
|
17203
|
+
verb: text(record.verb),
|
|
17204
|
+
description: text(record.description),
|
|
17205
|
+
rationale: text(record.rationale),
|
|
17206
|
+
cardinality: record.cardinality,
|
|
17207
|
+
fanout: record.fanout,
|
|
17208
|
+
keys: contextArray(record.keys).flatMap((entry) => {
|
|
17209
|
+
const key = contextRecord(entry);
|
|
17210
|
+
return typeof key.from === 'string' && typeof key.to === 'string' ? [{ from: key.from, to: key.to }] : [];
|
|
17211
|
+
}),
|
|
17212
|
+
}];
|
|
17213
|
+
}
|
|
17214
|
+
return [];
|
|
17215
|
+
});
|
|
17216
|
+
}
|
|
17217
|
+
/**
|
|
17218
|
+
* Evidence-only modeling suggestions: deterministic, no provider required.
|
|
17219
|
+
*
|
|
17220
|
+
* This is the `AGT-024` provider-unavailable fallback, not "Modeling AI". It
|
|
17221
|
+
* matches dbt model names against the request and pairs shared `*_id` columns.
|
|
17222
|
+
* Callers must label its output honestly so a user is never told a template
|
|
17223
|
+
* expansion was an AI proposal.
|
|
17224
|
+
*/
|
|
17225
|
+
function buildDeterministicModelingOperations(projectRoot, manifest, request) {
|
|
17226
|
+
const modeling = manifest.modeling;
|
|
17227
|
+
if (!modeling || !manifest.dbtProvenance)
|
|
17228
|
+
throw new Error('Modeling AI requires a compiled dbt-first project snapshot.');
|
|
17229
|
+
const context = request.workspaceContext ?? {};
|
|
17230
|
+
const requestedDomain = typeof context.domain === 'string'
|
|
17231
|
+
? context.domain
|
|
17232
|
+
: request.selectedObject?.kind === 'domain'
|
|
17233
|
+
? request.selectedObject.id
|
|
17234
|
+
: undefined;
|
|
17235
|
+
const domain = requestedDomain ?? Object.keys(modeling.packages)[0];
|
|
17236
|
+
if (!domain || !modeling.packages[domain])
|
|
17237
|
+
throw new Error('Select or create a Domain before building models with AI.');
|
|
17238
|
+
const selectedIdentity = request.selectedObject?.id;
|
|
17239
|
+
const selectedRelationship = request.selectedObject?.kind === 'relationship'
|
|
17240
|
+
? Object.values(modeling.relationships).find((item) => item.qualifiedId === selectedIdentity || item.id === selectedIdentity || item.localId === selectedIdentity)
|
|
17241
|
+
: undefined;
|
|
17242
|
+
if (selectedRelationship) {
|
|
17243
|
+
const cardinality = /one[-_ ]to[-_ ]one|1\s*:\s*1/i.test(request.question)
|
|
17244
|
+
? 'one_to_one'
|
|
17245
|
+
: /one[-_ ]to[-_ ]many|1\s*:\s*n/i.test(request.question)
|
|
17246
|
+
? 'one_to_many'
|
|
17247
|
+
: /many[-_ ]to[-_ ]one|n\s*:\s*1/i.test(request.question)
|
|
17248
|
+
? 'many_to_one'
|
|
17249
|
+
: /many[-_ ]to[-_ ]many|n\s*:\s*n/i.test(request.question)
|
|
17250
|
+
? 'many_to_many'
|
|
17251
|
+
: selectedRelationship.cardinality;
|
|
17252
|
+
return [{
|
|
17253
|
+
id: `relationship:${selectedRelationship.qualifiedId}`,
|
|
17254
|
+
kind: 'modeling_change',
|
|
17255
|
+
evidence: [selectedRelationship.sourcePath, ...(selectedRelationship.validation?.queryFingerprint ? [`validation:${selectedRelationship.validation.queryFingerprint}`] : [])],
|
|
17256
|
+
change: {
|
|
17257
|
+
operation: 'upsert_relationship',
|
|
17258
|
+
value: {
|
|
17259
|
+
id: selectedRelationship.localId,
|
|
17260
|
+
domain: selectedRelationship.ownerDomain ?? domain,
|
|
17261
|
+
areaId: modelAreaLocalId(selectedRelationship.areaId),
|
|
17262
|
+
from: modeling.entities[selectedRelationship.from]?.localId ?? selectedRelationship.from,
|
|
17263
|
+
to: modeling.entities[selectedRelationship.to]?.localId ?? selectedRelationship.to,
|
|
17264
|
+
keys: selectedRelationship.keys,
|
|
17265
|
+
cardinality,
|
|
17266
|
+
fanout: selectedRelationship.fanout === 'unsafe' ? 'forbidden' : selectedRelationship.fanout,
|
|
17267
|
+
status: 'draft',
|
|
17268
|
+
owner: selectedRelationship.owner,
|
|
17269
|
+
ownerDomain: selectedRelationship.ownerDomain,
|
|
17270
|
+
verb: selectedRelationship.verb,
|
|
17271
|
+
description: selectedRelationship.description,
|
|
17272
|
+
rationale: request.question.trim(),
|
|
17273
|
+
roles: selectedRelationship.roles,
|
|
17274
|
+
optionality: selectedRelationship.optionality,
|
|
17275
|
+
joinTypes: selectedRelationship.joinTypes,
|
|
17276
|
+
aggregation: selectedRelationship.aggregation,
|
|
17277
|
+
temporal: selectedRelationship.temporal,
|
|
17278
|
+
attributionBlock: selectedRelationship.attributionBlock,
|
|
17279
|
+
importRefs: selectedRelationship.importRefs,
|
|
17280
|
+
evidenceExpiresAt: undefined,
|
|
17281
|
+
},
|
|
17282
|
+
},
|
|
17283
|
+
}];
|
|
17284
|
+
}
|
|
17285
|
+
const selectedEntity = request.selectedObject?.kind === 'model'
|
|
17286
|
+
? Object.values(modeling.entities).find((item) => item.qualifiedId === selectedIdentity || item.id === selectedIdentity || item.localId === selectedIdentity)
|
|
17287
|
+
: undefined;
|
|
17288
|
+
if (selectedEntity) {
|
|
17289
|
+
// Without a provider there is nothing evidence-backed to propose here.
|
|
17290
|
+
// Copying the raw request into `businessContext` would present the user's
|
|
17291
|
+
// own prompt back to them as authored business meaning.
|
|
17292
|
+
throw new Error('Writing business context needs an AI provider. Configure one in Settings, or edit this model directly in Modeling.');
|
|
17293
|
+
}
|
|
17294
|
+
const requestedArea = typeof context.modelAreaId === 'string' ? context.modelAreaId : typeof context.areaId === 'string' ? context.areaId : undefined;
|
|
17295
|
+
const existingArea = Object.values(modeling.areas).find((area) => area.domain === domain
|
|
17296
|
+
&& (!requestedArea || area.qualifiedId === requestedArea || area.localId === requestedArea));
|
|
17297
|
+
const areaId = existingArea?.localId ?? modelAreaLocalId(requestedArea) ?? `${authoringSlug(request.question).slice(0, 36) || 'metadata'}_models`;
|
|
17298
|
+
const operations = [];
|
|
17299
|
+
if (!existingArea) {
|
|
17300
|
+
operations.push({
|
|
17301
|
+
id: `area:${domain}:${areaId}`,
|
|
17302
|
+
kind: 'modeling_change',
|
|
17303
|
+
evidence: [`domain:${domain}`],
|
|
17304
|
+
change: { operation: 'upsert_area', value: { id: areaId, domain, name: titleCaseAuthoring(areaId), description: `Metadata-backed modeling area requested by the author: ${request.question.trim()}`, intentExamples: [request.question.trim()] } },
|
|
17305
|
+
});
|
|
17306
|
+
}
|
|
17307
|
+
const bound = new Set(Object.values(modeling.entities).map((entity) => entity.dbtUniqueId));
|
|
17308
|
+
const tokens = authoringTokens(request.question);
|
|
17309
|
+
let candidates = Object.values(manifest.dbtProvenance.nodes).filter((node) => !bound.has(node.uniqueId));
|
|
17310
|
+
const matched = candidates.filter((node) => tokens.some((token) => `${node.name} ${node.uniqueId} ${node.sourcePath ?? ''}`.toLowerCase().includes(token)));
|
|
17311
|
+
if (matched.length)
|
|
17312
|
+
candidates = matched;
|
|
17313
|
+
candidates = candidates.slice(0, 12);
|
|
17314
|
+
if (candidates.length === 0)
|
|
17315
|
+
throw new Error('No unbound dbt models match this request. Focus an existing model to update its business context.');
|
|
17316
|
+
const usedIds = new Set(Object.values(modeling.entities).filter((entity) => entity.domain === domain).map((entity) => entity.localId));
|
|
17317
|
+
for (const node of candidates) {
|
|
17318
|
+
const base = authoringSlug(node.name).replace(/-/g, '_') || 'model';
|
|
17319
|
+
let id = base;
|
|
17320
|
+
let suffix = 2;
|
|
17321
|
+
while (usedIds.has(id))
|
|
17322
|
+
id = `${base}_${suffix++}`;
|
|
17323
|
+
usedIds.add(id);
|
|
17324
|
+
operations.push({
|
|
17325
|
+
id: `entity:${domain}:${id}`,
|
|
17326
|
+
kind: 'modeling_change',
|
|
17327
|
+
dependsOn: existingArea ? [] : [`area:${domain}:${areaId}`],
|
|
17328
|
+
evidence: [node.uniqueId, ...(node.sourcePath ? [node.sourcePath] : [])],
|
|
17329
|
+
change: { operation: 'upsert_entity', value: { id, domain, areaId, dbtModel: node.uniqueId, businessName: titleCaseAuthoring(node.name), status: 'draft', analyticalRole: 'unknown' } },
|
|
17330
|
+
});
|
|
17331
|
+
}
|
|
17332
|
+
if (/\b(connect|relationship|join|related)\b/i.test(request.question) && candidates.length >= 2) {
|
|
17333
|
+
const manifestPath = resolveDbtManifestPath(projectRoot);
|
|
17334
|
+
const left = manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, candidates[0].uniqueId) : undefined;
|
|
17335
|
+
const right = manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, candidates[1].uniqueId) : undefined;
|
|
17336
|
+
const rightColumns = new Set((right?.columns ?? []).map((column) => column.name.toLowerCase()));
|
|
17337
|
+
const sharedKey = (left?.columns ?? []).map((column) => column.name).find((name) => /(^id$|_id$)/i.test(name) && rightColumns.has(name.toLowerCase()));
|
|
17338
|
+
if (sharedKey) {
|
|
17339
|
+
const fromOperation = operations.find((operation) => operation.id.startsWith('entity:') && operation.kind === 'modeling_change' && operation.change.operation === 'upsert_entity' && operation.change.value.dbtModel === candidates[0].uniqueId);
|
|
17340
|
+
const toOperation = operations.find((operation) => operation.id.startsWith('entity:') && operation.kind === 'modeling_change' && operation.change.operation === 'upsert_entity' && operation.change.value.dbtModel === candidates[1].uniqueId);
|
|
17341
|
+
if (fromOperation?.kind === 'modeling_change' && fromOperation.change.operation === 'upsert_entity' && toOperation?.kind === 'modeling_change' && toOperation.change.operation === 'upsert_entity') {
|
|
17342
|
+
const relationshipId = `${fromOperation.change.value.id}_to_${toOperation.change.value.id}`;
|
|
17343
|
+
operations.push({
|
|
17344
|
+
id: `relationship:${domain}:${relationshipId}`,
|
|
17345
|
+
kind: 'modeling_change',
|
|
17346
|
+
dependsOn: [fromOperation.id, toOperation.id],
|
|
17347
|
+
evidence: [`shared column suggestion:${sharedKey}`, left?.uniqueId ?? '', right?.uniqueId ?? ''].filter(Boolean),
|
|
17348
|
+
change: { operation: 'upsert_relationship', value: { id: relationshipId, domain, areaId, from: fromOperation.change.value.id, to: toOperation.change.value.id, keys: [{ from: sharedKey, to: sharedKey }], cardinality: 'unknown', fanout: 'unknown', status: 'draft', rationale: 'Candidate only: shared dbt column name requires warehouse validation.' } },
|
|
17349
|
+
});
|
|
17350
|
+
}
|
|
17351
|
+
}
|
|
17352
|
+
}
|
|
17353
|
+
return operations;
|
|
17354
|
+
}
|
|
17355
|
+
/**
|
|
17356
|
+
* Skills AI: draft a domain skill's purpose, guardrails, and vocabulary.
|
|
17357
|
+
*
|
|
17358
|
+
* `SKILL-006` — the model sees qualified modeling/metric references, the
|
|
17359
|
+
* current skill, and the Domain/Area scope. It writes prose and vocabulary
|
|
17360
|
+
* only; every governed reference is taken from the deterministic base built
|
|
17361
|
+
* below, so it cannot invent a metric, block, or model. `previewContextAuthoring`
|
|
17362
|
+
* independently forces `status: 'draft'`, so a drafted skill never guides an
|
|
17363
|
+
* agent until a human activates it.
|
|
17364
|
+
*/
|
|
17365
|
+
async function buildAiSkillOperations(projectRoot, manifest, request, provider) {
|
|
17366
|
+
const base = buildMetadataBoundSkillOperations(projectRoot, manifest, request);
|
|
17367
|
+
const operation = base[0];
|
|
17368
|
+
if (!operation || operation.kind !== 'skill_change')
|
|
17369
|
+
return base;
|
|
17370
|
+
const raw = await provider.generate([
|
|
17371
|
+
{
|
|
17372
|
+
role: 'system',
|
|
17373
|
+
content: [
|
|
17374
|
+
'You write governed analytics guidance for one business domain.',
|
|
17375
|
+
'Return exactly one JSON object in a ```json fence: {"description":"...","body":"...","triggers":["..."],"clarifyWhen":["..."],"vocabulary":{"term":"meaning"}}. No prose outside the fence.',
|
|
17376
|
+
'body is markdown with a "## Purpose" section, a "## Guardrails" section, and a "## Clarify" section.',
|
|
17377
|
+
'Write guidance an analyst would follow. Never restate the request back as the guidance.',
|
|
17378
|
+
'Never name a metric, block, model, or relationship that is not in the supplied governed references.',
|
|
17379
|
+
'Do not grant tools, authorize joins or data access, activate the skill, or write executable code.',
|
|
17380
|
+
].join(' '),
|
|
17381
|
+
},
|
|
17382
|
+
{
|
|
17383
|
+
role: 'user',
|
|
17384
|
+
content: JSON.stringify({
|
|
17385
|
+
request: request.question.trim(),
|
|
17386
|
+
domain: operation.value.domain,
|
|
17387
|
+
modelAreaRefs: operation.value.modelAreaRefs,
|
|
17388
|
+
currentSkill: { id: operation.value.id, description: operation.value.description, body: operation.value.body },
|
|
17389
|
+
governedReferences: {
|
|
17390
|
+
metrics: operation.value.preferredMetrics,
|
|
17391
|
+
blocks: operation.value.preferredBlocks,
|
|
17392
|
+
modelingRefs: operation.value.sourceRefs,
|
|
17393
|
+
},
|
|
17394
|
+
}),
|
|
17395
|
+
},
|
|
17396
|
+
], { maxTokens: 2000, temperature: 0.2 });
|
|
17397
|
+
const fenced = raw.match(/```json\s*([\s\S]*?)```/i)?.[1] ?? raw.match(/```\s*([\s\S]*?)```/)?.[1] ?? raw;
|
|
17398
|
+
let parsed;
|
|
17399
|
+
try {
|
|
17400
|
+
parsed = contextRecord(JSON.parse(fenced.trim()));
|
|
17401
|
+
}
|
|
17402
|
+
catch {
|
|
17403
|
+
return base;
|
|
17404
|
+
}
|
|
17405
|
+
const body = typeof parsed.body === 'string' ? parsed.body.trim() : '';
|
|
17406
|
+
if (!body)
|
|
17407
|
+
return base;
|
|
17408
|
+
const vocabulary = contextRecord(parsed.vocabulary);
|
|
17409
|
+
return [{
|
|
17410
|
+
...operation,
|
|
17411
|
+
evidence: [...(operation.evidence ?? []), 'drafted by Skills AI from governed references only'],
|
|
17412
|
+
value: {
|
|
17413
|
+
...operation.value,
|
|
17414
|
+
description: typeof parsed.description === 'string' && parsed.description.trim() ? parsed.description.trim() : operation.value.description,
|
|
17415
|
+
body,
|
|
17416
|
+
triggers: contextStrings(parsed.triggers).slice(0, 12).length ? contextStrings(parsed.triggers).slice(0, 12) : operation.value.triggers,
|
|
17417
|
+
clarifyWhen: contextStrings(parsed.clarifyWhen).slice(0, 8).length ? contextStrings(parsed.clarifyWhen).slice(0, 8) : operation.value.clarifyWhen,
|
|
17418
|
+
vocabulary: Object.fromEntries(Object.entries(vocabulary)
|
|
17419
|
+
.filter(([, value]) => typeof value === 'string')
|
|
17420
|
+
.slice(0, 24)),
|
|
17421
|
+
// Governed references always come from the deterministic base; the model
|
|
17422
|
+
// never gets to add one.
|
|
17423
|
+
preferredMetrics: operation.value.preferredMetrics,
|
|
17424
|
+
preferredBlocks: operation.value.preferredBlocks,
|
|
17425
|
+
sourceRefs: operation.value.sourceRefs,
|
|
17426
|
+
status: 'draft',
|
|
17427
|
+
},
|
|
17428
|
+
}];
|
|
17429
|
+
}
|
|
17430
|
+
function buildMetadataBoundSkillOperations(projectRoot, manifest, request) {
|
|
17431
|
+
const context = request.workspaceContext ?? {};
|
|
17432
|
+
const selectedIdentity = request.selectedObject?.kind === 'skill' ? request.selectedObject.id : undefined;
|
|
17433
|
+
const existing = selectedIdentity
|
|
17434
|
+
? loadSkills(projectRoot).skills.find((skill) => skill.qualifiedId === selectedIdentity || skill.id === selectedIdentity)
|
|
17435
|
+
: undefined;
|
|
17436
|
+
const domain = typeof context.domain === 'string'
|
|
17437
|
+
? context.domain
|
|
17438
|
+
: existing?.domain
|
|
17439
|
+
?? (request.selectedObject?.kind === 'domain' ? request.selectedObject.id : undefined)
|
|
17440
|
+
?? Object.keys(manifest.modeling?.packages ?? {})[0];
|
|
17441
|
+
if (!domain)
|
|
17442
|
+
throw new Error('Select a Domain before building a Skill with AI.');
|
|
17443
|
+
const area = typeof context.modelAreaId === 'string' ? context.modelAreaId : existing?.modelAreaRefs?.[0];
|
|
17444
|
+
const focusReference = request.selectedObject?.kind === 'model' || request.selectedObject?.kind === 'relationship'
|
|
17445
|
+
? request.selectedObject.id
|
|
17446
|
+
: undefined;
|
|
17447
|
+
if (focusReference && !contextSourceReferenceExists(manifest, focusReference))
|
|
17448
|
+
throw new Error('The focused modeling object is not present in the current snapshot.');
|
|
17449
|
+
const id = existing?.localId ?? existing?.id ?? `${authoringSlug(request.question).slice(0, 48) || domain.replace(/[^a-z0-9]+/gi, '-')}-guidance`;
|
|
17450
|
+
const body = existing
|
|
17451
|
+
? `${existing.body.trim()}\n\n## Proposed correction\n${request.question.trim()}\n`
|
|
17452
|
+
: `## Purpose\nGuide analysis for ${domain} using reviewed repository metadata.\n\n## Guardrails\n${request.question.trim()}\n\n## Clarify\nAsk when the requested metric, model, or relationship is not governed.`;
|
|
17453
|
+
const value = {
|
|
17454
|
+
id,
|
|
17455
|
+
qualifiedId: existing?.qualifiedId,
|
|
17456
|
+
sourcePath: existing?.sourcePath,
|
|
17457
|
+
scope: 'project',
|
|
17458
|
+
domain,
|
|
17459
|
+
domains: [domain],
|
|
17460
|
+
modelAreaRefs: area ? [area] : [],
|
|
17461
|
+
kind: existing?.kind ?? 'domain_reference',
|
|
17462
|
+
status: 'draft',
|
|
17463
|
+
owner: existing?.owner,
|
|
17464
|
+
triggers: existing?.triggers ?? authoringTokens(request.question).slice(0, 8),
|
|
17465
|
+
exclusions: existing?.exclusions ?? [],
|
|
17466
|
+
description: existing?.description ?? `Metadata-backed guidance for ${titleCaseAuthoring(domain)}.`,
|
|
17467
|
+
preferredMetrics: existing?.preferredMetrics ?? Object.values(manifest.metrics ?? {}).filter((metric) => metric.domain === domain).slice(0, 12).map((metric) => metric.name),
|
|
17468
|
+
preferredBlocks: existing?.preferredBlocks ?? Object.values(manifest.blocks ?? {}).filter((block) => block.domain === domain && block.status === 'certified').slice(0, 12).map((block) => block.name),
|
|
17469
|
+
preferredDimensions: existing?.preferredDimensions ?? [],
|
|
17470
|
+
requiredFilters: existing?.requiredFilters ?? [],
|
|
17471
|
+
clarifyWhen: existing?.clarifyWhen ?? ['The request needs a model, metric, or relationship not present in the current governed context.'],
|
|
17472
|
+
examples: existing?.examples ?? [request.question.trim()],
|
|
17473
|
+
sourceRefs: [...new Set([...(existing?.sourceRefs ?? []), ...(focusReference ? [focusReference] : [])])],
|
|
17474
|
+
vocabulary: existing?.vocabulary ?? {},
|
|
17475
|
+
analyticalPolicy: existing?.analyticalPolicy,
|
|
17476
|
+
body,
|
|
17477
|
+
isStarter: existing?.isStarter,
|
|
17478
|
+
};
|
|
17479
|
+
const before = existing?.sourcePath && existsSync(existing.sourcePath) ? readFileSync(existing.sourcePath, 'utf8') : '';
|
|
17480
|
+
return [{
|
|
17481
|
+
id: `skill:${existing?.qualifiedId ?? `${domain}::skill::${id}`}`,
|
|
17482
|
+
kind: 'skill_change',
|
|
17483
|
+
operation: existing ? 'update' : 'create',
|
|
17484
|
+
targetQualifiedId: existing?.qualifiedId,
|
|
17485
|
+
expectedSourceHash: existing ? createHash('sha256').update(before).digest('hex') : undefined,
|
|
17486
|
+
evidence: [existing?.sourcePath ?? `domain:${domain}`, ...(focusReference ? [focusReference] : [])],
|
|
17487
|
+
value,
|
|
17488
|
+
}];
|
|
17489
|
+
}
|
|
17490
|
+
/**
|
|
17491
|
+
* The Domain an author lands in when none is selected: the single existing
|
|
17492
|
+
* Domain if there is exactly one, otherwise a new one named after the dbt
|
|
17493
|
+
* project. Callers keep full control — this is only the prefilled default.
|
|
17494
|
+
*/
|
|
17495
|
+
function defaultAuthoringDomainId(manifest) {
|
|
17496
|
+
const packages = Object.keys(manifest.modeling?.packages ?? {});
|
|
17497
|
+
if (packages.length === 1)
|
|
17498
|
+
return packages[0];
|
|
17499
|
+
const projectName = manifest.dbtProvenance?.projectName;
|
|
17500
|
+
return projectName ? authoringSlug(projectName).replace(/-/g, '_') || 'analytics' : 'analytics';
|
|
17501
|
+
}
|
|
17502
|
+
function authoringTokens(value) {
|
|
17503
|
+
const stop = new Set(['the', 'and', 'for', 'with', 'from', 'this', 'that', 'build', 'create', 'model', 'models', 'skill', 'please', 'into', 'about']);
|
|
17504
|
+
return [...new Set(value.toLowerCase().split(/[^a-z0-9_]+/).filter((token) => token.length > 2 && !stop.has(token)))];
|
|
17505
|
+
}
|
|
17506
|
+
function authoringSlug(value) {
|
|
17507
|
+
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
17508
|
+
}
|
|
17509
|
+
function titleCaseAuthoring(value) {
|
|
17510
|
+
return value.replace(/[._-]+/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
17511
|
+
}
|
|
17512
|
+
function contextBootstrapDir(projectRoot) {
|
|
15763
17513
|
return join(projectRoot, '.dql', 'local', 'context-bootstrap');
|
|
15764
17514
|
}
|
|
15765
17515
|
function contextBootstrapPath(projectRoot, id) {
|
|
@@ -17172,6 +18922,132 @@ export function dashboardRuntimeVariables(dashboard, overrides = {}) {
|
|
|
17172
18922
|
}
|
|
17173
18923
|
return { ...variables, ...overrides };
|
|
17174
18924
|
}
|
|
18925
|
+
/** Resolve semantic filters only through the component's explicit mapping. */
|
|
18926
|
+
export function dashboardSemanticFiltersForTile(dashboard, item, dashboardValues) {
|
|
18927
|
+
if (!item.semantic)
|
|
18928
|
+
return [];
|
|
18929
|
+
return (dashboard.filters ?? []).flatMap((filter) => {
|
|
18930
|
+
if (filter.scope?.tileIds && !filter.scope.tileIds.includes(item.i))
|
|
18931
|
+
return [];
|
|
18932
|
+
const binding = item.filterBindings?.find((candidate) => candidate.filter === filter.id
|
|
18933
|
+
&& candidate.capability !== 'unsupported'
|
|
18934
|
+
&& !candidate.unsupportedReason);
|
|
18935
|
+
const dimension = binding?.binding;
|
|
18936
|
+
const value = dashboardValues[filter.id];
|
|
18937
|
+
if (!dimension || !item.semantic?.dimensions?.includes(dimension) || isEmptyDashboardFilterValue(value))
|
|
18938
|
+
return [];
|
|
18939
|
+
const values = Array.isArray(value) ? value.map(String) : [String(value)];
|
|
18940
|
+
return [{ dimension, operator: values.length > 1 ? 'in' : 'equals', values }];
|
|
18941
|
+
});
|
|
18942
|
+
}
|
|
18943
|
+
/**
|
|
18944
|
+
* Return bounded, run-scoped filter choices only from columns the server has
|
|
18945
|
+
* already proven safe for predicate filtering. These sampled labels are sent
|
|
18946
|
+
* to the browser for a searchable control and are never written to `.dqld`.
|
|
18947
|
+
*/
|
|
18948
|
+
export function collectDashboardFilterOptions(dashboard, tiles, defaultLimit = 100) {
|
|
18949
|
+
const items = new Map(dashboard.layout.items.map((item) => [item.i, item]));
|
|
18950
|
+
const optionSets = [];
|
|
18951
|
+
for (const filter of dashboard.filters ?? []) {
|
|
18952
|
+
if (filter.type === 'boolean' || filter.type === 'relative_date' || filter.type === 'number_range')
|
|
18953
|
+
continue;
|
|
18954
|
+
const dateControl = filter.type === 'daterange' || filter.type === 'date';
|
|
18955
|
+
const limit = Math.max(1, Math.min(100, filter.optionSource?.limit ?? defaultLimit));
|
|
18956
|
+
const values = new Set();
|
|
18957
|
+
const sourceTileIds = [];
|
|
18958
|
+
let truncated = false;
|
|
18959
|
+
for (const tile of tiles) {
|
|
18960
|
+
const result = dashboardFilterOptionResult(tile.result);
|
|
18961
|
+
if (tile.status !== 'ok' || !result?.rows.length)
|
|
18962
|
+
continue;
|
|
18963
|
+
const item = items.get(tile.tileId);
|
|
18964
|
+
const binding = item?.filterBindings?.find((candidate) => candidate.filter === filter.id && candidate.capability !== 'unsupported');
|
|
18965
|
+
if (!binding)
|
|
18966
|
+
continue;
|
|
18967
|
+
const requestedColumn = dashboardOutputColumn(binding?.binding ?? filter.field?.name ?? filter.bindsTo ?? filter.id);
|
|
18968
|
+
if (!requestedColumn)
|
|
18969
|
+
continue;
|
|
18970
|
+
const safeColumn = tile.filterableColumns?.find((candidate) => normalizeDashboardOptionColumn(candidate.column) === normalizeDashboardOptionColumn(requestedColumn));
|
|
18971
|
+
if (!safeColumn)
|
|
18972
|
+
continue;
|
|
18973
|
+
const resultColumn = result.columns.find((column) => normalizeDashboardOptionColumn(column) === normalizeDashboardOptionColumn(safeColumn.column));
|
|
18974
|
+
if (!resultColumn)
|
|
18975
|
+
continue;
|
|
18976
|
+
sourceTileIds.push(tile.tileId);
|
|
18977
|
+
for (const row of result.rows) {
|
|
18978
|
+
const value = dashboardFilterOptionValue(row[resultColumn]);
|
|
18979
|
+
if (value !== null)
|
|
18980
|
+
values.add(value);
|
|
18981
|
+
}
|
|
18982
|
+
if (result.truncated || (result.rowCount ?? result.rows.length) > result.rows.length)
|
|
18983
|
+
truncated = true;
|
|
18984
|
+
}
|
|
18985
|
+
if (values.size === 0) {
|
|
18986
|
+
if (dateControl) {
|
|
18987
|
+
optionSets.push({
|
|
18988
|
+
filterId: filter.id,
|
|
18989
|
+
values: [],
|
|
18990
|
+
truncated,
|
|
18991
|
+
sourceTileIds: Array.from(new Set(sourceTileIds)),
|
|
18992
|
+
valueCount: 0,
|
|
18993
|
+
});
|
|
18994
|
+
}
|
|
18995
|
+
continue;
|
|
18996
|
+
}
|
|
18997
|
+
const sorted = Array.from(values).sort((left, right) => left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' }));
|
|
18998
|
+
const dateValues = dateControl
|
|
18999
|
+
? sorted
|
|
19000
|
+
.map((value) => ({ value, timestamp: Date.parse(value) }))
|
|
19001
|
+
.filter((item) => Number.isFinite(item.timestamp))
|
|
19002
|
+
.sort((left, right) => left.timestamp - right.timestamp)
|
|
19003
|
+
: [];
|
|
19004
|
+
optionSets.push({
|
|
19005
|
+
filterId: filter.id,
|
|
19006
|
+
values: dateControl ? [] : sorted.slice(0, limit),
|
|
19007
|
+
truncated: dateControl ? truncated : truncated || sorted.length > limit,
|
|
19008
|
+
sourceTileIds: Array.from(new Set(sourceTileIds)),
|
|
19009
|
+
...(dateControl ? {
|
|
19010
|
+
valueCount: dateValues.length,
|
|
19011
|
+
...(dateValues.length ? {
|
|
19012
|
+
dateRange: {
|
|
19013
|
+
min: new Date(dateValues[0].timestamp).toISOString().slice(0, 10),
|
|
19014
|
+
max: new Date(dateValues[dateValues.length - 1].timestamp).toISOString().slice(0, 10),
|
|
19015
|
+
},
|
|
19016
|
+
} : {}),
|
|
19017
|
+
} : {}),
|
|
19018
|
+
});
|
|
19019
|
+
}
|
|
19020
|
+
return optionSets;
|
|
19021
|
+
}
|
|
19022
|
+
function dashboardFilterOptionResult(value) {
|
|
19023
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
19024
|
+
return null;
|
|
19025
|
+
const record = value;
|
|
19026
|
+
if (!Array.isArray(record.columns) || !record.columns.every((column) => typeof column === 'string'))
|
|
19027
|
+
return null;
|
|
19028
|
+
if (!Array.isArray(record.rows) || !record.rows.every((row) => Boolean(row) && typeof row === 'object' && !Array.isArray(row)))
|
|
19029
|
+
return null;
|
|
19030
|
+
return {
|
|
19031
|
+
columns: record.columns,
|
|
19032
|
+
rows: record.rows,
|
|
19033
|
+
...(typeof record.rowCount === 'number' ? { rowCount: record.rowCount } : {}),
|
|
19034
|
+
...(typeof record.truncated === 'boolean' ? { truncated: record.truncated } : {}),
|
|
19035
|
+
};
|
|
19036
|
+
}
|
|
19037
|
+
function normalizeDashboardOptionColumn(value) {
|
|
19038
|
+
return value.replace(/[`"[\]]/g, '').trim().split('.').filter(Boolean).at(-1)?.toLowerCase() ?? '';
|
|
19039
|
+
}
|
|
19040
|
+
function dashboardFilterOptionValue(value) {
|
|
19041
|
+
if (typeof value === 'string')
|
|
19042
|
+
return value.trim() ? value : null;
|
|
19043
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
19044
|
+
return String(value);
|
|
19045
|
+
if (typeof value === 'boolean')
|
|
19046
|
+
return String(value);
|
|
19047
|
+
if (value instanceof Date && Number.isFinite(value.getTime()))
|
|
19048
|
+
return value.toISOString();
|
|
19049
|
+
return null;
|
|
19050
|
+
}
|
|
17175
19051
|
/**
|
|
17176
19052
|
* Resolve only declared tile parameter bindings. Dashboard filters remain
|
|
17177
19053
|
* values; they never become SQL identifiers or an implicit name-based binding.
|
|
@@ -17212,6 +19088,10 @@ export function applyDashboardFiltersToBlockExecution(input) {
|
|
|
17212
19088
|
const clauses = [];
|
|
17213
19089
|
let nextPosition = sqlParams.reduce((max, param) => Math.max(max, param.position), 0);
|
|
17214
19090
|
for (const filter of input.dashboard.filters ?? []) {
|
|
19091
|
+
if (filter.scope?.tileIds && input.tileId && !filter.scope.tileIds.includes(input.tileId)) {
|
|
19092
|
+
skippedFilters.push({ filter: filter.id, reason: 'component is outside this filter scope' });
|
|
19093
|
+
continue;
|
|
19094
|
+
}
|
|
17215
19095
|
const value = dashboardFilterValue(filter, variables);
|
|
17216
19096
|
if (isEmptyDashboardFilterValue(value)) {
|
|
17217
19097
|
skippedFilters.push({ filter: filter.id, reason: 'no value supplied' });
|
|
@@ -17226,7 +19106,7 @@ export function applyDashboardFiltersToBlockExecution(input) {
|
|
|
17226
19106
|
});
|
|
17227
19107
|
continue;
|
|
17228
19108
|
}
|
|
17229
|
-
const binding = resolveDashboardFilterBinding(filter, input.block);
|
|
19109
|
+
const binding = resolveDashboardFilterBinding(filter, input.block, input.tileFilterBindings);
|
|
17230
19110
|
if (!binding) {
|
|
17231
19111
|
skippedFilters.push({ filter: filter.id, reason: `block "${input.block.name}" does not declare a compatible filter binding` });
|
|
17232
19112
|
continue;
|
|
@@ -17348,8 +19228,16 @@ function bindDashboardFilterToExistingParams(filter, value, block, sqlParams, va
|
|
|
17348
19228
|
}
|
|
17349
19229
|
return [];
|
|
17350
19230
|
}
|
|
17351
|
-
function resolveDashboardFilterBinding(filter, block) {
|
|
19231
|
+
function resolveDashboardFilterBinding(filter, block, tileFilterBindings = []) {
|
|
17352
19232
|
const candidates = uniqueDashboardStrings([filter.id, filter.bindsTo ?? '']).map(normalizeDashboardFilterName);
|
|
19233
|
+
for (const entry of tileFilterBindings ?? []) {
|
|
19234
|
+
const mode = entry.mode ?? (entry.binding ? 'predicate' : undefined);
|
|
19235
|
+
if (mode === 'predicate'
|
|
19236
|
+
&& entry.binding
|
|
19237
|
+
&& !entry.unsupportedReason
|
|
19238
|
+
&& candidates.includes(normalizeDashboardFilterName(entry.filter)))
|
|
19239
|
+
return entry.binding;
|
|
19240
|
+
}
|
|
17353
19241
|
for (const entry of block.filterBindings ?? []) {
|
|
17354
19242
|
if (candidates.includes(normalizeDashboardFilterName(entry.filter)))
|
|
17355
19243
|
return entry.binding;
|
|
@@ -17393,6 +19281,13 @@ function buildDashboardFilterPredicate(input) {
|
|
|
17393
19281
|
const values = Array.isArray(input.value) ? input.value.filter((item) => !isEmptyDashboardFilterValue(item)) : [input.value];
|
|
17394
19282
|
if (values.length === 0)
|
|
17395
19283
|
return null;
|
|
19284
|
+
if (input.filterType === 'search' && values.length === 1) {
|
|
19285
|
+
const search = String(values[0] ?? '').trim();
|
|
19286
|
+
if (!search)
|
|
19287
|
+
return null;
|
|
19288
|
+
const param = addDashboardFilterParam(input, 'contains', `%${search}%`);
|
|
19289
|
+
return `LOWER(${input.expression}) LIKE LOWER($${param.position})`;
|
|
19290
|
+
}
|
|
17396
19291
|
if (values.length === 1) {
|
|
17397
19292
|
const param = addDashboardFilterParam(input, 'value', values[0]);
|
|
17398
19293
|
return `${input.expression} = $${param.position}`;
|
|
@@ -18815,6 +20710,22 @@ export function openBlockStudioDocument(projectRoot, relativePath, semanticLayer
|
|
|
18815
20710
|
validation: validateBlockStudioSource(source, semanticLayer),
|
|
18816
20711
|
};
|
|
18817
20712
|
}
|
|
20713
|
+
const BLOCK_STUDIO_DRAFT_RUNTIME_DIAGNOSTICS = new Set([
|
|
20714
|
+
'metricflow_unavailable',
|
|
20715
|
+
'semantic_compose_failed',
|
|
20716
|
+
'semantic_layer_missing',
|
|
20717
|
+
'semantic_runtime_required',
|
|
20718
|
+
]);
|
|
20719
|
+
/**
|
|
20720
|
+
* Draft persistence is stricter than loose text storage but does not require a
|
|
20721
|
+
* configured execution runtime. Syntax, unsafe SQL, invalid semantic identity,
|
|
20722
|
+
* and parameter-contract errors must be corrected before a block file is
|
|
20723
|
+
* written; runtime availability remains a Run/Certify concern.
|
|
20724
|
+
*/
|
|
20725
|
+
export function blockStudioValidationAllowsDraftSave(validation) {
|
|
20726
|
+
return !validation.diagnostics.some((diagnostic) => diagnostic.severity === 'error'
|
|
20727
|
+
&& !BLOCK_STUDIO_DRAFT_RUNTIME_DIAGNOSTICS.has(diagnostic.code ?? ''));
|
|
20728
|
+
}
|
|
18818
20729
|
/**
|
|
18819
20730
|
* Replace the CONTENTS of every string literal with same-length filler, keeping
|
|
18820
20731
|
* the quotes and every offset intact.
|
|
@@ -19422,17 +21333,33 @@ export function validateBlockStudioSource(source, semanticLayer) {
|
|
|
19422
21333
|
});
|
|
19423
21334
|
}
|
|
19424
21335
|
}
|
|
19425
|
-
|
|
19426
|
-
|
|
19427
|
-
|
|
21336
|
+
let parameters = [];
|
|
21337
|
+
try {
|
|
21338
|
+
const parameterInvocation = prepareBlockInvocation({ source, surface: 'block_studio' });
|
|
21339
|
+
parameters = parameterInvocation.parameters;
|
|
21340
|
+
if (parameterInvocation.errors.length > 0) {
|
|
21341
|
+
diagnostics.push(...parameterInvocation.errors.map((message) => ({ severity: 'error', code: 'parameter_parse', message })));
|
|
21342
|
+
}
|
|
21343
|
+
}
|
|
21344
|
+
catch (error) {
|
|
21345
|
+
// The main parser diagnostic above owns malformed source. Parameter
|
|
21346
|
+
// extraction must never turn a recoverable validation response into a 500.
|
|
21347
|
+
if (!diagnostics.some((diagnostic) => diagnostic.code === 'syntax')) {
|
|
21348
|
+
diagnostics.push({
|
|
21349
|
+
severity: 'error',
|
|
21350
|
+
code: 'parameter_parse',
|
|
21351
|
+
message: error instanceof Error ? error.message : String(error),
|
|
21352
|
+
});
|
|
21353
|
+
}
|
|
19428
21354
|
}
|
|
19429
21355
|
return {
|
|
19430
21356
|
valid: diagnostics.every((diagnostic) => diagnostic.severity !== 'error'),
|
|
21357
|
+
saveable: blockStudioValidationAllowsDraftSave({ diagnostics }),
|
|
19431
21358
|
diagnostics,
|
|
19432
21359
|
semanticRefs,
|
|
19433
21360
|
chartConfig: chartConfig ?? undefined,
|
|
19434
21361
|
executableSql,
|
|
19435
|
-
parameters
|
|
21362
|
+
parameters,
|
|
19436
21363
|
};
|
|
19437
21364
|
}
|
|
19438
21365
|
export async function createDqlArtifactGenerationSessionForProject(projectRoot, options, semanticLayer) {
|