@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.
Files changed (99) hide show
  1. package/dist/apps-api.d.ts +225 -5
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +1950 -117
  4. package/dist/apps-api.js.map +1 -1
  5. package/dist/args.d.ts +2 -0
  6. package/dist/args.d.ts.map +1 -1
  7. package/dist/args.js +3 -0
  8. package/dist/args.js.map +1 -1
  9. package/dist/assets/dql-notebook/assets/{AgentLogPage-CBGEoGmU.js → AgentLogPage-BlUE3qsX.js} +1 -1
  10. package/dist/assets/dql-notebook/assets/{AiBuildDialog-CwAD3tXE.js → AiBuildDialog-O88iK6UW.js} +1 -1
  11. package/dist/assets/dql-notebook/assets/AiBuildResult-D-6G4Sh3.js +33 -0
  12. package/dist/assets/dql-notebook/assets/{AiSidePanel-DjB8PWHG.js → AiSidePanel-MRSGtQDV.js} +1 -1
  13. package/dist/assets/dql-notebook/assets/AnalyticsHome-CcRVO6zN.js +6 -0
  14. package/dist/assets/dql-notebook/assets/AppsView-BTkGC-wl.js +5588 -0
  15. package/dist/assets/dql-notebook/assets/BlockStudio-BrTNaDpb.js +55 -0
  16. package/dist/assets/dql-notebook/assets/{BusinessArtifactView-C4SZVNW-.js → BusinessArtifactView-itEIUrYy.js} +1 -1
  17. package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-CbAqOpNc.js +22 -0
  18. package/dist/assets/dql-notebook/assets/{GitPage-BfVD4tVq.js → GitPage-BxOqRYu2.js} +1 -1
  19. package/dist/assets/dql-notebook/assets/GlobalAiRail-Tfkx76CI.js +1 -0
  20. package/dist/assets/dql-notebook/assets/GovernedContextPage-CeaDbepP.js +7 -0
  21. package/dist/assets/dql-notebook/assets/{HelpDocsPage-BDPNVot1.js → HelpDocsPage-D1lAryYv.js} +1 -1
  22. package/dist/assets/dql-notebook/assets/{HomePage-DiOnRhEG.js → HomePage-GB6se6xW.js} +1 -1
  23. package/dist/assets/dql-notebook/assets/{LineageDAG-BiUFX2KL.js → LineageDAG-zCyN-hdg.js} +1 -1
  24. package/dist/assets/dql-notebook/assets/{LineageDetailView-CfbSvlPJ.js → LineageDetailView-Cg0TgG2Y.js} +1 -1
  25. package/dist/assets/dql-notebook/assets/{LineageDrawer-CgTScaGB.js → LineageDrawer-DwMcenDf.js} +1 -1
  26. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-D7ZA7k68.js → LineagePathBreadcrumb-DUZx3ksU.js} +1 -1
  27. package/dist/assets/dql-notebook/assets/{MiniLineageGraph-GkD4y17x.js → MiniLineageGraph-DT_ycl6s.js} +1 -1
  28. package/dist/assets/dql-notebook/assets/{NewBlockModal-B9TfRjt7.js → NewBlockModal-qdhzeJPF.js} +1 -1
  29. package/dist/assets/dql-notebook/assets/{NewNotebookModal-BbO9NKUl.js → NewNotebookModal-BnaDzs1A.js} +1 -1
  30. package/dist/assets/dql-notebook/assets/{NotebookEditor-CuMpvXR9.js → NotebookEditor-CouPFzdo.js} +26 -31
  31. package/dist/assets/dql-notebook/assets/{ReadinessPage-BROJ9YBM.js → ReadinessPage-DB_KbH9Y.js} +1 -1
  32. package/dist/assets/dql-notebook/assets/{SetupOnboarding-BCcZlpuQ.js → SetupOnboarding-CamQq_N2.js} +1 -1
  33. package/dist/assets/dql-notebook/assets/SkillsPage--mYkOAK2.js +10 -0
  34. package/dist/assets/dql-notebook/assets/{TrustBadge-vQQh8d0D.js → TrustBadge-ClEYiYeA.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-DBfsmXes.js +88 -0
  36. package/dist/assets/dql-notebook/assets/{answer-to-notebook-DuqCcDDl.js → answer-to-notebook-Cg3UyGwh.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{arrow-left-CMe1kkOL.js → arrow-left-DnYPGqlr.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/{arrow-right-DvB_DONr.js → arrow-right-Ct_YGrmU.js} +1 -1
  39. package/dist/assets/dql-notebook/assets/{book-open-text-CJ3NAvrs.js → book-open-text-DULlwbj6.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/{circle-x-BCgtha1W.js → circle-x-ByYxoiS9.js} +1 -1
  41. package/dist/assets/dql-notebook/assets/{dagre.esm-GKp7qoUL.js → dagre.esm-BjYSO53F.js} +1 -1
  42. package/dist/assets/dql-notebook/assets/{external-link-C-PXokWV.js → external-link-DprWh9Yf.js} +1 -1
  43. package/dist/assets/dql-notebook/assets/{grip-vertical-B4EHbGXW.js → grip-vertical-CrEEYkXN.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/{index-1mIC_XX4.js → index-9w93YPF9.js} +127 -126
  45. package/dist/assets/dql-notebook/assets/index-B3shyZsg.css +1 -0
  46. package/dist/assets/dql-notebook/assets/{link-2-BLx-goet.js → link-2-BkW-rkEC.js} +1 -1
  47. package/dist/assets/dql-notebook/assets/{list-tree-BRFpEmvV.js → list-tree-BipBLmIE.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{minimize-2-CDwSCGlO.js → minimize-2-BhXsglrO.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{panel-right-open-CunA1xO7.js → panel-right-open-BoCSilYB.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{bot-CV8DYRrt.js → play-mdoZawPu.js} +7 -2
  51. package/dist/assets/dql-notebook/assets/{rotate-ccw-DOrfTZk8.js → rotate-ccw-ELiDRUmj.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/{semantic-fields-AfQvjt5R.js → semantic-fields-DVNRLfeU.js} +1 -1
  53. package/dist/assets/dql-notebook/assets/sliders-horizontal-GeUzpmKz.js +6 -0
  54. package/dist/assets/dql-notebook/assets/{star-DDhv4e8M.js → star-D63O81Wp.js} +1 -1
  55. package/dist/assets/dql-notebook/assets/{triangle-alert-2ZXx8wAu.js → triangle-alert-BWrCxQVE.js} +1 -1
  56. package/dist/assets/dql-notebook/assets/upload-CI-ldXhC.js +6 -0
  57. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Cv7Yq2hK.js +32 -0
  58. package/dist/assets/dql-notebook/assets/{user-round-BA074kiQ.js → user-round-Do21aFGo.js} +1 -1
  59. package/dist/assets/dql-notebook/assets/{wand-sparkles-CEYHLt9b.js → wand-sparkles-DT7u7F_x.js} +1 -1
  60. package/dist/assets/dql-notebook/assets/{workflow-CZtyrcR-.js → workflow-DHh2mMbn.js} +1 -1
  61. package/dist/assets/dql-notebook/assets/{wrench-Ong23-wu.js → wrench-7tYr_GKb.js} +1 -1
  62. package/dist/assets/dql-notebook/assets/{x-CSo1M3tu.js → x-CcYceFMz.js} +1 -1
  63. package/dist/assets/dql-notebook/index.html +2 -2
  64. package/dist/commands/app.d.ts.map +1 -1
  65. package/dist/commands/app.js +65 -133
  66. package/dist/commands/app.js.map +1 -1
  67. package/dist/commands/model.d.ts.map +1 -1
  68. package/dist/commands/model.js +83 -3
  69. package/dist/commands/model.js.map +1 -1
  70. package/dist/index.js +3 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/llm/providers/dql-agent-provider.d.ts +16 -0
  73. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  74. package/dist/llm/providers/dql-agent-provider.js +65 -2
  75. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  76. package/dist/local-runtime.d.ts +75 -0
  77. package/dist/local-runtime.d.ts.map +1 -1
  78. package/dist/local-runtime.js +2015 -88
  79. package/dist/local-runtime.js.map +1 -1
  80. package/dist/package.json +10 -10
  81. package/dist/sql-result-filter.d.ts +9 -0
  82. package/dist/sql-result-filter.d.ts.map +1 -1
  83. package/dist/sql-result-filter.js +25 -0
  84. package/dist/sql-result-filter.js.map +1 -1
  85. package/package.json +10 -10
  86. package/dist/assets/dql-notebook/assets/AgentAnswerCard-Cp0eh6zU.js +0 -5
  87. package/dist/assets/dql-notebook/assets/AiBuildResult-CyqTuQe5.js +0 -33
  88. package/dist/assets/dql-notebook/assets/AnalyticsHome-B7BkvA-R.js +0 -6
  89. package/dist/assets/dql-notebook/assets/AppsView-CUrqtEGC.js +0 -4583
  90. package/dist/assets/dql-notebook/assets/BlockStudio-Bl0aGQJR.js +0 -60
  91. package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-B0uxitnx.js +0 -22
  92. package/dist/assets/dql-notebook/assets/GlobalAiRail-DUwdy3fB.js +0 -1
  93. package/dist/assets/dql-notebook/assets/GovernedContextPage-D9f26ZFb.js +0 -7
  94. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-Dj4AXAhT.js +0 -83
  95. package/dist/assets/dql-notebook/assets/chevron-up-HxU1l85K.js +0 -6
  96. package/dist/assets/dql-notebook/assets/index-DKu4CZt3.css +0 -1
  97. package/dist/assets/dql-notebook/assets/maximize-2-CCd5uRIZ.js +0 -6
  98. package/dist/assets/dql-notebook/assets/pencil-D_I14Wyk.js +0 -6
  99. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-3rzkZrqc.js +0 -18
@@ -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
- const AGENT_RUN_REQUESTED_MODES = new Set(['auto', 'ask', 'research', 'sql', 'block', 'app']);
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
- ? [{ id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' }]
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 cell.', 409);
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-cell size limit.', 422);
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({ projectRoot, dqlVersion: 'notebook' });
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 appId = decodeURIComponent(appDashRun[1]);
8584
- const dashboardId = decodeURIComponent(appDashRun[2]);
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 = loadAppDashboard(projectRoot, appId, dashboardId);
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 draftPath = join(loaded.appDir, item.draftAnalysis.ref);
8725
- draftAppRelativePath = relative(loaded.appDir, draftPath);
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 = Object.entries(dashboardVariables)
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
- tiles,
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') || path.startsWith('/api/visualizations') || path === '/api/persona') {
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 without starting Notebook AI research.
13565
- * The returned source is applied only to the in-memory notebook cell by the
13566
- * client; this endpoint never writes a block/domain/dbt source file.
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
- function contextBootstrapDir(projectRoot) {
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
- const parameterInvocation = prepareBlockInvocation({ source, surface: 'block_studio' });
19426
- if (parameterInvocation.errors.length > 0) {
19427
- diagnostics.push(...parameterInvocation.errors.map((message) => ({ severity: 'error', code: 'parameter_parse', message })));
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: parameterInvocation.parameters,
21362
+ parameters,
19436
21363
  };
19437
21364
  }
19438
21365
  export async function createDqlArtifactGenerationSessionForProject(projectRoot, options, semanticLayer) {