@duckcodeailabs/dql-cli 1.12.11 → 1.13.1

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 (107) hide show
  1. package/dist/apps-api.d.ts +151 -3
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +675 -97
  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/AgentAnswerCard-c8QUo6NH.js +5 -0
  10. package/dist/assets/dql-notebook/assets/{AgentLogPage-hzHqpD3-.js → AgentLogPage-D6fgEAEy.js} +1 -1
  11. package/dist/assets/dql-notebook/assets/{AiBuildDialog-BQRWoxp9.js → AiBuildDialog-8miXFPse.js} +1 -1
  12. package/dist/assets/dql-notebook/assets/{AiBuildResult-D9t_4sQl.js → AiBuildResult-DPJ_SI0R.js} +3 -3
  13. package/dist/assets/dql-notebook/assets/AiSidePanel-NVA6lZnn.js +6 -0
  14. package/dist/assets/dql-notebook/assets/AnalyticsHome-DgwnRW71.js +6 -0
  15. package/dist/assets/dql-notebook/assets/AppsView-B6LYUpgm.js +4578 -0
  16. package/dist/assets/dql-notebook/assets/BlockStudio--jNw9XUI.js +60 -0
  17. package/dist/assets/dql-notebook/assets/{BusinessArtifactView-CnTbFVGT.js → BusinessArtifactView-DtRMiaF4.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-DhBsLCYr.js +22 -0
  19. package/dist/assets/dql-notebook/assets/GitPage-CazC-eTR.js +7 -0
  20. package/dist/assets/dql-notebook/assets/GlobalAiRail-1Tgweuu2.js +1 -0
  21. package/dist/assets/dql-notebook/assets/GovernedContextPage-C7qGrH3d.js +16 -0
  22. package/dist/assets/dql-notebook/assets/{HelpDocsPage-CL0vbSER.js → HelpDocsPage-DMHPsVqP.js} +1 -1
  23. package/dist/assets/dql-notebook/assets/{HomePage-DzAucwBW.js → HomePage-DcSI4dnU.js} +1 -1
  24. package/dist/assets/dql-notebook/assets/LineageDAG-BJ6xz06u.js +1 -0
  25. package/dist/assets/dql-notebook/assets/{LineageDetailView-DdVH-nz6.js → LineageDetailView-8WrQm-MF.js} +1 -1
  26. package/dist/assets/dql-notebook/assets/{LineageDrawer-BvrVZNKY.js → LineageDrawer-hnqCW_t-.js} +1 -1
  27. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-CSut-zvm.js → LineagePathBreadcrumb-B4LZTUsl.js} +3 -3
  28. package/dist/assets/dql-notebook/assets/{MiniLineageGraph-zg_1wW3M.js → MiniLineageGraph-CotnRuxa.js} +1 -1
  29. package/dist/assets/dql-notebook/assets/{NewBlockModal-9cDletl-.js → NewBlockModal-DHn-CEEW.js} +1 -1
  30. package/dist/assets/dql-notebook/assets/{NewNotebookModal-V6de8L4d.js → NewNotebookModal-BPGp-8oq.js} +2 -2
  31. package/dist/assets/dql-notebook/assets/NotebookEditor-B0OTHM6W.js +195 -0
  32. package/dist/assets/dql-notebook/assets/ReadinessPage-CoOIpxWq.js +1 -0
  33. package/dist/assets/dql-notebook/assets/{SetupOnboarding-Bg-MjbC9.js → SetupOnboarding-DxK_mFzf.js} +2 -2
  34. package/dist/assets/dql-notebook/assets/{TrustBadge-DGuHB8kP.js → TrustBadge-ZuD5N5Ww.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-duFXXNV_.js +88 -0
  36. package/dist/assets/dql-notebook/assets/{answer-to-notebook-BDzY4MuQ.js → answer-to-notebook-WAVAYjBv.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{arrow-left-IjlnAihE.js → arrow-left-L8odLbyN.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/{arrow-right-BI4cXgOY.js → arrow-right-WIF1V9a9.js} +1 -1
  39. package/dist/assets/dql-notebook/assets/{book-open-text-i6xhxu_I.js → book-open-text-BpqWMa4m.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/{bot-C6DE7Q6m.js → bot-CLR1V6dO.js} +1 -1
  41. package/dist/assets/dql-notebook/assets/{circle-x-BoAorVSm.js → circle-x-CQBOM0b0.js} +1 -1
  42. package/dist/assets/dql-notebook/assets/{dagre.esm-CycymVj6.js → dagre.esm-DlZi7NR3.js} +1 -1
  43. package/dist/assets/dql-notebook/assets/{external-link-ZwE5iHsX.js → external-link-DYqdQIGp.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/{grip-vertical-Dnp2st8l.js → grip-vertical-HmUijbwN.js} +1 -1
  45. package/dist/assets/dql-notebook/assets/index-Cpx47jCH.js +634 -0
  46. package/dist/assets/dql-notebook/assets/{list-tree-Ced8e_S1.js → list-tree-DGKqCxmz.js} +1 -1
  47. package/dist/assets/dql-notebook/assets/{minimize-2-CBIAE0XH.js → minimize-2-C8dvqCxX.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{panel-right-open-po3bWSeL.js → panel-right-open-yuVkIo5n.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{rotate-ccw-B9v1LTJ0.js → rotate-ccw-Di0kRnrM.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{semantic-fields-B6ii0an0.js → semantic-fields-BMQV0li8.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/sliders-horizontal-alebptlJ.js +6 -0
  52. package/dist/assets/dql-notebook/assets/{star-CO4tfaDq.js → star-CgCQbnXc.js} +1 -1
  53. package/dist/assets/dql-notebook/assets/{triangle-alert-BSdIu2El.js → triangle-alert-SyuSZ4ht.js} +1 -1
  54. package/dist/assets/dql-notebook/assets/upload-BPhfea_E.js +11 -0
  55. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Bnl9gWhj.js +28 -0
  56. package/dist/assets/dql-notebook/assets/{user-round-BfrV8U-W.js → user-round-J4PQHxo9.js} +1 -1
  57. package/dist/assets/dql-notebook/assets/{wand-sparkles-BgTxZNBc.js → wand-sparkles-c4WDXTCk.js} +1 -1
  58. package/dist/assets/dql-notebook/assets/{workflow--2ycp0yy.js → workflow-NtRLaq7n.js} +1 -1
  59. package/dist/assets/dql-notebook/assets/{wrench-LMCrpa9_.js → wrench-CS1f1Zv4.js} +1 -1
  60. package/dist/assets/dql-notebook/assets/{x-CeNgQwGm.js → x-CbKEfe5m.js} +1 -1
  61. package/dist/assets/dql-notebook/index.html +1 -1
  62. package/dist/commands/model.d.ts.map +1 -1
  63. package/dist/commands/model.js +62 -1
  64. package/dist/commands/model.js.map +1 -1
  65. package/dist/index.js +3 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/llm/providers/dql-agent-provider.d.ts +4 -3
  68. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  69. package/dist/llm/providers/dql-agent-provider.js +13 -10
  70. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  71. package/dist/local-operation-coordinator.d.ts +59 -0
  72. package/dist/local-operation-coordinator.d.ts.map +1 -0
  73. package/dist/local-operation-coordinator.js +256 -0
  74. package/dist/local-operation-coordinator.js.map +1 -0
  75. package/dist/local-runtime.d.ts +59 -1
  76. package/dist/local-runtime.d.ts.map +1 -1
  77. package/dist/local-runtime.js +2653 -342
  78. package/dist/local-runtime.js.map +1 -1
  79. package/dist/package.json +12 -11
  80. package/dist/project-refresh-coordinator.d.ts +19 -0
  81. package/dist/project-refresh-coordinator.d.ts.map +1 -0
  82. package/dist/project-refresh-coordinator.js +134 -0
  83. package/dist/project-refresh-coordinator.js.map +1 -0
  84. package/dist/project-refresh-worker.d.ts +13 -0
  85. package/dist/project-refresh-worker.d.ts.map +1 -0
  86. package/dist/project-refresh-worker.js +58 -0
  87. package/dist/project-refresh-worker.js.map +1 -0
  88. package/package.json +13 -11
  89. package/dist/assets/dql-notebook/assets/AgentAnswerCard-CpL2kDCV.js +0 -5
  90. package/dist/assets/dql-notebook/assets/AiSidePanel-BCQB9rVF.js +0 -6
  91. package/dist/assets/dql-notebook/assets/AnalyticsHome-Ba4Ve_Gd.js +0 -6
  92. package/dist/assets/dql-notebook/assets/AppsView-CdAT9BOq.js +0 -4466
  93. package/dist/assets/dql-notebook/assets/BlockStudio-Brg4fwn5.js +0 -60
  94. package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-CQe2FUZq.js +0 -22
  95. package/dist/assets/dql-notebook/assets/GitPage-DHyDBChc.js +0 -7
  96. package/dist/assets/dql-notebook/assets/GlobalAiRail-Cys4n_rq.js +0 -1
  97. package/dist/assets/dql-notebook/assets/GovernedContextPage-DyKoDSZ2.js +0 -7
  98. package/dist/assets/dql-notebook/assets/LineageDAG-QHqd_AM5.js +0 -1
  99. package/dist/assets/dql-notebook/assets/NotebookEditor-EZ0pfWVs.js +0 -200
  100. package/dist/assets/dql-notebook/assets/ReadinessPage-BaGvjyfd.js +0 -1
  101. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-BlWAVEwO.js +0 -83
  102. package/dist/assets/dql-notebook/assets/chevron-up-DSEILnDs.js +0 -6
  103. package/dist/assets/dql-notebook/assets/index-BTuliKhK.js +0 -634
  104. package/dist/assets/dql-notebook/assets/link-2-CTKe6Snr.js +0 -6
  105. package/dist/assets/dql-notebook/assets/maximize-2-Cojc6867.js +0 -6
  106. package/dist/assets/dql-notebook/assets/pencil-Cb45c6WU.js +0 -6
  107. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-CkVFrrjk.js +0 -18
@@ -1,18 +1,21 @@
1
1
  import { execFileSync, execSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { gzipSync } from "node:zlib";
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
+ import { mkdir as mkdirAsync, rename as renameAsync, rm as rmAsync, writeFile as writeFileAsync, } from 'node:fs/promises';
6
7
  import { createRequire } from "node:module";
7
8
  import { homedir } from "node:os";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import { dirname, extname, join, normalize, relative, resolve, sep, } from "node:path";
10
11
  import { Readable } from "node:stream";
12
+ import { LocalOperationCoordinator, } from './local-operation-coordinator.js';
13
+ import { ProjectRefreshCoordinator } from './project-refresh-coordinator.js';
11
14
  import { buildMixedSourceWarehouseFallbackSql, findMentionedNotebookDataset, planMixedSourceNotebookSql, planMixedSourceSql, } from "./mixed-source-sql.js";
12
15
  import { resolveNpmInvocation } from './npm-runtime.js';
13
16
  import { ensureDqlGitignore, isGovernedSourceFile, isLegacyBroadDqlIgnore, } from './git-contract.js';
14
17
  import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, hasStandaloneSemanticRef, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
15
- 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, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, semanticDimensionReference, } from '@duckcodeailabs/dql-core';
16
19
  import { load as loadYaml } from 'js-yaml';
17
20
  import { listBlockTemplates } from './block-templates.js';
18
21
  import { getRunner as getLLMRunner } from './llm/index.js';
@@ -21,10 +24,10 @@ import { fetchLatestPublishedDqlVersion, resolveDqlRuntimeVersionStatus } from '
21
24
  import { resolveRetrievalHealthStatus } from './retrieval-health.js';
22
25
  import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
23
26
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
24
- 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';
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';
25
28
  import { addSqlResultFilter, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
26
29
  import { gatherProposeEnrichment } from './propose-enrich.js';
27
- import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
30
+ import { handleAppsApi, proposeAppAiBuild, recommendVisualization, } from './apps-api.js';
28
31
  import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
29
32
  import { ClaudeCodeCliProvider, CodexCliProvider } from './providers/subscription-cli.js';
30
33
  import { ClaudeOAuthManager, ClaudeOAuthProvider, claudeOAuthConnected, CLAUDE_OAUTH_MODELS, CLAUDE_OAUTH_DEFAULT_MODEL, } from './providers/oauth/claude-oauth.js';
@@ -85,6 +88,63 @@ function hasDbtSemanticArtifacts(projectRoot, dbtProjectDir, configuredManifestP
85
88
  return false;
86
89
  }
87
90
  const AGENT_RUN_REQUESTED_MODES = new Set(['auto', 'ask', 'research', 'sql', 'block', 'app']);
91
+ /** AGT-007 / AGT-022: App-specific context adapter into the uniform AgentRun. */
92
+ export function buildAppCopilotResearchAgentRequest(input) {
93
+ const memoOnly = input.mode === 'memo_only';
94
+ const appResearch = {
95
+ mode: 'app_research',
96
+ generationMode: input.mode ?? 'sql_and_memo',
97
+ intent: input.intent,
98
+ appId: input.appId,
99
+ dashboardId: input.dashboardId,
100
+ sourceTileId: input.sourceTileId,
101
+ sourceBlockId: input.sourceBlockId,
102
+ title: input.title,
103
+ instruction: [
104
+ 'Answer as an app-scoped analyst. Start from certified block/result context, active filters, dbt/semantic metadata, and schema evidence.',
105
+ memoOnly
106
+ ? 'Write the stakeholder memo from the supplied selected result, preview rows, generated SQL, metrics, drivers, filters, and caveats. Do not generate replacement SQL unless the user explicitly asks to fix SQL.'
107
+ : 'Generate review-required read-only SQL only when the certified result does not exactly answer the requested analysis grain. Execute only through the bounded generated SQL preview path.',
108
+ 'In the natural-language answer, write a stakeholder analysis memo, not a generic chat answer. Use Markdown headings chosen for the question, usually Executive answer, Key numbers, Drivers or Business readout, Caveats, and Next action.',
109
+ 'Use concrete numbers from the selected result or preview when available. If baseline, segment, grain, lineage, or source proof is missing, say that explicitly instead of inventing a driver story.',
110
+ 'Keep raw SQL in proposedSql/sql. Do not put SQL code fences or implementation trace in the memo body.',
111
+ ].join(' '),
112
+ generatedSql: input.generatedSql,
113
+ metrics: input.metrics,
114
+ drivers: input.drivers,
115
+ resultPreviews: input.resultPreviews,
116
+ summaryHint: input.summaryHint,
117
+ recommendationHint: input.recommendationHint,
118
+ sqlError: input.sqlError,
119
+ sqlErrorKind: input.sqlErrorKind,
120
+ hasReportEvidence: input.hasReportEvidence,
121
+ context: input.context,
122
+ };
123
+ return {
124
+ question: memoOnly
125
+ ? [
126
+ `Research question: ${input.question}`,
127
+ 'Write the business memo now using only the evidence envelope supplied as upstream context.',
128
+ 'Return Markdown sections. Keep SQL, query text, implementation trace, and raw routing details out of the memo body.',
129
+ 'If the evidence is insufficient, state the gap and the next reviewer action instead of filling the story with generic text.',
130
+ ].join('\n')
131
+ : input.question,
132
+ requestedMode: 'research',
133
+ audience: 'stakeholder',
134
+ selectedObject: {
135
+ kind: 'app',
136
+ id: input.appId,
137
+ title: input.title,
138
+ },
139
+ analysisDepth: 'deep',
140
+ workspaceContext: {
141
+ surface: 'app_copilot',
142
+ appId: input.appId,
143
+ dashboardId: input.dashboardId,
144
+ appResearch,
145
+ },
146
+ };
147
+ }
88
148
  const AGENT_RUN_SELECTED_OBJECT_KINDS = new Set([
89
149
  'notebook',
90
150
  'cell',
@@ -228,6 +288,7 @@ export function parseAgentRunRequestBody(body) {
228
288
  request: {
229
289
  question,
230
290
  selectedEvidenceId: agentRunString(record.selectedEvidenceId),
291
+ clarificationSourceQuestion: agentRunString(record.clarificationSourceQuestion),
231
292
  requestedMode,
232
293
  audience,
233
294
  intent: agentRunString(record.intent),
@@ -324,7 +385,16 @@ export function agentRunDeadlineMs(request, env = process.env, activeProviderId)
324
385
  if (request.requestedMode === 'research' || effectiveDepth === 'deep') {
325
386
  return scale(researchDeadline);
326
387
  }
327
- const plan = buildAnalysisQuestionPlan(request.question);
388
+ // A structured clarification chip displays a compact metric/path label, but
389
+ // the engine restores and executes the original analytical question. Budget
390
+ // from that same original question as well. Otherwise a complex ranking or
391
+ // cross-model request can incorrectly inherit the 45s scalar-lookup budget
392
+ // merely because the selected label is short, then time out after the user
393
+ // already supplied the exact governed meaning.
394
+ const deadlineQuestion = request.selectedEvidenceId
395
+ ? request.clarificationSourceQuestion?.trim() || request.question
396
+ : request.question;
397
+ const plan = buildAnalysisQuestionPlan(deadlineQuestion);
328
398
  if (plan.needsResearchWorkspace || isAnalyticalBuildQuestion(plan)) {
329
399
  return scale(researchDeadline);
330
400
  }
@@ -418,6 +488,41 @@ export function repairableSqlFromAgentRun(run) {
418
488
  }
419
489
  return undefined;
420
490
  }
491
+ /** Editable DQL retained on an immutable failed run, including its bound values. */
492
+ export function repairableDqlArtifactFromAgentRun(run) {
493
+ for (const artifact of run.artifacts) {
494
+ const payload = agentRunRecord(artifact.payload);
495
+ if (!payload)
496
+ continue;
497
+ const dqlArtifact = normalizeDqlArtifactReference(payload.dqlArtifact);
498
+ // Only an ownerless/custom SQL block is editable in place. Certified and
499
+ // semantic artifacts remain immutable; their retained compiled SQL may
500
+ // still produce a separate review-required repair result below.
501
+ if (dqlArtifact?.kind === 'sql_block'
502
+ && dqlArtifact.source?.trim()
503
+ && notebookDqlSourceAllowsBackgroundRepair(dqlArtifact.source)) {
504
+ return dqlArtifact;
505
+ }
506
+ }
507
+ return undefined;
508
+ }
509
+ /**
510
+ * Keep the parameter surface immutable while repairing malformed DQL. A parser
511
+ * cannot reliably read a broken wrapper, so collect both declared parameter
512
+ * names and SQL interpolation names with a deliberately small lexical pass.
513
+ */
514
+ export function dqlRepairParameterContract(source) {
515
+ const names = new Set();
516
+ for (const match of source.matchAll(/\$\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}/g)) {
517
+ names.add(match[1]);
518
+ }
519
+ for (const match of source.matchAll(/\bparams\s*\{([\s\S]*?)\}/gi)) {
520
+ for (const declaration of match[1].matchAll(/(?:^|\n)\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/g)) {
521
+ names.add(declaration[1]);
522
+ }
523
+ }
524
+ return [...names].sort((left, right) => left.localeCompare(right));
525
+ }
421
526
  /** Structured producer failure retained on the source run. */
422
527
  export function warehouseFailureFromAgentRun(run) {
423
528
  for (const artifact of run.artifacts) {
@@ -429,10 +534,49 @@ export function warehouseFailureFromAgentRun(run) {
429
534
  }
430
535
  return undefined;
431
536
  }
537
+ /**
538
+ * Retain planning and retrieval evidence for a successful derived repair while
539
+ * deliberately dropping the source failure and any result-bound facts. This
540
+ * is the presentation contract used by "How it was answered" and by grounded
541
+ * follow-ups after repair.
542
+ */
543
+ export function repairPresentationContextFromAgentRun(run) {
544
+ const retainedKeys = [
545
+ 'resolvedAnalyticalPlan',
546
+ 'analyticalExecutionGraph',
547
+ 'analyticalFreshnessObservation',
548
+ 'governedCompilationReceipt',
549
+ 'governedAnalyticalGraphCompilationReceipt',
550
+ 'contextPack',
551
+ 'contextPackId',
552
+ 'cascade',
553
+ 'citations',
554
+ 'lineage',
555
+ ];
556
+ const context = {};
557
+ for (const artifact of run.artifacts) {
558
+ const payload = agentRunRecord(artifact.payload);
559
+ if (!payload)
560
+ continue;
561
+ for (const key of retainedKeys) {
562
+ if (context[key] === undefined && payload[key] !== undefined)
563
+ context[key] = payload[key];
564
+ }
565
+ const semanticTrace = agentRunRecord(payload.semanticExecutionTrace);
566
+ if (context.semanticExecutionTrace === undefined && semanticTrace && !semanticTrace.failure) {
567
+ context.semanticExecutionTrace = semanticTrace;
568
+ }
569
+ }
570
+ return context;
571
+ }
432
572
  /** Permission, authentication, policy and unsafe-query failures are never AI-repaired. */
433
573
  export function analyticalFailureAllowsDeterministicRetry(failure) {
434
574
  return !failure || !['permission', 'authentication', 'unsafe', 'cancelled'].includes(failure.category);
435
575
  }
576
+ /** Structured policy/access refusals stay terminal even when retained SQL exists. */
577
+ export function analyticalFailureAllowsAppRepair(failure) {
578
+ return !failure || !['PERMISSION_DENIED', 'POLICY_DENIED'].includes(failure.code);
579
+ }
436
580
  function businessNarrativeGaps(warnings) {
437
581
  const businessRelevant = (warnings ?? []).filter((warning) => !(/missing requested output column/i.test(warning)
438
582
  || /\bquery plan\b/i.test(warning)
@@ -1011,11 +1155,11 @@ export async function startLocalServer(opts) {
1011
1155
  const resolveNamedConnection = (name) => {
1012
1156
  if (!name)
1013
1157
  return null;
1014
- const stored = getStoredConnections(projectConfig);
1015
- const normalized = normalizeStoredConnection(stored[name]);
1016
- return normalized
1017
- ? normalizeProjectConnection(normalized, projectRoot)
1018
- : null;
1158
+ // `GET /api/connections` exposes the active legacy/default connection as
1159
+ // `default` so every execution surface can present one uniform selector.
1160
+ // Resolve that selector through the same project contract used by metadata
1161
+ // APIs instead of looking only in the newer named `connections` map.
1162
+ return projectConnectionById(projectRoot, projectConfig, name);
1019
1163
  };
1020
1164
  const resolveExecutionConnection = async (body) => {
1021
1165
  const target = body.executionTarget && typeof body.executionTarget === "object"
@@ -1104,6 +1248,35 @@ export async function startLocalServer(opts) {
1104
1248
  // unchanged. Explicit refresh, save, and certification paths still request
1105
1249
  // the default forced rebuild below.
1106
1250
  await refreshLocalMetadataCatalog(projectRoot, undefined, undefined, false);
1251
+ // PERF-001 / API-001: project mutations acknowledge after their source file
1252
+ // is durable. Rebuildable manifest, lineage, metadata, and KG projections run
1253
+ // in one coalesced worker lane and remain observable across page navigation.
1254
+ const operationCoordinator = new LocalOperationCoordinator(join(projectRoot, '.dql', 'cache', 'operations.sqlite'));
1255
+ const operationSseClients = new Set();
1256
+ const operationIdempotency = new Map();
1257
+ const projectRefreshCoordinator = new ProjectRefreshCoordinator(operationCoordinator, () => {
1258
+ projectSnapshots.invalidate();
1259
+ dbtNodeDetailCache.clear();
1260
+ invalidateAgentProjectState(projectRoot);
1261
+ _lineageCache.delete(projectRoot);
1262
+ });
1263
+ const scheduleProjectRefresh = (reason, writeManifest = true) => (projectRefreshCoordinator.schedule({
1264
+ projectRoot,
1265
+ dbtManifestPath: resolveDbtManifestPath(projectRoot, projectConfig) ?? undefined,
1266
+ writeManifest,
1267
+ reason,
1268
+ }));
1269
+ const unsubscribeOperationEvents = operationCoordinator.subscribe((operation) => {
1270
+ const payload = serializeJSON(operation);
1271
+ for (const client of operationSseClients) {
1272
+ try {
1273
+ client.write(`event: operation\ndata: ${payload}\n\n`);
1274
+ }
1275
+ catch {
1276
+ operationSseClients.delete(client);
1277
+ }
1278
+ }
1279
+ });
1107
1280
  const startupDbtManifestPath = resolveDbtManifestPath(projectRoot, projectConfig);
1108
1281
  if (startupDbtManifestPath && !isAgentProjectIndexReady(projectRoot)) {
1109
1282
  try {
@@ -1185,8 +1358,8 @@ export async function startLocalServer(opts) {
1185
1358
  catch { /* non-fatal */ }
1186
1359
  }
1187
1360
  }
1188
- const executeLocalSqlForStoredResult = async (sql) => {
1189
- const activeConnection = requireActiveConnection();
1361
+ const executeLocalSqlForStoredResult = async (sql, executionConnection) => {
1362
+ const activeConnection = requireActiveConnection(executionConnection);
1190
1363
  const semantic = prepareSemanticSql(sql, semanticLayer);
1191
1364
  if (semantic.unresolvedRefs.length > 0) {
1192
1365
  throw new Error(`Unknown semantic reference${semantic.unresolvedRefs.length > 1 ? 's' : ''}: ${semantic.unresolvedRefs.join(', ')}`);
@@ -2239,7 +2412,12 @@ export async function startLocalServer(opts) {
2239
2412
  : isTerminalFailure
2240
2413
  ? terminalFailureActions
2241
2414
  : isGroundingGap
2242
- ? [{ id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' }]
2415
+ ? governedAnswer.refusalCode === 'modeling_gap'
2416
+ ? [
2417
+ { id: 'fix-modeling-gap', label: 'Fix with Modeling AI', route: 'modeling_draft', artifactKind: 'modeling_change_proposal' },
2418
+ { id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' },
2419
+ ]
2420
+ : [{ id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' }]
2243
2421
  : [
2244
2422
  { id: 'create-block', label: governedAnswer.dqlArtifact ? 'Review DQL draft' : 'Create DQL draft', route: 'dql_block_draft', artifactKind: 'dql_block_draft' },
2245
2423
  { id: 'research-gap', label: 'Research deeper', route: 'research' },
@@ -2412,11 +2590,175 @@ export async function startLocalServer(opts) {
2412
2590
  nextActions,
2413
2591
  };
2414
2592
  };
2593
+ const contextProposalStore = new FileContextAuthoringProposalStore(projectRoot);
2594
+ const previewContextAuthoring = (input) => {
2595
+ const snapshot = projectSnapshot();
2596
+ if (input.expectedSnapshotId && input.expectedSnapshotId !== snapshot.snapshotId) {
2597
+ throw Object.assign(new Error('Project sources changed before the authoring proposal was previewed.'), { code: 'PROPOSAL_STALE' });
2598
+ }
2599
+ if (input.operations.length === 0)
2600
+ throw Object.assign(new Error('At least one authoring operation is required.'), { code: 'INVALID_REQUEST' });
2601
+ const diagnostics = [...(input.diagnostics ?? [])];
2602
+ const operations = input.operations.map((operation) => {
2603
+ if (operation.kind === 'skill_change') {
2604
+ const value = { ...operation.value };
2605
+ value.status = 'draft';
2606
+ if (value.sourceRefs?.some((reference) => !contextSourceReferenceExists(snapshot.manifest, reference))) {
2607
+ 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.' });
2608
+ }
2609
+ return { ...operation, value };
2610
+ }
2611
+ if (operation.kind !== 'modeling_change')
2612
+ return operation;
2613
+ if ((input.origin === 'ai' || input.origin === 'correction') && operation.change.operation.startsWith('remove_')) {
2614
+ 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.' });
2615
+ }
2616
+ if (operation.change.operation === 'upsert_relationship') {
2617
+ const current = operation.change.value;
2618
+ if (current.crossDomain && (input.origin === 'ai' || input.origin === 'yaml_import')) {
2619
+ diagnostics.push({ code: 'CROSS_DOMAIN_IMPORT_FORBIDDEN', severity: 'blocking', operationId: operation.id, message: 'Cross-domain relationships require the explicit export, contract, and import workflow.' });
2620
+ }
2621
+ if (input.origin === 'ai' || input.origin === 'correction' || input.origin === 'yaml_import') {
2622
+ return {
2623
+ ...operation,
2624
+ change: {
2625
+ ...operation.change,
2626
+ value: {
2627
+ ...current,
2628
+ status: 'draft',
2629
+ fanout: current.fanout === 'unsafe' ? 'forbidden' : current.fanout,
2630
+ certifiedAgainst: undefined,
2631
+ validation: undefined,
2632
+ },
2633
+ },
2634
+ };
2635
+ }
2636
+ }
2637
+ if (operation.change.operation === 'upsert_entity'
2638
+ && (input.origin === 'ai' || input.origin === 'yaml_import' || input.origin === 'dbt_discovery')
2639
+ && !operation.change.value.areaId) {
2640
+ diagnostics.push({ code: 'MODEL_AREA_REQUIRED', severity: 'blocking', operationId: operation.id, message: 'Imported and AI-authored models must target one Model Area.' });
2641
+ }
2642
+ return operation;
2643
+ });
2644
+ const operationIds = new Set(operations.map((operation) => operation.id));
2645
+ for (const operation of operations) {
2646
+ for (const dependency of operation.dependsOn ?? []) {
2647
+ if (!operationIds.has(dependency))
2648
+ diagnostics.push({ code: 'DEPENDENCY_CONFLICT', severity: 'blocking', operationId: operation.id, message: `Required proposal operation is missing: ${dependency}` });
2649
+ }
2650
+ }
2651
+ const patches = [];
2652
+ const modelingOperations = operations.filter((operation) => operation.kind === 'modeling_change');
2653
+ if (modelingOperations.length > 0) {
2654
+ const batch = previewModelingChanges(projectRoot, modelingOperations.map((operation) => operation.change));
2655
+ for (const patch of batch.patches) {
2656
+ const owner = modelingOperations.find((operation) => {
2657
+ const single = previewModelingChange(projectRoot, operation.change);
2658
+ return single.patches.some((candidate) => candidate.path === patch.path);
2659
+ })?.id ?? modelingOperations[0].id;
2660
+ patches.push({ ...patch, owner: 'dql', operationId: owner });
2661
+ }
2662
+ }
2663
+ for (const operation of operations) {
2664
+ if (operation.kind === 'skill_change') {
2665
+ const preview = previewSkillChange(projectRoot, operation.value, operation.targetQualifiedId, operation.expectedSourceHash);
2666
+ for (const patch of preview.patches) {
2667
+ patches.push({
2668
+ path: relative(projectRoot, patch.path).replace(/\\/g, '/'),
2669
+ before: patch.before,
2670
+ after: patch.after,
2671
+ changed: patch.changed,
2672
+ owner: 'dql',
2673
+ operationId: operation.id,
2674
+ });
2675
+ }
2676
+ }
2677
+ else if (operation.kind === 'dbt_source_change') {
2678
+ const { dbtProjectDir, manifestPath } = onboardingDbtPaths({});
2679
+ const preview = previewDbtSourcePatch(dbtProjectDir, manifestPath, operation.change);
2680
+ patches.push({
2681
+ path: relative(projectRoot, resolve(dbtProjectDir, preview.patch.path)).replace(/\\/g, '/'),
2682
+ before: preview.patch.before,
2683
+ after: preview.patch.after,
2684
+ changed: preview.patch.changed,
2685
+ owner: 'dbt',
2686
+ operationId: operation.id,
2687
+ });
2688
+ }
2689
+ }
2690
+ const duplicatePaths = patches.map((patch) => patch.path).filter((path, index, values) => values.indexOf(path) !== index);
2691
+ for (const path of [...new Set(duplicatePaths)])
2692
+ diagnostics.push({ code: 'OWNERSHIP_CONFLICT', severity: 'blocking', message: `More than one proposal operation owns ${path}. Repreview after resolving the conflict.` });
2693
+ const dependencyFingerprints = Object.fromEntries(patches.map((patch) => [
2694
+ patch.path,
2695
+ createHash('sha256').update(patch.before).digest('hex'),
2696
+ ]));
2697
+ const proposal = buildContextAuthoringProposal({
2698
+ origin: input.origin,
2699
+ baseSnapshotId: snapshot.snapshotId,
2700
+ dependencyFingerprints,
2701
+ operations,
2702
+ patches,
2703
+ diagnostics,
2704
+ sourceRunId: input.sourceRunId,
2705
+ sourceArtifactId: input.sourceArtifactId,
2706
+ revision: input.revision,
2707
+ });
2708
+ contextProposalStore.save(proposal);
2709
+ return proposal;
2710
+ };
2711
+ const modelingAuthoringRunExecutor = async ({ runId, request }) => {
2712
+ const snapshot = projectSnapshot();
2713
+ const operations = buildMetadataBoundModelingOperations(projectRoot, snapshot.manifest, request);
2714
+ const proposal = previewContextAuthoring({
2715
+ origin: request.workspaceContext?.correction === true ? 'correction' : 'ai',
2716
+ operations,
2717
+ expectedSnapshotId: typeof request.workspaceContext?.snapshotId === 'string' ? request.workspaceContext.snapshotId : snapshot.snapshotId,
2718
+ sourceRunId: runId,
2719
+ revision: Number(request.workspaceContext?.revision ?? 1),
2720
+ });
2721
+ return {
2722
+ summary: `Prepared ${proposal.impact.modelingChanges} metadata-bound modeling change${proposal.impact.modelingChanges === 1 ? '' : 's'} for review.`,
2723
+ answer: proposal.diagnostics.some((item) => item.severity === 'blocking')
2724
+ ? 'I prepared a modeling proposal, but the blocking items must be resolved before it can be saved.'
2725
+ : 'I prepared an evidence-bound modeling proposal. Review the exact source diff before saving it as a draft.',
2726
+ status: 'needs_review',
2727
+ trustState: 'review_required',
2728
+ stopReason: 'human_review_required',
2729
+ artifacts: [{ id: `${runId}:modeling-proposal`, kind: 'modeling_change_proposal', title: 'Modeling change proposal', trustState: 'review_required', ref: proposal.id, payload: proposal }],
2730
+ evaluations: proposal.diagnostics.map((diagnostic) => ({ id: `${proposal.id}:${diagnostic.code}`, label: diagnostic.code, passed: diagnostic.severity !== 'blocking', severity: diagnostic.severity, message: diagnostic.message })),
2731
+ nextActions: [{ id: 'review-modeling-proposal', label: 'Review in Modeling', artifactKind: 'modeling_change_proposal' }],
2732
+ };
2733
+ };
2734
+ const skillAuthoringRunExecutor = async ({ runId, request }) => {
2735
+ const snapshot = projectSnapshot();
2736
+ const operations = buildMetadataBoundSkillOperations(projectRoot, snapshot.manifest, request);
2737
+ const proposal = previewContextAuthoring({
2738
+ origin: request.workspaceContext?.correction === true ? 'correction' : 'ai',
2739
+ operations,
2740
+ expectedSnapshotId: typeof request.workspaceContext?.snapshotId === 'string' ? request.workspaceContext.snapshotId : snapshot.snapshotId,
2741
+ sourceRunId: runId,
2742
+ revision: Number(request.workspaceContext?.revision ?? 1),
2743
+ });
2744
+ return {
2745
+ summary: `Prepared ${proposal.impact.skillChanges} metadata-bound Skill change${proposal.impact.skillChanges === 1 ? '' : 's'} for review.`,
2746
+ answer: 'I prepared a scope-qualified Skill draft. It will not guide an agent unless you save it and later activate it explicitly.',
2747
+ status: 'needs_review',
2748
+ trustState: 'review_required',
2749
+ stopReason: 'human_review_required',
2750
+ artifacts: [{ id: `${runId}:skill-proposal`, kind: 'skill_change_proposal', title: 'Skill change proposal', trustState: 'review_required', ref: proposal.id, payload: proposal }],
2751
+ evaluations: proposal.diagnostics.map((diagnostic) => ({ id: `${proposal.id}:${diagnostic.code}`, label: diagnostic.code, passed: diagnostic.severity !== 'blocking', severity: diagnostic.severity, message: diagnostic.message })),
2752
+ nextActions: [{ id: 'review-skill-proposal', label: 'Review Skill draft', artifactKind: 'skill_change_proposal' }],
2753
+ };
2754
+ };
2415
2755
  const agentRunExecutors = {
2416
2756
  conversation: conversationRunExecutor,
2417
2757
  certified_answer: answerRunExecutor,
2418
2758
  semantic_answer: answerRunExecutor,
2419
2759
  generated_answer: answerRunExecutor,
2760
+ modeling_draft: modelingAuthoringRunExecutor,
2761
+ skill_draft: skillAuthoringRunExecutor,
2420
2762
  research: async (researchContext) => {
2421
2763
  const { runId, request, routeDecision, emit } = researchContext;
2422
2764
  const metrics = loadSemanticMetrics(projectRoot);
@@ -2453,6 +2795,23 @@ export async function startLocalServer(opts) {
2453
2795
  const needsClarification = Boolean(plan.followUp);
2454
2796
  const notebookPath = agentRunNotebookPath(request, runId);
2455
2797
  const researchIntent = agentRunResearchIntent(request);
2798
+ const researchSource = agentRunRecord(request.workspaceContext?.researchSource);
2799
+ const researchExecutionConnection = !needsClarification && request.executionTarget
2800
+ ? await resolveAgentRunExecutionConnection(request)
2801
+ : undefined;
2802
+ const researchExecutionConnectionName = request.executionTarget?.target === 'connection'
2803
+ ? request.executionTarget.connectionName
2804
+ : undefined;
2805
+ const researchContextEnvelope = {
2806
+ surface: 'unified_agent_run',
2807
+ agentRunId: runId,
2808
+ routeDecision,
2809
+ selectedObject: request.selectedObject,
2810
+ executionTarget: request.executionTarget,
2811
+ workspaceContext: request.workspaceContext,
2812
+ researchSource,
2813
+ plan,
2814
+ };
2456
2815
  let researchRun;
2457
2816
  let researchWorkspaceError;
2458
2817
  if (!needsClarification) {
@@ -2474,14 +2833,7 @@ export async function startLocalServer(opts) {
2474
2833
  intent: researchIntent,
2475
2834
  domain: agentRunWorkspaceValue(request, 'domain'),
2476
2835
  owner: agentRunWorkspaceValue(request, 'owner'),
2477
- context: {
2478
- surface: 'unified_agent_run',
2479
- agentRunId: runId,
2480
- routeDecision,
2481
- selectedObject: request.selectedObject,
2482
- workspaceContext: request.workspaceContext,
2483
- plan,
2484
- },
2836
+ context: researchContextEnvelope,
2485
2837
  });
2486
2838
  emit({
2487
2839
  type: 'artifact.created',
@@ -2496,14 +2848,13 @@ export async function startLocalServer(opts) {
2496
2848
  sourceCellFingerprint,
2497
2849
  question: request.question,
2498
2850
  intent: researchIntent,
2499
- context: {
2500
- surface: 'unified_agent_run',
2501
- agentRunId: runId,
2502
- routeDecision,
2503
- selectedObject: request.selectedObject,
2504
- workspaceContext: request.workspaceContext,
2505
- plan,
2506
- },
2851
+ context: researchContextEnvelope,
2852
+ executionConnection: researchExecutionConnection,
2853
+ executionConnectionName: researchExecutionConnectionName,
2854
+ signal: request.signal,
2855
+ baselineSql: agentRunString(researchSource?.sql),
2856
+ baselineDqlArtifact: researchSource?.dqlArtifact,
2857
+ baselineRunId: agentRunString(researchSource?.runId),
2507
2858
  });
2508
2859
  researchRun = withNotebookResearchChecklist(executed);
2509
2860
  }
@@ -2816,7 +3167,7 @@ export async function startLocalServer(opts) {
2816
3167
  message: `Generating review-required SQL for an uncovered question: ${question}`,
2817
3168
  route: 'app_build',
2818
3169
  });
2819
- return runGovernedAgentAnswerForRun({ question });
3170
+ return generateAppBuildAnswerWithRepair(question);
2820
3171
  },
2821
3172
  });
2822
3173
  }
@@ -3716,13 +4067,15 @@ export async function startLocalServer(opts) {
3716
4067
  * evidence. The refusal can say the relation is not visible to this
3717
4068
  * connection, rather than only that DQL had not looked at it.
3718
4069
  */
3719
- const probeNamedRelationsForAgent = async (relations, probeConnection) => {
4070
+ const probeNamedRelationsForAgent = async (relations, probeConnection, verifyExecutionSchema = false) => {
3720
4071
  const probeTarget = probeConnection ?? connection;
3721
4072
  if (!probeTarget)
3722
4073
  return undefined;
3723
- // Same operator switch that governs live value grounding: a deployment that
3724
- // wants zero live metadata traffic keeps it.
3725
- if (resolveAgentRuntimeValueGrounding(projectConfig).mode !== 'safe_automatic')
4074
+ // Named relation discovery remains behind the operator's live-grounding
4075
+ // switch. Executable schema verification is different: it is a bounded,
4076
+ // read-only metadata point lookup for relations already selected from dbt,
4077
+ // and never reads row values or mutates the active target.
4078
+ if (!verifyExecutionSchema && resolveAgentRuntimeValueGrounding(projectConfig).mode !== 'safe_automatic')
3726
4079
  return undefined;
3727
4080
  const sql = buildNamedRelationProbeSql(relations);
3728
4081
  if (!sql)
@@ -3775,12 +4128,29 @@ export async function startLocalServer(opts) {
3775
4128
  && activeRuntimeSnapshot.status !== 'stale');
3776
4129
  const valueGrounding = resolveAgentRuntimeValueGrounding(projectConfig);
3777
4130
  if (catalogContext.length > 0) {
3778
- if (!schemaConnection || valueGrounding.mode !== 'safe_automatic')
4131
+ if (!schemaConnection)
3779
4132
  return catalogContext;
3780
- // The immutable dbt/DQL snapshot already owns schema discovery. A named
3781
- // row value must not turn a warm Ask into an information_schema scan over
3782
- // thousands of enterprise tables; only field-scoped value probes are live.
3783
- const enriched = await enrichAgentSchemaContextWithValueMatches(question, catalogContext, executor, schemaConnection, valueGrounding.searchSafeColumns);
4133
+ // dbt metadata is the semantic authority, but generated SQL must use the
4134
+ // physical columns visible on THIS execution target. Verify only the few
4135
+ // question-ranked relations with a bounded information_schema point lookup;
4136
+ // this is read-only and never mutates DuckDB, Snowflake, or any other target.
4137
+ let executionSchema = catalogContext;
4138
+ try {
4139
+ const live = await probeNamedRelationsForAgent(catalogContext.slice(0, 4).map((table) => table.relation), schemaConnection, true);
4140
+ if (live?.schemaContext?.length) {
4141
+ executionSchema = reconcileAgentSchemaContextWithLive(catalogContext, live.schemaContext);
4142
+ }
4143
+ }
4144
+ catch {
4145
+ // Live verification is additive. Existing metadata remains available if
4146
+ // the connection cannot expose information_schema to this user.
4147
+ }
4148
+ // The immutable dbt/DQL snapshot owns schema discovery. Live verification
4149
+ // only confirms those named relations; row values remain governed by the
4150
+ // separate allowlisted grounding policy below.
4151
+ if (valueGrounding.mode !== 'safe_automatic')
4152
+ return executionSchema;
4153
+ const enriched = await enrichAgentSchemaContextWithValueMatches(question, executionSchema, executor, schemaConnection, valueGrounding.searchSafeColumns);
3784
4154
  return enriched;
3785
4155
  }
3786
4156
  // An activated, scope-qualified warehouse generation is the complete warm
@@ -3912,83 +4282,365 @@ export async function startLocalServer(opts) {
3912
4282
  }
3913
4283
  return { result, repairedSql, repairMode, firstFailure, rewrites };
3914
4284
  };
3915
- const generateInvestigationSqlForApp = async (input) => {
3916
- const governed = resolveGovernedAnswerRunner(projectRoot);
3917
- const resolvedProvider = governed?.provider ?? null;
3918
- const runner = governed?.runner ?? null;
3919
- if (!resolvedProvider || !runner) {
3920
- throw new Error('No AI provider is configured. Configure a subscription (Claude Code / Codex), OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.');
4285
+ /**
4286
+ * Repair a DQL wrapper that cannot compile far enough to expose executable
4287
+ * SQL. This is the DQL-side half of the same bounded repair contract used by
4288
+ * Ask and Notebook: one provider call, one block, identical parameters, one
4289
+ * read-only execution, and the exact target selected by the failed run.
4290
+ */
4291
+ const executeBoundedDqlRepair = async (input) => {
4292
+ const provider = await createBlockStudioAssistProvider(projectRoot);
4293
+ if (!provider) {
4294
+ throw boundedRepairError('Configure an available AI provider in Settings to repair this DQL draft.', 409);
3921
4295
  }
3922
- let governedAnswer;
3923
- let providerError;
3924
- const memoOnly = input.mode === 'memo_only';
3925
- const reasoningEffort = resolveRunReasoningEffort(projectRoot, resolvedProvider, 'research', false);
3926
- const contextEnvelope = {
3927
- mode: 'app_research',
3928
- generationMode: input.mode ?? 'sql_and_memo',
3929
- intent: input.intent,
3930
- appId: input.appId,
3931
- dashboardId: input.dashboardId,
3932
- sourceTileId: input.sourceTileId,
3933
- sourceBlockId: input.sourceBlockId,
3934
- title: input.title,
3935
- instruction: [
3936
- 'Answer as an app-scoped analyst. Start from certified block/result context, active filters, dbt/semantic metadata, and schema evidence.',
3937
- memoOnly
3938
- ? 'Write the stakeholder memo from the supplied selected result, preview rows, generated SQL, metrics, drivers, filters, and caveats. Do not generate replacement SQL unless the user explicitly asks to fix SQL.'
3939
- : 'Generate review-required read-only SQL only when the certified result does not exactly answer the requested analysis grain. Execute only through the bounded generated SQL preview path.',
3940
- 'In the natural-language answer, write a stakeholder analysis memo, not a generic chat answer. Use Markdown headings chosen for the question, usually Executive answer, Key numbers, Drivers or Business readout, Caveats, and Next action.',
3941
- 'Use concrete numbers from the selected result or preview when available. If baseline, segment, grain, lineage, or source proof is missing, say that explicitly instead of inventing a driver story.',
3942
- 'Keep raw SQL in proposedSql/sql. Do not put SQL code fences or implementation trace in the memo body.',
3943
- ].join(' '),
3944
- generatedSql: input.generatedSql,
3945
- metrics: input.metrics,
3946
- drivers: input.drivers,
3947
- resultPreviews: input.resultPreviews,
3948
- summaryHint: input.summaryHint,
3949
- recommendationHint: input.recommendationHint,
3950
- sqlError: input.sqlError,
3951
- sqlErrorKind: input.sqlErrorKind,
3952
- hasReportEvidence: input.hasReportEvidence,
3953
- context: input.context,
3954
- };
3955
- const controller = new AbortController();
3956
- await runner.run({
3957
- provider: resolvedProvider,
3958
- messages: [{
4296
+ let schemaContext;
4297
+ try {
4298
+ // Include source identifiers in retrieval so a terse cell title still
4299
+ // resolves the physical dbt relations and live target columns it uses.
4300
+ schemaContext = await getSchemaContextForAgent(`${input.question}\n${input.source.slice(0, 6_000)}`, undefined, input.targetConnection, input.targetConnectionName);
4301
+ }
4302
+ catch (error) {
4303
+ const failure = normalizeWarehouseSqlFailure(error, input.targetConnection.driver);
4304
+ throw boundedRepairError(failure.redactedMessage, 422, failure);
4305
+ }
4306
+ const boundedSchema = schemaContext.slice(0, 16).map((table) => ({
4307
+ relation: table.relation,
4308
+ columns: table.columns.slice(0, 40).map((column) => ({ name: column.name, type: column.type })),
4309
+ }));
4310
+ let raw;
4311
+ try {
4312
+ raw = await provider.generate([
4313
+ {
4314
+ role: 'system',
4315
+ content: [
4316
+ 'Repair one malformed executable DQL custom block and its embedded read-only SQL.',
4317
+ 'Return only one JSON object with exactly one key named dql. Do not use markdown.',
4318
+ 'The DQL shape is: block "Name" { type = "custom" params { name: type = default } query = """SELECT ...""" visualization { ... } tests { ... } }.',
4319
+ 'Preserve the block name, business fields, visualization, tests, requested grain, filters, output columns, and aggregation semantics.',
4320
+ 'Preserve every declared parameter and every ${parameter} interpolation exactly. Do not add or remove parameters.',
4321
+ 'Use only relations and columns present in the supplied schema context.',
4322
+ 'Never emit internal relation identities such as source::, model::, seed::, or snapshot::.',
4323
+ 'Return exactly one custom block. Never certify it, bind a saved block, add writes, DDL, or multiple SQL statements.',
4324
+ ].join(' '),
4325
+ },
4326
+ {
3959
4327
  role: 'user',
3960
- content: memoOnly
3961
- ? [
3962
- `Research question: ${input.question}`,
3963
- 'Write the business memo now using only the evidence envelope supplied as upstream context.',
3964
- 'Return Markdown sections. Keep SQL, query text, implementation trace, and raw routing details out of the memo body.',
3965
- 'If the evidence is insufficient, state the gap and the next reviewer action instead of filling the story with generic text.',
3966
- ].join('\n')
3967
- : input.question,
3968
- }],
3969
- upstream: {
3970
- cellId: `app-research:${input.appId}:${input.dashboardId ?? 'app'}`,
3971
- sql: JSON.stringify(contextEnvelope, null, 2),
4328
+ content: JSON.stringify({
4329
+ question: input.question,
4330
+ driver: input.targetConnection.driver,
4331
+ failure: input.failure.slice(0, 2_000),
4332
+ dql: input.source.slice(0, 40_000),
4333
+ parameters: input.parameters ?? {},
4334
+ schema: boundedSchema,
4335
+ }),
4336
+ },
4337
+ ], { maxTokens: 2400, temperature: 0 });
4338
+ }
4339
+ catch {
4340
+ throw boundedRepairError('The AI provider could not complete this DQL repair. Check Provider Settings and retry.', 502);
4341
+ }
4342
+ let repairedSource;
4343
+ try {
4344
+ const parsed = parseFirstJsonObject(raw);
4345
+ repairedSource = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
4346
+ && typeof parsed.dql === 'string'
4347
+ ? String(parsed.dql).trim()
4348
+ : undefined;
4349
+ }
4350
+ catch {
4351
+ repairedSource = undefined;
4352
+ }
4353
+ if (!repairedSource) {
4354
+ throw boundedRepairError('The AI repair did not return a complete DQL block.', 422);
4355
+ }
4356
+ if (repairedSource.length > 50_000) {
4357
+ throw boundedRepairError('The repaired DQL exceeded the safe editable-draft size limit.', 422);
4358
+ }
4359
+ if (/^\s*@block\(/i.test(repairedSource) || /\btype\s*=\s*"semantic"/i.test(repairedSource)) {
4360
+ throw boundedRepairError('The repair changed this editable custom block into a governed reference or semantic block.', 422);
4361
+ }
4362
+ if (!/\bstatus\s*=\s*"certified"/i.test(input.source)
4363
+ && /\bstatus\s*=\s*"certified"/i.test(repairedSource)) {
4364
+ throw boundedRepairError('The repair attempted to certify an AI-edited block, so DQL stopped it.', 422);
4365
+ }
4366
+ const beforeParameters = dqlRepairParameterContract(input.source);
4367
+ const afterParameters = dqlRepairParameterContract(repairedSource);
4368
+ if (beforeParameters.join('|') !== afterParameters.join('|')) {
4369
+ throw boundedRepairError('The repair changed this block\'s parameter contract, so DQL stopped it before execution.', 422);
4370
+ }
4371
+ let invocation;
4372
+ let plan;
4373
+ try {
4374
+ const program = new Parser(repairedSource, '<bounded-dql-repair>').parse();
4375
+ const blocks = program.statements.filter((statement) => statement.kind === NodeKind.BlockDecl);
4376
+ if (blocks.length !== 1 || program.statements.length !== 1) {
4377
+ throw new Error('The repaired source must contain exactly one DQL block.');
4378
+ }
4379
+ const block = blocks[0];
4380
+ if (block.kind !== NodeKind.BlockDecl || block.blockType !== 'custom') {
4381
+ throw new Error('The repaired source must remain a custom DQL block.');
4382
+ }
4383
+ invocation = prepareBlockInvocation({
4384
+ source: repairedSource,
4385
+ parameters: input.parameters,
4386
+ question: input.question,
4387
+ surface: 'notebook',
4388
+ });
4389
+ if (invocation.errors.length)
4390
+ throw new Error(invocation.errors.join(' '));
4391
+ if (invocation.unresolvedParameters.length) {
4392
+ throw new Error(`Provide required parameters before retrying: ${invocation.unresolvedParameters.join(', ')}.`);
4393
+ }
4394
+ plan = buildExecutionPlan({
4395
+ id: 'bounded-dql-repair',
4396
+ type: 'dql',
4397
+ source: repairedSource,
4398
+ title: input.artifactName,
4399
+ }, {
4400
+ semanticLayer,
4401
+ driver: input.targetConnection.driver,
4402
+ parameters: invocation.values,
4403
+ });
4404
+ if (!plan?.sql?.trim())
4405
+ throw new Error('The repaired DQL did not compile to SQL.');
4406
+ }
4407
+ catch (error) {
4408
+ throw boundedRepairError(`The repaired DQL did not compile safely: ${error instanceof Error ? error.message : String(error)}`, 422);
4409
+ }
4410
+ const seed = {
4411
+ kind: 'sql_block',
4412
+ source: repairedSource,
4413
+ name: input.artifactName ?? plan.title,
4414
+ persistence: 'transient',
4415
+ trustState: 'review_required',
4416
+ compiledSql: plan.sql,
4417
+ ...(invocation.parameters.length ? { parameters: invocation.parameters } : {}),
4418
+ ...(Object.keys(invocation.values).length ? { parameterValues: invocation.values } : {}),
4419
+ };
4420
+ let result;
4421
+ try {
4422
+ result = await executeGeneratedSqlDirect(input.question, plan.sql, seed, input.targetConnection, { sqlParams: plan.sqlParams, variables: { ...plan.variables, ...invocation.values } });
4423
+ }
4424
+ catch (error) {
4425
+ const failure = normalizeWarehouseSqlFailure(error, input.targetConnection.driver);
4426
+ throw boundedRepairError(failure.redactedMessage, 422, failure);
4427
+ }
4428
+ result = {
4429
+ ...result,
4430
+ dqlArtifact: {
4431
+ ...(result.dqlArtifact ?? seed),
4432
+ source: repairedSource,
4433
+ compiledSql: result.sql ?? plan.sql,
4434
+ trustState: 'review_required',
4435
+ persistence: 'transient',
4436
+ ...(result.executionReceipt ? { executionReceipt: result.executionReceipt } : {}),
3972
4437
  },
3973
- reasoningEffort,
3974
- analysisDepth: 'deep',
3975
- projectRoot,
3976
- executeCertifiedBlock: executeCertifiedBlockForAgent,
3977
- executeGeneratedSql: executeGeneratedSqlForAgent,
3978
- getSchemaContext: getSchemaContextForAgent,
3979
- }, (turn) => {
3980
- if (turn.kind === 'tool_result' && turn.id === 'governed_answer') {
3981
- governedAnswer = turn.output;
4438
+ };
4439
+ return {
4440
+ result,
4441
+ repairedSql: plan.sql,
4442
+ repairedSource,
4443
+ repairMode: 'ai',
4444
+ rewrites: [],
4445
+ sqlParams: plan.sqlParams,
4446
+ variables: { ...plan.variables, ...invocation.values },
4447
+ };
4448
+ };
4449
+ /**
4450
+ * App proposal parity with Ask/Notebook repair. The first governed attempt is
4451
+ * retained unchanged. Only an execution failure with retained read-only SQL
4452
+ * may spend the shared one-attempt repair budget, on the same connection.
4453
+ * The repaired candidate is still exploratory and review-required.
4454
+ */
4455
+ const generateAppBuildAnswerWithRepair = async (question, appContext) => {
4456
+ const request = {
4457
+ question,
4458
+ audience: 'stakeholder',
4459
+ workspaceContext: appContext
4460
+ ? { surface: 'app_builder', appContext }
4461
+ : { surface: 'app_builder' },
4462
+ };
4463
+ const answer = await runGovernedAgentAnswerForRun(request);
4464
+ const sourceSql = answer.proposedSql?.trim() || answer.sql?.trim();
4465
+ const hasExecutionFailure = agentAnswerHasExecutionFailure(answer)
4466
+ || Boolean(answer.warehouseFailure)
4467
+ || Boolean(answer.analyticalFailure);
4468
+ if (answer.result || !sourceSql || !hasExecutionFailure)
4469
+ return answer;
4470
+ const attemptedAt = new Date().toISOString();
4471
+ const originalFailure = answer.warehouseFailure?.redactedMessage
4472
+ ?? answer.executionError
4473
+ ?? 'The generated query did not execute.';
4474
+ const originalSqlFingerprint = `sha256:${createHash('sha256').update(sourceSql).digest('hex')}`;
4475
+ const failedTrace = (message) => ({
4476
+ version: 1,
4477
+ status: 'failed',
4478
+ source: 'query_generator',
4479
+ attemptedAt,
4480
+ originalFailure,
4481
+ originalSqlFingerprint,
4482
+ approvalEligible: false,
4483
+ message,
4484
+ });
4485
+ if (!analyticalFailureAllowsDeterministicRetry(answer.warehouseFailure)
4486
+ || !analyticalFailureAllowsAppRepair(answer.analyticalFailure)) {
4487
+ return {
4488
+ ...answer,
4489
+ appBuilderRepair: failedTrace('This failure needs an access, policy, or connection decision and was not sent to AI repair.'),
4490
+ };
4491
+ }
4492
+ try {
4493
+ const targetConnection = await resolveAgentRunExecutionConnection(request);
4494
+ const targetConnectionName = request.executionTarget?.target === 'connection'
4495
+ ? request.executionTarget.connectionName
4496
+ : request.executionTarget?.target === 'local'
4497
+ ? 'local'
4498
+ : projectConfig.defaultConnectionName;
4499
+ const repaired = await executeBoundedSqlRepair({
4500
+ question,
4501
+ sourceSql,
4502
+ targetConnection,
4503
+ targetConnectionName,
4504
+ });
4505
+ const repair = {
4506
+ version: 1,
4507
+ status: 'repaired',
4508
+ source: 'query_generator',
4509
+ mode: repaired.repairMode,
4510
+ attemptedAt,
4511
+ originalFailure,
4512
+ originalSqlFingerprint,
4513
+ repairedSqlFingerprint: `sha256:${createHash('sha256').update(repaired.repairedSql).digest('hex')}`,
4514
+ approvalEligible: false,
4515
+ message: `${repaired.repairMode === 'ai' ? 'AI' : 'Deterministic'} repair executed successfully on the same data target. Review the repaired query before adding it to the private App draft.`,
4516
+ };
4517
+ return {
4518
+ ...answer,
4519
+ result: repaired.result,
4520
+ proposedSql: repaired.repairedSql,
4521
+ sql: repaired.repairedSql,
4522
+ executionError: undefined,
4523
+ warehouseFailure: undefined,
4524
+ analyticalFailure: undefined,
4525
+ validationWarnings: [
4526
+ ...(answer.validationWarnings ?? []),
4527
+ repair.message,
4528
+ ],
4529
+ appBuilderRepair: repair,
4530
+ };
4531
+ }
4532
+ catch (error) {
4533
+ return {
4534
+ ...answer,
4535
+ appBuilderRepair: failedTrace(error instanceof Error
4536
+ ? `The bounded repair could not complete: ${error.message}`
4537
+ : 'The bounded repair could not complete.'),
4538
+ };
4539
+ }
4540
+ };
4541
+ const repairFailedAppTileExecution = async (input) => {
4542
+ const attemptedAt = new Date().toISOString();
4543
+ const driver = input.targetConnection?.driver ?? connection?.driver ?? 'duckdb';
4544
+ const failure = normalizeWarehouseSqlFailure(input.error, driver);
4545
+ const originalFailure = failure.redactedMessage;
4546
+ const originalSqlFingerprint = input.sourceSql
4547
+ ? `sha256:${createHash('sha256').update(input.sourceSql).digest('hex')}`
4548
+ : undefined;
4549
+ const failed = (message) => ({
4550
+ ok: false,
4551
+ repair: {
4552
+ version: 1,
4553
+ status: 'failed',
4554
+ source: input.source,
4555
+ attemptedAt,
4556
+ originalFailure,
4557
+ ...(originalSqlFingerprint ? { originalSqlFingerprint } : {}),
4558
+ approvalEligible: false,
4559
+ message,
4560
+ },
4561
+ });
4562
+ if (!input.targetConnection) {
4563
+ return failed('Configure the original data target before retrying this App tile.');
4564
+ }
4565
+ if (!input.sourceSql?.trim() && input.sourceDql?.trim()) {
4566
+ try {
4567
+ const repaired = await executeBoundedDqlRepair({
4568
+ question: input.question,
4569
+ source: input.sourceDql,
4570
+ failure: originalFailure,
4571
+ targetConnection: input.targetConnection,
4572
+ targetConnectionName: input.targetConnectionName,
4573
+ parameters: input.variables,
4574
+ artifactName: input.artifactName,
4575
+ });
4576
+ const repair = {
4577
+ version: 1,
4578
+ status: 'repaired',
4579
+ source: input.source,
4580
+ mode: repaired.repairMode,
4581
+ attemptedAt,
4582
+ originalFailure,
4583
+ repairedSqlFingerprint: `sha256:${createHash('sha256').update(repaired.repairedSql).digest('hex')}`,
4584
+ approvalEligible: false,
4585
+ message: `${repaired.repairMode === 'ai' ? 'AI' : 'Deterministic'} DQL repair executed successfully on the same data target. The App result remains review-required and the source file was not changed.`,
4586
+ };
4587
+ return {
4588
+ ok: true,
4589
+ result: repaired.result,
4590
+ repairedSql: repaired.repairedSql,
4591
+ repairedDql: repaired.repairedSource,
4592
+ repair,
4593
+ };
3982
4594
  }
3983
- if (turn.kind === 'error') {
3984
- providerError = turn.message;
4595
+ catch (error) {
4596
+ return failed(error instanceof Error
4597
+ ? `The bounded DQL repair could not complete: ${error.message}`
4598
+ : 'The bounded DQL repair could not complete.');
3985
4599
  }
3986
- }, controller.signal);
3987
- if (!governedAnswer) {
3988
- throw new Error(providerError ?? 'The AI provider did not return a governed answer.');
3989
4600
  }
4601
+ if (!input.sourceSql?.trim()) {
4602
+ return failed('DQL retained the failure, but no read-only SQL or editable DQL was available for bounded repair.');
4603
+ }
4604
+ if (!analyticalFailureAllowsDeterministicRetry(failure)) {
4605
+ return failed('This failure needs an access, policy, or connection decision and was not sent to AI repair.');
4606
+ }
4607
+ try {
4608
+ const repaired = await executeBoundedSqlRepair({
4609
+ question: input.question,
4610
+ sourceSql: input.sourceSql,
4611
+ targetConnection: input.targetConnection,
4612
+ targetConnectionName: input.targetConnectionName,
4613
+ sqlParams: input.sqlParams,
4614
+ variables: input.variables,
4615
+ });
4616
+ const repair = {
4617
+ version: 1,
4618
+ status: 'repaired',
4619
+ source: input.source,
4620
+ mode: repaired.repairMode,
4621
+ attemptedAt,
4622
+ originalFailure,
4623
+ ...(originalSqlFingerprint ? { originalSqlFingerprint } : {}),
4624
+ repairedSqlFingerprint: `sha256:${createHash('sha256').update(repaired.repairedSql).digest('hex')}`,
4625
+ approvalEligible: false,
4626
+ message: `${repaired.repairMode === 'ai' ? 'AI' : 'Deterministic'} repair executed successfully on the same data target. The result remains review-required.`,
4627
+ };
4628
+ return { ok: true, result: repaired.result, repairedSql: repaired.repairedSql, repair };
4629
+ }
4630
+ catch (error) {
4631
+ return failed(error instanceof Error
4632
+ ? `The bounded repair could not complete: ${error.message}`
4633
+ : 'The bounded repair could not complete.');
4634
+ }
4635
+ };
4636
+ const generateInvestigationSqlForApp = async (input) => {
4637
+ const request = buildAppCopilotResearchAgentRequest(input);
4638
+ // AGT-007 / AGT-022: App Copilot keeps its server-owned App evidence
4639
+ // adapter, while routing the actual investigation through the uniform
4640
+ // snapshot, retrieval, trust, execution, and bounded-repair orchestration.
4641
+ const governedAnswer = await runGovernedAgentAnswerForRun(request, undefined, 'research');
3990
4642
  return {
3991
- sql: memoOnly ? undefined : governedAnswer.proposedSql ?? governedAnswer.sql,
4643
+ sql: input.mode === 'memo_only' ? undefined : governedAnswer.proposedSql ?? governedAnswer.sql,
3992
4644
  answer: governedAnswer.answer ?? governedAnswer.text,
3993
4645
  result: governedAnswer.result,
3994
4646
  analysisPlan: governedAnswer.analysisPlan,
@@ -4106,6 +4758,9 @@ export async function startLocalServer(opts) {
4106
4758
  let generatedSql = notebookResearchString(input.generatedSql) ?? run.generatedSql;
4107
4759
  let reviewedSql = notebookResearchString(input.reviewedSql) ?? run.reviewedSql;
4108
4760
  let dqlArtifact = normalizeDqlArtifactReference(input.dqlArtifact) ?? run.dqlArtifact;
4761
+ const baselineSql = notebookResearchString(input.baselineSql);
4762
+ const baselineDqlArtifact = normalizeDqlArtifactReference(input.baselineDqlArtifact);
4763
+ let usedBaselineFallback = false;
4109
4764
  const startedAt = new Date().toISOString();
4110
4765
  storage.updateRun(run.id, {
4111
4766
  domain,
@@ -4156,7 +4811,7 @@ export async function startLocalServer(opts) {
4156
4811
  generationWarnings.push('No AI provider is configured. Metadata context was saved as a research plan; paste SQL or configure an AI provider to generate candidate SQL.');
4157
4812
  }
4158
4813
  else {
4159
- const controller = new AbortController();
4814
+ const researchSignal = input.signal ?? new AbortController().signal;
4160
4815
  try {
4161
4816
  await runner.run({
4162
4817
  provider: resolvedProvider,
@@ -4178,18 +4833,19 @@ export async function startLocalServer(opts) {
4178
4833
  reasoningEffort: resolveRunReasoningEffort(projectRoot, resolvedProvider, 'research', false),
4179
4834
  analysisDepth: 'deep',
4180
4835
  projectRoot,
4181
- executeCertifiedBlock: executeCertifiedBlockForAgent,
4182
- executeGeneratedSql: executeGeneratedSqlForAgent,
4183
- getSchemaContext: getSchemaContextForAgent,
4836
+ executeCertifiedBlock: (node, invocation) => executeCertifiedBlockForAgent(node, invocation, input.executionConnection, input.executionConnectionName),
4837
+ executeGeneratedSql: (sql, artifact) => executeGeneratedArtifactForAgent(question, sql, artifact, input.executionConnection, input.executionConnectionName),
4838
+ getSchemaContext: (schemaQuestion, preparedContextPack) => getSchemaContextForAgent(schemaQuestion, preparedContextPack, input.executionConnection, input.executionConnectionName),
4184
4839
  }, (turn) => {
4185
4840
  if (turn.kind === 'tool_result' && turn.id === 'governed_answer') {
4186
4841
  governedAnswer = turn.output;
4187
4842
  }
4188
4843
  if (turn.kind === 'error')
4189
4844
  providerError = turn.message;
4190
- }, controller.signal);
4845
+ }, researchSignal);
4191
4846
  }
4192
4847
  catch (error) {
4848
+ rethrowIfCancelled(error, input.signal);
4193
4849
  providerError = error instanceof Error ? error.message : String(error);
4194
4850
  }
4195
4851
  if (!governedAnswer) {
@@ -4204,6 +4860,25 @@ export async function startLocalServer(opts) {
4204
4860
  }
4205
4861
  }
4206
4862
  }
4863
+ const deeperCompositionFailed = !governedAnswer
4864
+ || governedAnswer.kind === 'no_answer'
4865
+ || agentAnswerHasExecutionFailure(governedAnswer)
4866
+ || Boolean(governedAnswer.analyticalFailure)
4867
+ || (!governedAnswer.result && !generatedSql && !dqlArtifact);
4868
+ if (!reviewedSql && baselineSql && deeperCompositionFailed) {
4869
+ const failure = governedAnswer?.executionError
4870
+ ?? governedAnswer?.analyticalFailure?.message
4871
+ ?? governedAnswer?.answer
4872
+ ?? governedAnswer?.text
4873
+ ?? providerError;
4874
+ generatedSql = baselineSql;
4875
+ dqlArtifact = baselineDqlArtifact ?? dqlArtifact;
4876
+ usedBaselineFallback = true;
4877
+ generationWarnings.push([
4878
+ 'The deeper composition did not produce a safer executable query, so DQL kept and revalidated the successful Ask baseline on the same data target.',
4879
+ failure ? `Research detail: ${failure}` : '',
4880
+ ].filter(Boolean).join(' '));
4881
+ }
4207
4882
  const sqlForPreview = reviewedSql || generatedSql;
4208
4883
  const warnings = [
4209
4884
  ...(contextPack.warnings ?? []),
@@ -4212,14 +4887,14 @@ export async function startLocalServer(opts) {
4212
4887
  ].filter(Boolean);
4213
4888
  let resultPreview;
4214
4889
  let previewError;
4215
- if (governedAnswer?.result?.rows && !reviewedSql) {
4890
+ if (!usedBaselineFallback && governedAnswer?.result?.rows && !reviewedSql) {
4216
4891
  resultPreview = normalizeNotebookAgentResult(governedAnswer.result);
4217
4892
  }
4218
4893
  else if (sqlForPreview) {
4219
4894
  try {
4220
4895
  const previewSql = buildAgentPreviewSql(sqlForPreview);
4221
4896
  const previewStart = Date.now();
4222
- resultPreview = await executeLocalSqlForStoredResult(previewSql);
4897
+ resultPreview = await executeLocalSqlForStoredResult(previewSql, input.executionConnection);
4223
4898
  recordNotebookQueryRun(projectRoot, {
4224
4899
  notebookPath: run.notebookPath,
4225
4900
  cellId: run.sourceCellId,
@@ -4259,8 +4934,10 @@ export async function startLocalServer(opts) {
4259
4934
  : undefined;
4260
4935
  const evidence = {
4261
4936
  trustStatus: {
4262
- label: governedAnswer?.trustLabel
4263
- ?? (reviewedSql ? 'Reviewed notebook SQL' : generatedSql ? 'AI-generated research SQL' : dqlArtifact ? 'AI-generated DQL artifact' : 'Metadata-grounded research plan'),
4937
+ label: usedBaselineFallback
4938
+ ? 'Successful Ask baseline · revalidated on the same target'
4939
+ : governedAnswer?.trustLabel
4940
+ ?? (reviewedSql ? 'Reviewed notebook SQL' : generatedSql ? 'AI-generated research SQL' : dqlArtifact ? 'AI-generated DQL artifact' : 'Metadata-grounded research plan'),
4264
4941
  reviewRequired: true,
4265
4942
  },
4266
4943
  contextPackId: contextPack.id,
@@ -4294,9 +4971,11 @@ export async function startLocalServer(opts) {
4294
4971
  ...(governedAnswer?.citations ?? []),
4295
4972
  ].slice(0, 40),
4296
4973
  };
4297
- const summary = notebookResearchString(governedAnswer?.answer)
4298
- ?? notebookResearchString(governedAnswer?.text)
4299
- ?? notebookResearchSummary(question, resultPreview, previewError);
4974
+ const summary = usedBaselineFallback && resultPreview && !previewError
4975
+ ? 'Research retained the successful Ask baseline and revalidated it on the same data target. No unverified replacement query was accepted; use the dossier to add the next breakdown or comparison.'
4976
+ : notebookResearchString(governedAnswer?.answer)
4977
+ ?? notebookResearchString(governedAnswer?.text)
4978
+ ?? notebookResearchSummary(question, resultPreview, previewError);
4300
4979
  const recommendation = previewError
4301
4980
  ? 'Review the SQL, selected metadata, and connection context before rerunning.'
4302
4981
  : dqlArtifact && !reviewedSql
@@ -4334,6 +5013,9 @@ export async function startLocalServer(opts) {
4334
5013
  contextPackId: contextPack.id,
4335
5014
  routeDecision,
4336
5015
  warnings: [
5016
+ ...(usedBaselineFallback && input.baselineRunId
5017
+ ? [`Baseline Ask run: ${input.baselineRunId}`]
5018
+ : []),
4337
5019
  ...warnings,
4338
5020
  ...(display && !display.ok ? [display.error] : []),
4339
5021
  ...(display && display.ok ? display.warnings : []),
@@ -4344,6 +5026,7 @@ export async function startLocalServer(opts) {
4344
5026
  }) ?? run;
4345
5027
  }
4346
5028
  catch (error) {
5029
+ rethrowIfCancelled(error, input.signal);
4347
5030
  return storage.updateRun(run.id, {
4348
5031
  domain,
4349
5032
  owner,
@@ -4570,16 +5253,19 @@ export async function startLocalServer(opts) {
4570
5253
  });
4571
5254
  // SSE clients for /api/watch hot-reload
4572
5255
  const sseClients = new Set();
5256
+ const projectWatchers = [];
5257
+ let runtimeClosing = false;
4573
5258
  // Watch all Git-owned authoring roots. Domain Packages are recursive and are
4574
5259
  // the canonical home for modeling, skills, blocks, notebooks, Apps, and evals.
4575
5260
  if (projectRoot) {
4576
- let sourceRefreshTimer;
4577
5261
  for (const dir of ['domains', 'skills', 'tests', 'notebooks', 'workbooks', 'blocks', 'apps', 'dashboards', 'semantic-layer', 'data']) {
4578
5262
  const watchDir = join(projectRoot, dir);
4579
5263
  if (!existsSync(watchDir))
4580
5264
  continue;
4581
5265
  try {
4582
- watch(watchDir, { persistent: false, recursive: true }, (eventType, filename) => {
5266
+ const watcher = watch(watchDir, { persistent: false, recursive: true }, (eventType, filename) => {
5267
+ if (runtimeClosing)
5268
+ return;
4583
5269
  if (!filename)
4584
5270
  return;
4585
5271
  const path = `${dir}/${filename}`;
@@ -4594,11 +5280,7 @@ export async function startLocalServer(opts) {
4594
5280
  }
4595
5281
  if (['domains', 'skills', 'tests', 'blocks', 'apps', 'notebooks'].includes(dir)) {
4596
5282
  invalidateAgentProjectState(projectRoot);
4597
- if (sourceRefreshTimer)
4598
- clearTimeout(sourceRefreshTimer);
4599
- sourceRefreshTimer = setTimeout(() => {
4600
- void refreshUnifiedProjectIndexes(projectRoot).catch(() => undefined);
4601
- }, 250);
5283
+ scheduleProjectRefresh(`file:${path}`);
4602
5284
  }
4603
5285
  // Hot-reload semantic layer on change and notify frontend
4604
5286
  if (dir === 'semantic-layer') {
@@ -4616,21 +5298,47 @@ export async function startLocalServer(opts) {
4616
5298
  }).catch(() => { });
4617
5299
  }
4618
5300
  });
5301
+ projectWatchers.push(watcher);
4619
5302
  }
4620
5303
  catch { /* dir not watchable */ }
4621
5304
  }
5305
+ // A dbt repository commonly keeps its shared Skills folder beside the DQL
5306
+ // project (`../skills`). It is Git-owned context too, so watch the resolved
5307
+ // configured root even when it is not the default projectRoot/skills path.
5308
+ const configuredSkillsRoot = skillsDir(projectRoot);
5309
+ const defaultSkillsRoot = join(projectRoot, 'skills');
5310
+ if (existsSync(configuredSkillsRoot) && resolve(configuredSkillsRoot) !== resolve(defaultSkillsRoot)) {
5311
+ try {
5312
+ const watcher = watch(configuredSkillsRoot, { persistent: false, recursive: true }, (eventType, filename) => {
5313
+ if (runtimeClosing || !filename)
5314
+ return;
5315
+ const path = relative(projectRoot, join(configuredSkillsRoot, String(filename))).replace(/\\/g, '/');
5316
+ invalidateAgentProjectState(projectRoot);
5317
+ scheduleProjectRefresh(`skills:${path}`);
5318
+ const payload = JSON.stringify({ type: eventType === 'rename' ? 'file-added' : 'file-changed', path });
5319
+ for (const client of sseClients) {
5320
+ try {
5321
+ client.write(`event: change\ndata: ${payload}\n\n`);
5322
+ }
5323
+ catch {
5324
+ sseClients.delete(client);
5325
+ }
5326
+ }
5327
+ });
5328
+ projectWatchers.push(watcher);
5329
+ }
5330
+ catch { /* configured Skills folder is not watchable */ }
5331
+ }
4622
5332
  const configuredDbtManifest = resolveDbtManifestPath(projectRoot, projectConfig);
4623
5333
  if (configuredDbtManifest && existsSync(dirname(configuredDbtManifest))) {
4624
5334
  try {
4625
- watch(dirname(configuredDbtManifest), { persistent: false }, (_eventType, filename) => {
5335
+ const watcher = watch(dirname(configuredDbtManifest), { persistent: false }, (_eventType, filename) => {
5336
+ if (runtimeClosing)
5337
+ return;
4626
5338
  if (!filename || !['manifest.json', 'catalog.json', 'semantic_manifest.json', 'run_results.json'].includes(String(filename)))
4627
5339
  return;
4628
5340
  invalidateAgentProjectState(projectRoot);
4629
- if (sourceRefreshTimer)
4630
- clearTimeout(sourceRefreshTimer);
4631
- sourceRefreshTimer = setTimeout(() => {
4632
- void refreshUnifiedProjectIndexes(projectRoot).catch(() => undefined);
4633
- }, 250);
5341
+ scheduleProjectRefresh(`dbt:${String(filename)}`);
4634
5342
  const payload = JSON.stringify({ type: 'dbt-artifacts-changed', path: String(filename) });
4635
5343
  for (const client of sseClients) {
4636
5344
  try {
@@ -4652,6 +5360,7 @@ export async function startLocalServer(opts) {
4652
5360
  }
4653
5361
  }).catch(() => { });
4654
5362
  });
5363
+ projectWatchers.push(watcher);
4655
5364
  }
4656
5365
  catch { /* dbt artifact directory not watchable */ }
4657
5366
  }
@@ -4734,11 +5443,15 @@ export async function startLocalServer(opts) {
4734
5443
  result: normalizeQueryResult(result),
4735
5444
  chartConfig: plan?.chartConfig ?? validation.chartConfig ?? null,
4736
5445
  invocation,
5446
+ tests: plan?.tests ?? [],
4737
5447
  };
4738
5448
  };
4739
- const runBlockStudioTestSummary = async (source, targetConnection) => {
4740
- const activeConnection = requireActiveConnection(targetConnection);
5449
+ const runBlockStudioTestSummary = async (source, targetConnection, executedPreview) => {
4741
5450
  const start = Date.now();
5451
+ if (executedPreview) {
5452
+ return evaluateBlockStudioTests(executedPreview.tests, executedPreview.result.rows, executedPreview.result.columns, start);
5453
+ }
5454
+ const activeConnection = requireActiveConnection(targetConnection);
4742
5455
  const tableMapping = await resolveSemanticTableMapping(executor, activeConnection, semanticLayer, projectRoot);
4743
5456
  // Run tests against the SAME SQL the preview runs: for a semantic block with a
4744
5457
  // pre-compiled query, that's the query (not a recompiled metric), so the test's
@@ -4783,40 +5496,7 @@ export async function startLocalServer(opts) {
4783
5496
  const columns = Array.isArray(rawResult?.columns)
4784
5497
  ? rawResult.columns.map((column) => typeof column === 'string' ? column : column?.name ?? String(column))
4785
5498
  : [];
4786
- const assertions = [];
4787
- let passed = 0;
4788
- let failed = 0;
4789
- for (const test of tests) {
4790
- const name = `assert ${test.field} ${test.operator} ${formatBlockStudioExpected(test.expected)}`;
4791
- let actual;
4792
- if (test.field === 'row_count') {
4793
- actual = rows.length;
4794
- }
4795
- else if (!columns.includes(test.field)) {
4796
- assertions.push({ name, passed: false, expected: test.expected, error: `Column '${test.field}' not found in results` });
4797
- failed += 1;
4798
- continue;
4799
- }
4800
- else {
4801
- actual = rows[0]?.[test.field];
4802
- }
4803
- const ok = compareBlockStudioValues(actual, test.operator, test.expected);
4804
- if (ok) {
4805
- assertions.push({ name, passed: true, actual, expected: test.expected });
4806
- passed += 1;
4807
- }
4808
- else {
4809
- assertions.push({
4810
- name,
4811
- passed: false,
4812
- actual,
4813
- expected: test.expected,
4814
- error: `${String(actual)} ${test.operator} ${formatBlockStudioExpected(test.expected)} is false`,
4815
- });
4816
- failed += 1;
4817
- }
4818
- }
4819
- return { passed, failed, skipped: 0, duration: Date.now() - start, assertions, runAt: new Date() };
5499
+ return evaluateBlockStudioTests(tests, rows, columns, start);
4820
5500
  };
4821
5501
  const saveDqlGenerationDraft = (importId, candidate) => saveDqlGenerationDraftForProject(projectRoot, importId, candidate);
4822
5502
  const createDqlGenerationSessionFromBody = async (body) => {
@@ -4875,7 +5555,9 @@ export async function startLocalServer(opts) {
4875
5555
  blockers.push(error instanceof Error ? error.message : String(error));
4876
5556
  }
4877
5557
  try {
4878
- testResults = await runBlockStudioTestSummary(source);
5558
+ testResults = preview
5559
+ ? await runBlockStudioTestSummary(source, undefined, preview)
5560
+ : await runBlockStudioTestSummary(source);
4879
5561
  }
4880
5562
  catch (error) {
4881
5563
  testResults = {
@@ -5032,15 +5714,24 @@ export async function startLocalServer(opts) {
5032
5714
  writeHead(status, { ...headers, ...validatorHeaders });
5033
5715
  return end(raw);
5034
5716
  }
5035
- const gz = gzipSync(raw);
5036
- writeHead(status, {
5037
- ...headers,
5038
- ...validatorHeaders,
5039
- 'Content-Encoding': 'gzip',
5040
- 'Content-Length': gz.byteLength,
5041
- Vary: 'Accept-Encoding, Origin',
5717
+ gzip(raw, (error, gz) => {
5718
+ if (response.destroyed || response.writableEnded)
5719
+ return;
5720
+ if (error) {
5721
+ writeHead(status, { ...headers, ...validatorHeaders });
5722
+ end(raw);
5723
+ return;
5724
+ }
5725
+ writeHead(status, {
5726
+ ...headers,
5727
+ ...validatorHeaders,
5728
+ 'Content-Encoding': 'gzip',
5729
+ 'Content-Length': gz.byteLength,
5730
+ Vary: 'Accept-Encoding, Origin',
5731
+ });
5732
+ end(gz);
5042
5733
  });
5043
- return end(gz);
5734
+ return response;
5044
5735
  });
5045
5736
  return rawResponse;
5046
5737
  };
@@ -5059,7 +5750,7 @@ export async function startLocalServer(opts) {
5059
5750
  res.setHeader('Access-Control-Allow-Origin', requestOrigin);
5060
5751
  res.setHeader('Vary', 'Origin');
5061
5752
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
5062
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
5753
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Idempotency-Key');
5063
5754
  if (!originAllowed && path.startsWith('/api/')) {
5064
5755
  res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
5065
5756
  res.end(serializeJSON({ error: 'Origin is not allowed.' }));
@@ -5094,6 +5785,40 @@ export async function startLocalServer(opts) {
5094
5785
  res.end(serializeJSON({ status: 'ok', version: runtimeVersion, versionStatus, retrievalHealth }));
5095
5786
  return;
5096
5787
  }
5788
+ if (req.method === 'GET' && path === '/api/operations/events') {
5789
+ res.writeHead(200, {
5790
+ 'Content-Type': 'text/event-stream',
5791
+ 'Cache-Control': 'no-cache',
5792
+ 'Connection': 'keep-alive',
5793
+ 'X-Accel-Buffering': 'no',
5794
+ });
5795
+ res.write(': connected\n\n');
5796
+ operationSseClients.add(res);
5797
+ for (const operation of operationCoordinator.list(20).reverse()) {
5798
+ res.write(`event: operation\ndata: ${serializeJSON(operation)}\n\n`);
5799
+ }
5800
+ req.on('close', () => operationSseClients.delete(res));
5801
+ return;
5802
+ }
5803
+ if (req.method === 'GET' && path === '/api/operations') {
5804
+ const limit = Number(url.searchParams.get('limit') ?? 50);
5805
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
5806
+ res.end(serializeJSON({ operations: operationCoordinator.list(limit) }));
5807
+ return;
5808
+ }
5809
+ const operationMatch = path.match(/^\/api\/operations\/([^/]+)$/);
5810
+ if (operationMatch && req.method === 'GET') {
5811
+ const operation = operationCoordinator.get(decodeURIComponent(operationMatch[1]));
5812
+ res.writeHead(operation ? 200 : 404, { 'Content-Type': 'application/json; charset=utf-8' });
5813
+ res.end(serializeJSON(operation ?? { error: 'Operation not found.' }));
5814
+ return;
5815
+ }
5816
+ if (operationMatch && req.method === 'DELETE') {
5817
+ const operation = operationCoordinator.cancel(decodeURIComponent(operationMatch[1]));
5818
+ res.writeHead(operation ? 200 : 404, { 'Content-Type': 'application/json; charset=utf-8' });
5819
+ res.end(serializeJSON(operation ?? { error: 'Operation not found.' }));
5820
+ return;
5821
+ }
5097
5822
  if (req.method === 'GET' && path === '/api/onboarding/launch') {
5098
5823
  const requestId = apiRequestId('onboarding-launch');
5099
5824
  const prefs = readUserPrefs(userPrefsPath);
@@ -5856,6 +6581,224 @@ export async function startLocalServer(opts) {
5856
6581
  }));
5857
6582
  return;
5858
6583
  }
6584
+ if (req.method === 'POST' && path === '/api/modeling/dbt-first/imports') {
6585
+ const requestId = apiRequestId('modeling-import');
6586
+ try {
6587
+ const body = await readJSON(req);
6588
+ const source = body.source && typeof body.source === 'object' ? body.source : body;
6589
+ const session = createModelingImportSession(projectRoot, gitRoot, loopback, source);
6590
+ res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
6591
+ res.end(serializeJSON({ requestId, session: publicModelingImportSession(session) }));
6592
+ }
6593
+ catch (error) {
6594
+ const code = apiErrorCode(error, 'MODELING_IMPORT_INVALID');
6595
+ res.writeHead(code === 'PATH_ACCESS_FORBIDDEN' ? 403 : 400, { 'Content-Type': 'application/json; charset=utf-8' });
6596
+ 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.'] })));
6597
+ }
6598
+ return;
6599
+ }
6600
+ const modelingImportMatch = path.match(/^\/api\/modeling\/dbt-first\/imports\/([^/]+)$/);
6601
+ if (req.method === 'GET' && modelingImportMatch) {
6602
+ const session = readModelingImportSession(projectRoot, decodeURIComponent(modelingImportMatch[1]));
6603
+ if (!session) {
6604
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
6605
+ res.end(serializeJSON({ error: 'Modeling import session not found.', code: 'MODELING_IMPORT_NOT_FOUND' }));
6606
+ return;
6607
+ }
6608
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6609
+ res.end(serializeJSON({ session: publicModelingImportSession(session) }));
6610
+ return;
6611
+ }
6612
+ const modelingImportPreviewMatch = path.match(/^\/api\/modeling\/dbt-first\/imports\/([^/]+)\/preview$/);
6613
+ if (req.method === 'POST' && modelingImportPreviewMatch) {
6614
+ const requestId = apiRequestId('modeling-import-preview');
6615
+ try {
6616
+ const session = readModelingImportSession(projectRoot, decodeURIComponent(modelingImportPreviewMatch[1]));
6617
+ if (!session)
6618
+ throw Object.assign(new Error('Modeling import session not found.'), { code: 'MODELING_IMPORT_NOT_FOUND' });
6619
+ const body = await readJSON(req);
6620
+ const snapshot = projectSnapshot();
6621
+ const selectedCandidateIds = Array.isArray(body.selectedCandidateIds)
6622
+ ? body.selectedCandidateIds.filter((value) => typeof value === 'string')
6623
+ : session.candidates.filter((candidate) => candidate.dialect !== 'unsupported' && candidate.action !== 'open_existing').map((candidate) => candidate.id);
6624
+ const domain = typeof body.domain === 'string' ? body.domain.trim() : '';
6625
+ const areaId = typeof body.areaId === 'string' ? body.areaId.trim() : '';
6626
+ if (!domain || !areaId)
6627
+ throw Object.assign(new Error('Choose exactly one Domain and Model Area for imported models.'), { code: 'MODEL_SCOPE_REQUIRED' });
6628
+ const planned = modelingImportOperations(session, selectedCandidateIds, domain, areaId, snapshot.manifest);
6629
+ const proposal = previewContextAuthoring({
6630
+ origin: 'yaml_import',
6631
+ operations: planned.operations,
6632
+ expectedSnapshotId: typeof body.expectedSnapshotId === 'string' ? body.expectedSnapshotId : snapshot.snapshotId,
6633
+ diagnostics: planned.diagnostics,
6634
+ });
6635
+ const updated = { ...session, proposalId: proposal.id, updatedAt: new Date().toISOString() };
6636
+ writeModelingImportSession(projectRoot, updated);
6637
+ res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
6638
+ res.end(serializeJSON({ requestId, session: publicModelingImportSession(updated), proposal }));
6639
+ }
6640
+ catch (error) {
6641
+ const code = apiErrorCode(error, 'MODELING_IMPORT_PREVIEW_FAILED');
6642
+ res.writeHead(code === 'MODELING_IMPORT_NOT_FOUND' ? 404 : code === 'PROPOSAL_STALE' ? 409 : 400, { 'Content-Type': 'application/json; charset=utf-8' });
6643
+ res.end(serializeJSON(apiErrorEnvelope({ requestId, code, message: apiErrorMessage(error), nextActions: ['Resolve unmatched models or choose another Domain and Area, then preview again.'] })));
6644
+ }
6645
+ return;
6646
+ }
6647
+ if (req.method === 'POST' && path === '/api/context-proposals') {
6648
+ const requestId = apiRequestId('context-proposal');
6649
+ try {
6650
+ const body = await readJSON(req);
6651
+ const origin = parseContextAuthoringOrigin(body.origin);
6652
+ const operations = parseContextAuthoringOperations(body.operations);
6653
+ const proposal = previewContextAuthoring({
6654
+ origin,
6655
+ operations,
6656
+ expectedSnapshotId: typeof body.expectedSnapshotId === 'string' ? body.expectedSnapshotId : undefined,
6657
+ sourceRunId: typeof body.sourceRunId === 'string' ? body.sourceRunId : undefined,
6658
+ sourceArtifactId: typeof body.sourceArtifactId === 'string' ? body.sourceArtifactId : undefined,
6659
+ revision: typeof body.revision === 'number' ? body.revision : undefined,
6660
+ });
6661
+ res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
6662
+ res.end(serializeJSON({ requestId, proposal }));
6663
+ }
6664
+ catch (error) {
6665
+ const code = apiErrorCode(error, 'PROPOSAL_INVALID');
6666
+ res.writeHead(code === 'PROPOSAL_STALE' ? 409 : 400, { 'Content-Type': 'application/json; charset=utf-8' });
6667
+ res.end(serializeJSON(apiErrorEnvelope({ requestId, code, message: apiErrorMessage(error), nextActions: ['Refresh Modeling and preview the proposal again.'] })));
6668
+ }
6669
+ return;
6670
+ }
6671
+ const contextProposalMatch = path.match(/^\/api\/context-proposals\/([^/]+)$/);
6672
+ if (req.method === 'GET' && contextProposalMatch) {
6673
+ const id = decodeURIComponent(contextProposalMatch[1]);
6674
+ const proposal = contextProposalStore.get(id);
6675
+ if (!proposal) {
6676
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
6677
+ res.end(serializeJSON({ error: 'Context proposal not found.', code: 'PROPOSAL_NOT_FOUND' }));
6678
+ return;
6679
+ }
6680
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6681
+ res.end(serializeJSON({ proposal }));
6682
+ return;
6683
+ }
6684
+ const contextRepreviewMatch = path.match(/^\/api\/context-proposals\/([^/]+)\/repreview$/);
6685
+ if (req.method === 'POST' && contextRepreviewMatch) {
6686
+ const requestId = apiRequestId('context-repreview');
6687
+ try {
6688
+ const current = contextProposalStore.get(decodeURIComponent(contextRepreviewMatch[1]));
6689
+ if (!current)
6690
+ throw Object.assign(new Error('Context proposal not found.'), { code: 'PROPOSAL_NOT_FOUND' });
6691
+ const body = await readJSON(req);
6692
+ const selected = Array.isArray(body.selectedOperationIds)
6693
+ ? body.selectedOperationIds.filter((value) => typeof value === 'string')
6694
+ : current.operations.map((operation) => operation.id);
6695
+ const operations = contextAuthoringDependencyClosure(current.operations, selected);
6696
+ const proposal = previewContextAuthoring({
6697
+ origin: current.origin,
6698
+ operations,
6699
+ expectedSnapshotId: body.rebase === true ? projectSnapshot().snapshotId : current.baseSnapshotId,
6700
+ sourceRunId: current.sourceRunId,
6701
+ sourceArtifactId: current.sourceArtifactId,
6702
+ revision: (current.revision ?? 1) + 1,
6703
+ });
6704
+ res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
6705
+ res.end(serializeJSON({ requestId, proposal }));
6706
+ }
6707
+ catch (error) {
6708
+ const code = apiErrorCode(error, 'PROPOSAL_INVALID');
6709
+ res.writeHead(code === 'PROPOSAL_STALE' ? 409 : code === 'PROPOSAL_NOT_FOUND' ? 404 : 400, { 'Content-Type': 'application/json; charset=utf-8' });
6710
+ res.end(serializeJSON(apiErrorEnvelope({ requestId, code, message: apiErrorMessage(error), nextActions: ['Refresh the proposal against the latest project snapshot.'] })));
6711
+ }
6712
+ return;
6713
+ }
6714
+ const contextCommitMatch = path.match(/^\/api\/context-proposals\/([^/]+)\/commit$/);
6715
+ if (req.method === 'POST' && contextCommitMatch) {
6716
+ const requestId = apiRequestId('context-commit');
6717
+ const proposalId = decodeURIComponent(contextCommitMatch[1]);
6718
+ try {
6719
+ const proposal = contextProposalStore.get(proposalId);
6720
+ if (!proposal)
6721
+ throw Object.assign(new Error('Context proposal not found.'), { code: 'PROPOSAL_NOT_FOUND' });
6722
+ const body = await readJSON(req);
6723
+ const unexpected = Object.keys(body).filter((key) => key !== 'expectedProposalHash' && key !== 'idempotencyKey');
6724
+ if (unexpected.length)
6725
+ throw Object.assign(new Error('Commit accepts only expectedProposalHash and idempotencyKey.'), { code: 'INVALID_REQUEST' });
6726
+ const expectedProposalHash = typeof body.expectedProposalHash === 'string' ? body.expectedProposalHash : '';
6727
+ const idempotencyKey = typeof body.idempotencyKey === 'string' ? body.idempotencyKey.trim() : '';
6728
+ if (!expectedProposalHash || !idempotencyKey)
6729
+ throw Object.assign(new Error('The reviewed proposal hash and idempotency key are required.'), { code: 'INVALID_REQUEST' });
6730
+ const receipt = contextProposalStore.getCommitReceipt(idempotencyKey);
6731
+ if (receipt) {
6732
+ if (receipt.proposalId !== proposal.id || receipt.proposalHash !== expectedProposalHash)
6733
+ throw Object.assign(new Error('The idempotency key belongs to another reviewed proposal request.'), { code: 'IDEMPOTENCY_CONFLICT' });
6734
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6735
+ res.end(serializeJSON({ requestId, proposal: contextProposalStore.get(proposal.id), snapshotId: receipt.snapshotId, idempotent: true }));
6736
+ return;
6737
+ }
6738
+ if (proposal.status !== 'proposed')
6739
+ throw Object.assign(new Error('Only a proposed change can be committed.'), { code: 'PROPOSAL_STALE' });
6740
+ if (proposal.proposalHash !== expectedProposalHash)
6741
+ throw Object.assign(new Error('The proposal changed after review.'), { code: 'PROPOSAL_STALE' });
6742
+ if (proposal.diagnostics.some((diagnostic) => diagnostic.severity === 'blocking'))
6743
+ throw Object.assign(new Error('Resolve all blocking proposal diagnostics before saving.'), { code: 'PROPOSAL_BLOCKED' });
6744
+ const beforeSnapshot = projectSnapshot();
6745
+ if (beforeSnapshot.snapshotId !== proposal.baseSnapshotId)
6746
+ throw Object.assign(new Error('Project sources changed after this proposal was reviewed.'), { code: 'PROPOSAL_STALE' });
6747
+ const resolvedPatches = proposal.patches.map((patch) => ({ patch, absolute: resolveContextPatchPath(projectRoot, gitRoot, patch.path) }));
6748
+ for (const { patch, absolute } of resolvedPatches) {
6749
+ const current = existsSync(absolute) ? readFileSync(absolute, 'utf8') : '';
6750
+ if (current !== patch.before)
6751
+ throw Object.assign(new Error(`Source changed after preview: ${patch.path}`), { code: 'SOURCE_CHANGED' });
6752
+ }
6753
+ const written = [];
6754
+ try {
6755
+ for (const item of resolvedPatches) {
6756
+ if (!item.patch.changed)
6757
+ continue;
6758
+ if (!item.patch.after)
6759
+ rmSync(item.absolute, { force: true });
6760
+ else {
6761
+ mkdirSync(dirname(item.absolute), { recursive: true });
6762
+ writeFileSync(item.absolute, item.patch.after, 'utf8');
6763
+ }
6764
+ written.push(item);
6765
+ }
6766
+ projectSnapshots.invalidate();
6767
+ invalidateAgentProjectState(projectRoot);
6768
+ const nextSnapshot = projectSnapshot();
6769
+ if (nextSnapshot.error)
6770
+ throw Object.assign(new Error(nextSnapshot.error), { code: 'COMPILE_FAILED' });
6771
+ await refreshUnifiedProjectIndexes(projectRoot);
6772
+ const committed = { ...proposal, status: 'committed', committedAt: new Date().toISOString(), committedSnapshotId: nextSnapshot.snapshotId };
6773
+ contextProposalStore.save(committed);
6774
+ contextProposalStore.saveCommitReceipt(idempotencyKey, { proposalId: proposal.id, proposalHash: proposal.proposalHash, snapshotId: nextSnapshot.snapshotId });
6775
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6776
+ res.end(serializeJSON({ requestId, proposal: committed, snapshotId: nextSnapshot.snapshotId, modeling: nextSnapshot.manifest.modeling }));
6777
+ }
6778
+ catch (error) {
6779
+ for (const { patch, absolute } of written.reverse()) {
6780
+ if (!patch.before)
6781
+ rmSync(absolute, { force: true });
6782
+ else {
6783
+ mkdirSync(dirname(absolute), { recursive: true });
6784
+ writeFileSync(absolute, patch.before, 'utf8');
6785
+ }
6786
+ }
6787
+ projectSnapshots.invalidate();
6788
+ invalidateAgentProjectState(projectRoot);
6789
+ contextProposalStore.save(proposal);
6790
+ contextProposalStore.deleteCommitReceipt(idempotencyKey);
6791
+ throw error;
6792
+ }
6793
+ }
6794
+ catch (error) {
6795
+ const code = apiErrorCode(error, /changed after/i.test(apiErrorMessage(error)) ? 'SOURCE_CHANGED' : 'PROPOSAL_COMMIT_FAILED');
6796
+ 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;
6797
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
6798
+ res.end(serializeJSON(apiErrorEnvelope({ requestId, code, message: apiErrorMessage(error), nextActions: ['Rebase or repreview the proposal before saving.'] })));
6799
+ }
6800
+ return;
6801
+ }
5859
6802
  if (req.method === 'POST' && path === '/api/modeling/dbt-first/dbt-source/preview') {
5860
6803
  const requestId = apiRequestId('modeling-dbt-source-preview');
5861
6804
  const snapshot = projectSnapshot();
@@ -6018,13 +6961,14 @@ export async function startLocalServer(opts) {
6018
6961
  return;
6019
6962
  }
6020
6963
  const sourceSql = repairableSqlFromAgentRun(sourceRun);
6964
+ const sourceDqlArtifact = repairableDqlArtifactFromAgentRun(sourceRun);
6021
6965
  const retainedFailure = warehouseFailureFromAgentRun(sourceRun);
6022
- if (!sourceSql || !analyticalFailureAllowsDeterministicRetry(retainedFailure)) {
6966
+ if ((!sourceSql && !sourceDqlArtifact) || !analyticalFailureAllowsDeterministicRetry(retainedFailure)) {
6023
6967
  res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
6024
6968
  res.end(serializeJSON({
6025
- error: sourceSql
6969
+ error: sourceSql || sourceDqlArtifact
6026
6970
  ? 'This failure requires authorized access or a policy change and cannot be repaired by AI.'
6027
- : 'This run has no retained SQL statement to repair.',
6971
+ : 'This run has no retained SQL statement or editable DQL artifact to repair.',
6028
6972
  ...(retainedFailure ? { warehouseFailure: retainedFailure } : {}),
6029
6973
  }));
6030
6974
  return;
@@ -6050,12 +6994,27 @@ export async function startLocalServer(opts) {
6050
6994
  : projectConfig.defaultConnectionName;
6051
6995
  let repaired;
6052
6996
  try {
6053
- repaired = await executeBoundedSqlRepair({
6054
- question: sourceRun.question,
6055
- sourceSql,
6056
- targetConnection,
6057
- targetConnectionName,
6058
- });
6997
+ // Prefer the editable DQL contract even when the failed run also
6998
+ // retained compiled SQL. This keeps parameters, visualization, tests,
6999
+ // and the corrected wrapper attached to the repaired Ask result.
7000
+ repaired = sourceDqlArtifact
7001
+ ? await executeBoundedDqlRepair({
7002
+ question: sourceRun.question,
7003
+ source: sourceDqlArtifact.source,
7004
+ failure: retainedFailure?.redactedMessage
7005
+ ?? sourceRun.diagnosticReceipt?.failure?.message
7006
+ ?? sourceRun.summary,
7007
+ targetConnection,
7008
+ targetConnectionName,
7009
+ parameters: sourceDqlArtifact.parameterValues,
7010
+ artifactName: sourceDqlArtifact.name,
7011
+ })
7012
+ : await executeBoundedSqlRepair({
7013
+ question: sourceRun.question,
7014
+ sourceSql: sourceSql,
7015
+ targetConnection,
7016
+ targetConnectionName,
7017
+ });
6059
7018
  }
6060
7019
  catch (error) {
6061
7020
  const repairError = error;
@@ -6078,6 +7037,8 @@ export async function startLocalServer(opts) {
6078
7037
  ...(preview ? { columnFormats: agentColumnDisplayFormats(projectRoot, preview.columns) } : {}),
6079
7038
  });
6080
7039
  const sourceFailure = analyticalFailedRunFromAgentRun(sourceRun)?.failure;
7040
+ const presentationContext = repairPresentationContextFromAgentRun(sourceRun);
7041
+ const executionResultFingerprint = result.executionReceipt?.resultFingerprint;
6081
7042
  const artifact = agentRunArtifact('answer', 'Repaired analytical answer', {
6082
7043
  kind: 'uncertified',
6083
7044
  certification: 'analyst_review_required',
@@ -6085,13 +7046,31 @@ export async function startLocalServer(opts) {
6085
7046
  trustLabel: 'AI-generated · review required',
6086
7047
  answer: synthesis.text,
6087
7048
  text: synthesis.text,
7049
+ ...presentationContext,
6088
7050
  result,
6089
7051
  proposedSql: repairedSql,
6090
7052
  sql: result.sql ?? repairedSql,
6091
7053
  dqlArtifact: result.dqlArtifact,
7054
+ diagnosticReceipt: {
7055
+ version: 1,
7056
+ phase: 'run.completed',
7057
+ route: 'generated_answer',
7058
+ repair: {
7059
+ attempt: 1,
7060
+ mode: repairMode,
7061
+ sourceRunId: sourceRun.id,
7062
+ targetPreserved: true,
7063
+ readOnly: true,
7064
+ },
7065
+ execution: {
7066
+ rowCount: result.rowCount,
7067
+ ...(executionResultFingerprint ? { resultFingerprint: executionResultFingerprint } : {}),
7068
+ },
7069
+ },
6092
7070
  previousAttempt: {
6093
7071
  sourceRunId: sourceRun.id,
6094
- sql: sourceSql,
7072
+ ...(sourceSql ? { sql: sourceSql } : {}),
7073
+ ...(sourceDqlArtifact?.source ? { dql: sourceDqlArtifact.source } : {}),
6095
7074
  failure: retainedFailure ?? firstFailure,
6096
7075
  },
6097
7076
  repair: { attempt: 1, mode: repairMode },
@@ -6274,39 +7253,83 @@ export async function startLocalServer(opts) {
6274
7253
  const wantsStream = url.searchParams.get('stream') === '1' || url.searchParams.get('stream') === 'true';
6275
7254
  const runId = parsed.request.runId;
6276
7255
  const runController = new AbortController();
6277
- parsed.request.signal = AbortSignal.any([
6278
- runController.signal,
6279
- AbortSignal.timeout(agentRunDeadlineMs(parsed.request, process.env, getActiveProvider(projectRoot))),
6280
- ]);
6281
7256
  if (runId)
6282
7257
  activeAgentRunControllers.set(runId, runController);
7258
+ let streamConnected = true;
7259
+ res.on('close', () => { streamConnected = false; });
7260
+ const writeStream = (event, data) => {
7261
+ if (!streamConnected || res.destroyed || res.writableEnded)
7262
+ return;
7263
+ try {
7264
+ writeAgentRunSse(res, event, data);
7265
+ }
7266
+ catch {
7267
+ streamConnected = false;
7268
+ }
7269
+ };
7270
+ if (wantsStream) {
7271
+ res.writeHead(200, {
7272
+ 'Content-Type': 'text/event-stream; charset=utf-8',
7273
+ 'Cache-Control': 'no-cache',
7274
+ 'Connection': 'keep-alive',
7275
+ 'X-Accel-Buffering': 'no',
7276
+ });
7277
+ }
7278
+ const operation = runId ? operationCoordinator.create({
7279
+ type: 'agent_run',
7280
+ scope: `agent-run:${runId}`,
7281
+ resourceRevision: parsed.request.threadId,
7282
+ message: 'AI request accepted. You can change pages while it runs.',
7283
+ cancellable: true,
7284
+ }) : null;
7285
+ if (wantsStream)
7286
+ writeStream('agent-run-accepted', { runId, operationId: operation?.id });
7287
+ let completedRun;
7288
+ let runError;
6283
7289
  try {
7290
+ const executeRun = async (operationSignal, report) => {
7291
+ parsed.request.signal = AbortSignal.any([
7292
+ runController.signal,
7293
+ ...(operationSignal ? [operationSignal] : []),
7294
+ AbortSignal.timeout(agentRunDeadlineMs(parsed.request, process.env, getActiveProvider(projectRoot))),
7295
+ ]);
7296
+ report?.({ phase: 'reasoning', progress: 8, message: 'Grounding the request and selecting the governed route.' });
7297
+ try {
7298
+ const run = await agentRunEngine.run(parsed.request, (event) => {
7299
+ if (wantsStream)
7300
+ writeStream('agent-run-event', event);
7301
+ report?.({ phase: event.type, progress: 45, message: event.message });
7302
+ }, (delta) => {
7303
+ if (wantsStream)
7304
+ writeStream('agent-run-answer-delta', { runId: parsed.request.runId, delta });
7305
+ });
7306
+ completedRun = run;
7307
+ recordConversationTurn(conversationStore, parsed.request.threadId, run);
7308
+ return { runId: run.id, threadId: parsed.request.threadId, status: run.status };
7309
+ }
7310
+ catch (error) {
7311
+ runError = error;
7312
+ throw error;
7313
+ }
7314
+ };
7315
+ if (operation)
7316
+ await operationCoordinator.run(operation.id, (signal, report) => executeRun(signal, report));
7317
+ else
7318
+ await executeRun();
7319
+ if (runError)
7320
+ throw runError;
7321
+ if (!completedRun)
7322
+ throw new Error('The AI request ended before producing a stored run.');
6284
7323
  if (wantsStream) {
6285
- res.writeHead(200, {
6286
- 'Content-Type': 'text/event-stream; charset=utf-8',
6287
- 'Cache-Control': 'no-cache',
6288
- 'Connection': 'keep-alive',
6289
- 'X-Accel-Buffering': 'no',
6290
- });
6291
- const run = await agentRunEngine.run(parsed.request, (event) => {
6292
- writeAgentRunSse(res, 'agent-run-event', event);
6293
- }, (delta) => {
6294
- writeAgentRunSse(res, 'agent-run-answer-delta', { runId: parsed.request.runId, delta });
6295
- });
6296
- recordConversationTurn(conversationStore, parsed.request.threadId, run);
6297
- // The terminal frame carried the whole stored run — 2.4 MB on a real
6298
- // question, of which the renderable part was ~3 KB. The rest was the
6299
- // same artifacts repeated four times over. The client renders from
6300
- // the projection and fetches the full record from the run-state
6301
- // route only if something needs it.
6302
- writeAgentRunSse(res, 'agent-run-complete', slimAgentRunForTransport(run));
6303
- res.end();
7324
+ // The complete stored run stays in the run-state route; the live
7325
+ // stream ships only the renderable transport projection.
7326
+ writeStream('agent-run-complete', slimAgentRunForTransport(completedRun));
7327
+ if (streamConnected && !res.writableEnded)
7328
+ res.end();
6304
7329
  return;
6305
7330
  }
6306
- const run = await agentRunEngine.run(parsed.request);
6307
- recordConversationTurn(conversationStore, parsed.request.threadId, run);
6308
7331
  res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
6309
- res.end(serializeJSON({ run }));
7332
+ res.end(serializeJSON({ run: completedRun }));
6310
7333
  }
6311
7334
  finally {
6312
7335
  if (runId)
@@ -6316,8 +7339,10 @@ export async function startLocalServer(opts) {
6316
7339
  catch (error) {
6317
7340
  const message = error instanceof Error ? error.message : String(error);
6318
7341
  if (res.headersSent) {
6319
- writeAgentRunSse(res, 'agent-run-error', { error: message });
6320
- res.end();
7342
+ if (!res.destroyed && !res.writableEnded) {
7343
+ writeAgentRunSse(res, 'agent-run-error', { error: message });
7344
+ res.end();
7345
+ }
6321
7346
  }
6322
7347
  else {
6323
7348
  res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -6808,14 +7833,22 @@ export async function startLocalServer(opts) {
6808
7833
  const metrics = semanticLayer
6809
7834
  ? semanticLayer.listMetrics(undefined, { includeMeasures: false }).map((m) => m.name).filter(matches).sort().slice(0, limit)
6810
7835
  : [];
6811
- const manifest = buildManifest({ projectRoot, dqlVersion: 'notebook' });
7836
+ const manifest = buildManifest({
7837
+ projectRoot,
7838
+ dqlVersion: 'notebook',
7839
+ dbtManifestPath: resolveDbtManifestPath(projectRoot) ?? undefined,
7840
+ });
6812
7841
  const blocks = Object.values(manifest.blocks)
6813
7842
  .filter((b) => b.status === 'certified')
6814
7843
  .map((b) => b.name)
6815
7844
  .filter(matches)
6816
7845
  .sort();
7846
+ const modelingRefs = [
7847
+ ...Object.values(manifest.modeling?.entities ?? {}).map((entity) => entity.qualifiedId),
7848
+ ...Object.values(manifest.modeling?.relationships ?? {}).map((relationship) => relationship.qualifiedId),
7849
+ ].filter(matches).sort();
6817
7850
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6818
- res.end(serializeJSON({ metrics, blocks: blocks.slice(0, limit) }));
7851
+ res.end(serializeJSON({ metrics, blocks: blocks.slice(0, limit), modelingRefs: modelingRefs.slice(0, limit) }));
6819
7852
  }
6820
7853
  catch (error) {
6821
7854
  res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -6833,6 +7866,9 @@ export async function startLocalServer(opts) {
6833
7866
  return;
6834
7867
  }
6835
7868
  const skill = writeSkill(projectRoot, input);
7869
+ projectSnapshots.invalidate();
7870
+ invalidateAgentProjectState(projectRoot);
7871
+ await refreshUnifiedProjectIndexes(projectRoot);
6836
7872
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6837
7873
  res.end(serializeJSON({ skill: serializeSkill(skill) }));
6838
7874
  }
@@ -6852,7 +7888,10 @@ export async function startLocalServer(opts) {
6852
7888
  res.end(serializeJSON({ error: 'Provide { skill } with scope and body.' }));
6853
7889
  return;
6854
7890
  }
6855
- const skill = writeSkill(projectRoot, input);
7891
+ const skill = writeSkill(projectRoot, { ...input, qualifiedId: id });
7892
+ projectSnapshots.invalidate();
7893
+ invalidateAgentProjectState(projectRoot);
7894
+ await refreshUnifiedProjectIndexes(projectRoot);
6856
7895
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6857
7896
  res.end(serializeJSON({ skill: serializeSkill(skill) }));
6858
7897
  }
@@ -6866,6 +7905,9 @@ export async function startLocalServer(opts) {
6866
7905
  try {
6867
7906
  const id = decodeURIComponent(path.slice('/api/skills/'.length));
6868
7907
  deleteSkill(projectRoot, id);
7908
+ projectSnapshots.invalidate();
7909
+ invalidateAgentProjectState(projectRoot);
7910
+ await refreshUnifiedProjectIndexes(projectRoot);
6869
7911
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6870
7912
  res.end(serializeJSON({ ok: true }));
6871
7913
  }
@@ -7989,9 +9031,14 @@ export async function startLocalServer(opts) {
7989
9031
  ? body.variables
7990
9032
  : {};
7991
9033
  const dashboardVariables = dashboardRuntimeVariables(loaded.dashboard, variables);
9034
+ const requestedTileId = typeof body.tileId === 'string' && body.tileId.trim()
9035
+ ? body.tileId.trim()
9036
+ : undefined;
7992
9037
  const tiles = [];
7993
9038
  let localApps = null;
7994
9039
  for (const item of loaded.dashboard.layout.items) {
9040
+ if (requestedTileId && item.i !== requestedTileId)
9041
+ continue;
7995
9042
  if (item.text) {
7996
9043
  tiles.push({
7997
9044
  tileId: item.i,
@@ -8079,9 +9126,194 @@ export async function startLocalServer(opts) {
8079
9126
  kind: 'ai_pin',
8080
9127
  name: pin.title,
8081
9128
  },
9129
+ artifact: {
9130
+ version: 1,
9131
+ sourceKind: 'ai_pin',
9132
+ name: pinArtifact?.name ?? pin.title,
9133
+ ...(pinArtifact?.sourcePath ? { sourcePath: pinArtifact.sourcePath } : {}),
9134
+ ...(pinArtifact?.source ? { dql: pinArtifact.source.slice(0, 40_000) } : {}),
9135
+ ...(pinArtifact?.compiledSql || pin.sql ? { sql: (pinArtifact?.compiledSql ?? pin.sql).slice(0, 40_000) } : {}),
9136
+ trustState: pinArtifact?.trustState === 'certified' || pin.certification === 'certified'
9137
+ ? 'certified'
9138
+ : 'review_required',
9139
+ explanation: [
9140
+ 'This result comes from a saved App insight.',
9141
+ pinArtifact ? 'The DQL artifact was executed again with the current App parameters.' : 'The saved bounded result is displayed with its original trust state.',
9142
+ ],
9143
+ executionTarget: executionTargetDescriptor(body),
9144
+ },
8082
9145
  });
8083
9146
  continue;
8084
9147
  }
9148
+ if (item.draftAnalysis) {
9149
+ let draftSource;
9150
+ let draftAppRelativePath;
9151
+ let draftParameters = {};
9152
+ let draftTargetConnection;
9153
+ let draftRepairPlan;
9154
+ try {
9155
+ const draftPath = join(loaded.appDir, item.draftAnalysis.ref);
9156
+ draftAppRelativePath = relative(loaded.appDir, draftPath);
9157
+ if (draftAppRelativePath.startsWith('..') || draftAppRelativePath.startsWith('/')) {
9158
+ throw new Error('App-scoped analysis reference escapes the App directory.');
9159
+ }
9160
+ draftSource = readFileSync(draftPath, 'utf-8');
9161
+ const fingerprint = `sha256:${createHash('sha256').update(draftSource).digest('hex')}`;
9162
+ if (fingerprint !== item.draftAnalysis.artifactFingerprint) {
9163
+ throw new Error('App-scoped analysis changed after approval. Review the draft again.');
9164
+ }
9165
+ draftParameters = dashboardTileParameterValues({
9166
+ item,
9167
+ dashboardValues: dashboardVariables,
9168
+ requestValues: variables,
9169
+ });
9170
+ draftTargetConnection = isConnectionConfig(body.connection)
9171
+ ? body.connection
9172
+ : connection ?? undefined;
9173
+ draftRepairPlan = buildExecutionPlan({ id: item.i, type: 'dql', source: draftSource, title: item.title ?? item.i }, {
9174
+ semanticLayer,
9175
+ driver: draftTargetConnection?.driver ?? 'duckdb',
9176
+ parameters: draftParameters,
9177
+ });
9178
+ let result;
9179
+ let repair;
9180
+ let repairedSql;
9181
+ let repairedDql;
9182
+ try {
9183
+ result = await executeDqlArtifactSourceForAgent(draftSource, { name: item.title ?? item.i, path: draftAppRelativePath }, { question: item.title, parameters: draftParameters });
9184
+ }
9185
+ catch (executionError) {
9186
+ const repaired = await repairFailedAppTileExecution({
9187
+ source: 'draft_analysis',
9188
+ question: item.title ?? item.i,
9189
+ sourceSql: draftRepairPlan?.sql,
9190
+ sourceDql: draftSource,
9191
+ artifactName: item.title ?? item.i,
9192
+ error: executionError,
9193
+ targetConnection: draftTargetConnection,
9194
+ targetConnectionName: projectConfig.defaultConnectionName,
9195
+ sqlParams: draftRepairPlan?.sqlParams,
9196
+ variables: { ...(draftRepairPlan?.variables ?? {}), ...draftParameters },
9197
+ });
9198
+ if (!repaired.ok) {
9199
+ tiles.push({
9200
+ tileId: item.i,
9201
+ status: 'error',
9202
+ tileType: 'draftAnalysis',
9203
+ title: item.title ?? item.i,
9204
+ error: executionError instanceof Error ? executionError.message : String(executionError),
9205
+ trustState: 'review_required',
9206
+ repair: repaired.repair,
9207
+ artifact: {
9208
+ version: 1,
9209
+ sourceKind: 'draft_analysis',
9210
+ name: item.title ?? item.i,
9211
+ sourcePath: draftAppRelativePath,
9212
+ dql: draftSource.slice(0, 40_000),
9213
+ ...(draftRepairPlan?.sql ? { sql: draftRepairPlan.sql.slice(0, 40_000) } : {}),
9214
+ trustState: 'review_required',
9215
+ explanation: ['This App-scoped analysis remains review-required.', 'Automatic repair could not produce a safe executable result.'],
9216
+ executionTarget: executionTargetDescriptor(body),
9217
+ },
9218
+ });
9219
+ continue;
9220
+ }
9221
+ result = repaired.result;
9222
+ repair = repaired.repair;
9223
+ repairedSql = repaired.repairedSql;
9224
+ repairedDql = repaired.repairedDql;
9225
+ }
9226
+ tiles.push({
9227
+ tileId: item.i,
9228
+ status: 'ok',
9229
+ tileType: 'draftAnalysis',
9230
+ title: item.title ?? item.i,
9231
+ viz: item.viz,
9232
+ chartConfig: mergeDashboardChartConfig(result.chartConfig, item),
9233
+ result,
9234
+ trustState: 'review_required',
9235
+ citation: { kind: repair ? 'app_draft_analysis_repaired' : 'app_draft_analysis', name: item.title ?? item.i, path: draftAppRelativePath },
9236
+ ...(repair ? { repair } : {}),
9237
+ artifact: {
9238
+ version: 1,
9239
+ sourceKind: 'draft_analysis',
9240
+ name: item.title ?? item.i,
9241
+ sourcePath: draftAppRelativePath,
9242
+ dql: (repairedDql ?? draftSource).slice(0, 40_000),
9243
+ ...(repairedSql ?? draftRepairPlan?.sql ? { sql: (repairedSql ?? draftRepairPlan.sql).slice(0, 40_000) } : {}),
9244
+ trustState: 'review_required',
9245
+ explanation: [
9246
+ 'This is private App analysis and remains review-required.',
9247
+ repair ? 'A bounded repair ran on the original data target without changing the saved source file.' : 'The saved App-scoped DQL was executed with the current filters.',
9248
+ ],
9249
+ executionTarget: executionTargetDescriptor(body),
9250
+ },
9251
+ });
9252
+ }
9253
+ catch (err) {
9254
+ const repaired = await repairFailedAppTileExecution({
9255
+ source: 'draft_analysis',
9256
+ question: item.title ?? item.i,
9257
+ sourceSql: draftRepairPlan?.sql,
9258
+ sourceDql: draftSource,
9259
+ artifactName: item.title ?? item.i,
9260
+ error: err,
9261
+ targetConnection: draftTargetConnection,
9262
+ targetConnectionName: projectConfig.defaultConnectionName,
9263
+ sqlParams: draftRepairPlan?.sqlParams,
9264
+ variables: { ...(draftRepairPlan?.variables ?? {}), ...draftParameters },
9265
+ });
9266
+ if (repaired.ok && draftSource) {
9267
+ tiles.push({
9268
+ tileId: item.i,
9269
+ status: 'ok',
9270
+ tileType: 'draftAnalysis',
9271
+ title: item.title ?? item.i,
9272
+ viz: item.viz,
9273
+ chartConfig: mergeDashboardChartConfig(repaired.result.chartConfig, item),
9274
+ result: repaired.result,
9275
+ trustState: 'review_required',
9276
+ citation: { kind: 'app_draft_analysis_repaired', name: item.title ?? item.i, path: draftAppRelativePath },
9277
+ repair: repaired.repair,
9278
+ artifact: {
9279
+ version: 1,
9280
+ sourceKind: 'draft_analysis',
9281
+ name: item.title ?? item.i,
9282
+ ...(draftAppRelativePath ? { sourcePath: draftAppRelativePath } : {}),
9283
+ dql: (repaired.repairedDql ?? draftSource).slice(0, 40_000),
9284
+ sql: repaired.repairedSql.slice(0, 40_000),
9285
+ trustState: 'review_required',
9286
+ explanation: ['A bounded DQL repair executed on the original data target. The saved App analysis was not changed.'],
9287
+ executionTarget: executionTargetDescriptor(body),
9288
+ },
9289
+ });
9290
+ continue;
9291
+ }
9292
+ tiles.push({
9293
+ tileId: item.i,
9294
+ status: 'error',
9295
+ tileType: 'draftAnalysis',
9296
+ title: item.title ?? item.i,
9297
+ error: err instanceof Error ? err.message : String(err),
9298
+ trustState: 'review_required',
9299
+ repair: repaired.repair,
9300
+ ...(draftSource ? {
9301
+ artifact: {
9302
+ version: 1,
9303
+ sourceKind: 'draft_analysis',
9304
+ name: item.title ?? item.i,
9305
+ ...(draftAppRelativePath ? { sourcePath: draftAppRelativePath } : {}),
9306
+ dql: draftSource.slice(0, 40_000),
9307
+ ...(draftRepairPlan?.sql ? { sql: draftRepairPlan.sql.slice(0, 40_000) } : {}),
9308
+ trustState: 'review_required',
9309
+ explanation: ['Automatic repair could not produce a safe executable result. The saved App analysis was not changed.'],
9310
+ executionTarget: executionTargetDescriptor(body),
9311
+ },
9312
+ } : {}),
9313
+ });
9314
+ }
9315
+ continue;
9316
+ }
8085
9317
  if (item.semantic) {
8086
9318
  try {
8087
9319
  if (!semanticLayer)
@@ -8122,16 +9354,69 @@ export async function startLocalServer(opts) {
8122
9354
  if (!composed)
8123
9355
  throw new Error('The governed semantic query could not be composed.');
8124
9356
  const prepared = prepareLocalExecution(composed.sql, targetConnection, projectRoot, projectConfig);
8125
- const result = await executor.executeQuery(prepared.sql, [], {}, prepared.connection);
9357
+ let normalizedResult;
9358
+ let repair;
9359
+ let executedSql = composed.sql;
9360
+ try {
9361
+ const result = await executor.executeQuery(prepared.sql, [], {}, prepared.connection);
9362
+ normalizedResult = normalizeQueryResult(result);
9363
+ }
9364
+ catch (executionError) {
9365
+ const repaired = await repairFailedAppTileExecution({
9366
+ source: 'semantic_query',
9367
+ question: item.title ?? item.semantic.id,
9368
+ sourceSql: composed.sql,
9369
+ error: executionError,
9370
+ targetConnection,
9371
+ });
9372
+ if (!repaired.ok) {
9373
+ tiles.push({
9374
+ tileId: item.i,
9375
+ status: 'error',
9376
+ tileType: 'semantic',
9377
+ title: item.title ?? item.semantic.id,
9378
+ error: executionError instanceof Error ? executionError.message : String(executionError),
9379
+ trustState: 'review_required',
9380
+ repair: repaired.repair,
9381
+ artifact: {
9382
+ version: 1,
9383
+ sourceKind: 'semantic_query',
9384
+ name: item.title ?? item.semantic.id,
9385
+ sql: composed.sql.slice(0, 40_000),
9386
+ trustState: 'review_required',
9387
+ explanation: ['Automatic repair could not execute the governed semantic query safely.'],
9388
+ executionTarget: executionTargetDescriptor(body),
9389
+ },
9390
+ });
9391
+ continue;
9392
+ }
9393
+ normalizedResult = normalizeQueryResult(repaired.result);
9394
+ repair = repaired.repair;
9395
+ executedSql = repaired.repairedSql;
9396
+ }
8126
9397
  tiles.push({
8127
9398
  tileId: item.i,
8128
9399
  status: 'ok',
8129
9400
  tileType: 'semantic',
8130
9401
  title: item.title ?? item.semantic.id,
8131
9402
  viz: item.viz,
8132
- result: normalizeQueryResult(result),
9403
+ result: normalizedResult,
8133
9404
  trustState: 'review_required',
8134
- citation: { kind: 'semantic_query', name: item.semantic.id },
9405
+ citation: { kind: repair ? 'semantic_query_repaired' : 'semantic_query', name: item.semantic.id },
9406
+ ...(repair ? { repair } : {}),
9407
+ artifact: {
9408
+ version: 1,
9409
+ sourceKind: 'semantic_query',
9410
+ name: item.title ?? item.semantic.id,
9411
+ sql: executedSql.slice(0, 40_000),
9412
+ trustState: 'review_required',
9413
+ explanation: [
9414
+ `Metrics: ${item.semantic.metrics.join(', ') || 'none'}.`,
9415
+ `Dimensions: ${item.semantic.dimensions?.join(', ') || 'none'}.`,
9416
+ repair ? 'The composed SQL needed bounded repair and therefore remains review-required.' : 'The governed semantic intent was composed for the active warehouse and remains review-required until approval.',
9417
+ ],
9418
+ executionTarget: executionTargetDescriptor(body),
9419
+ },
8135
9420
  });
8136
9421
  }
8137
9422
  catch (err) {
@@ -8156,6 +9441,11 @@ export async function startLocalServer(opts) {
8156
9441
  });
8157
9442
  continue;
8158
9443
  }
9444
+ let blockSource;
9445
+ let blockTargetConnection;
9446
+ let blockPlan;
9447
+ let blockFilterApplication;
9448
+ let blockInvocation;
8159
9449
  try {
8160
9450
  assertAppAccess({
8161
9451
  app: loaded.app,
@@ -8163,55 +9453,55 @@ export async function startLocalServer(opts) {
8163
9453
  level: 'execute',
8164
9454
  });
8165
9455
  const absBlockPath = join(projectRoot, block.filePath);
8166
- const source = readFileSync(absBlockPath, 'utf-8');
8167
- const targetConnection = isConnectionConfig(body.connection) ? body.connection : connection;
8168
- const tableMapping = await resolveSemanticTableMapping(executor, targetConnection, semanticLayer, projectRoot);
9456
+ blockSource = readFileSync(absBlockPath, 'utf-8');
9457
+ blockTargetConnection = requireActiveConnection(isConnectionConfig(body.connection) ? body.connection : connection);
9458
+ const tableMapping = await resolveSemanticTableMapping(executor, blockTargetConnection, semanticLayer, projectRoot);
8169
9459
  const boundParameters = dashboardTileParameterValues({
8170
9460
  item,
8171
9461
  dashboardValues: dashboardVariables,
8172
9462
  requestValues: variables,
8173
9463
  });
8174
- const invocation = prepareBlockInvocation({
9464
+ blockInvocation = prepareBlockInvocation({
8175
9465
  block: block.name,
8176
- source,
9466
+ source: blockSource,
8177
9467
  parameters: { ...dashboardVariables, ...boundParameters },
8178
9468
  parameterSources: Object.fromEntries(Object.keys(boundParameters).map((name) => [name, 'surface'])),
8179
9469
  surface: 'app',
8180
9470
  });
8181
- if (invocation.errors.length || invocation.unresolvedParameters.length) {
9471
+ if (blockInvocation.errors.length || blockInvocation.unresolvedParameters.length) {
8182
9472
  tiles.push({
8183
9473
  tileId: item.i,
8184
9474
  status: 'unresolved',
8185
9475
  tileType: 'block',
8186
9476
  blockId: block.name,
8187
9477
  title: item.title ?? block.name,
8188
- error: invocation.errors[0] ?? `Needs values for: ${invocation.unresolvedParameters.join(', ')}`,
9478
+ error: blockInvocation.errors[0] ?? `Needs values for: ${blockInvocation.unresolvedParameters.join(', ')}`,
8189
9479
  invocation: {
8190
- resolvedParameters: invocation.resolvedParameters,
8191
- unresolvedParameters: invocation.unresolvedParameters,
8192
- auditId: invocation.auditId,
9480
+ resolvedParameters: blockInvocation.resolvedParameters,
9481
+ unresolvedParameters: blockInvocation.unresolvedParameters,
9482
+ auditId: blockInvocation.auditId,
8193
9483
  },
8194
9484
  });
8195
9485
  continue;
8196
9486
  }
8197
9487
  const semanticCompose = semanticLayer
8198
- ? await composeSemanticBlockSqlForRuntime(source, semanticLayer, {
8199
- driver: targetConnection.driver,
9488
+ ? await composeSemanticBlockSqlForRuntime(blockSource, semanticLayer, {
9489
+ driver: blockTargetConnection.driver,
8200
9490
  tableMapping,
8201
9491
  projectRoot,
8202
9492
  projectConfig,
8203
9493
  detectedProvider: semanticDetectedProvider,
8204
- parameters: invocation.values,
9494
+ parameters: blockInvocation.values,
8205
9495
  })
8206
9496
  : null;
8207
- const plan = buildExecutionPlan({ id: item.i, type: 'dql', source, title: item.title ?? block.name }, {
9497
+ blockPlan = buildExecutionPlan({ id: item.i, type: 'dql', source: blockSource, title: item.title ?? block.name }, {
8208
9498
  semanticLayer,
8209
- driver: targetConnection.driver,
9499
+ driver: blockTargetConnection.driver,
8210
9500
  tableMapping,
8211
- parameters: invocation.values,
9501
+ parameters: blockInvocation.values,
8212
9502
  semanticSql: semanticCompose?.sql ?? undefined,
8213
9503
  });
8214
- if (!plan && !semanticCompose?.sql) {
9504
+ if (!blockPlan && !semanticCompose?.sql) {
8215
9505
  tiles.push({
8216
9506
  tileId: item.i,
8217
9507
  status: 'error',
@@ -8220,15 +9510,15 @@ export async function startLocalServer(opts) {
8220
9510
  });
8221
9511
  continue;
8222
9512
  }
8223
- const filterApplication = applyDashboardFiltersToBlockExecution({
8224
- sql: semanticCompose?.sql ?? plan.sql,
8225
- sqlParams: plan?.sqlParams ?? [],
8226
- variables: { ...(plan?.variables ?? {}), ...dashboardVariables },
9513
+ blockFilterApplication = applyDashboardFiltersToBlockExecution({
9514
+ sql: semanticCompose?.sql ?? blockPlan.sql,
9515
+ sqlParams: blockPlan?.sqlParams ?? [],
9516
+ variables: { ...(blockPlan?.variables ?? {}), ...dashboardVariables },
8227
9517
  block,
8228
9518
  dashboard: loaded.dashboard,
8229
9519
  });
8230
- const prepared = prepareLocalExecution(filterApplication.sql, targetConnection, projectRoot, projectConfig);
8231
- const result = await executor.executeQuery(prepared.sql, filterApplication.sqlParams, runtimeVariables({ ...filterApplication.variables, ...invocation.values }), prepared.connection);
9520
+ const prepared = prepareLocalExecution(blockFilterApplication.sql, blockTargetConnection, projectRoot, projectConfig);
9521
+ const result = await executor.executeQuery(prepared.sql, blockFilterApplication.sqlParams, runtimeVariables({ ...blockFilterApplication.variables, ...blockInvocation.values }), prepared.connection);
8232
9522
  tiles.push({
8233
9523
  tileId: item.i,
8234
9524
  status: 'ok',
@@ -8237,22 +9527,37 @@ export async function startLocalServer(opts) {
8237
9527
  certificationStatus: block.status ?? null,
8238
9528
  title: item.title ?? block.name,
8239
9529
  viz: item.viz,
8240
- chartConfig: mergeDashboardChartConfig(plan?.chartConfig, item),
9530
+ chartConfig: mergeDashboardChartConfig(blockPlan?.chartConfig, item),
8241
9531
  result: normalizeQueryResult(result),
8242
9532
  filters: {
8243
- applied: filterApplication.appliedFilters,
8244
- skipped: filterApplication.skippedFilters,
9533
+ applied: blockFilterApplication.appliedFilters,
9534
+ skipped: blockFilterApplication.skippedFilters,
8245
9535
  },
8246
9536
  invocation: {
8247
- resolvedParameters: invocation.resolvedParameters,
8248
- unresolvedParameters: invocation.unresolvedParameters,
8249
- auditId: invocation.auditId,
9537
+ resolvedParameters: blockInvocation.resolvedParameters,
9538
+ unresolvedParameters: blockInvocation.unresolvedParameters,
9539
+ auditId: blockInvocation.auditId,
8250
9540
  },
9541
+ trustState: block.status === 'certified' ? 'certified' : 'review_required',
8251
9542
  citation: {
8252
9543
  kind: 'block',
8253
9544
  name: block.name,
8254
9545
  path: block.filePath,
8255
9546
  },
9547
+ artifact: {
9548
+ version: 1,
9549
+ sourceKind: 'certified_block',
9550
+ name: block.name,
9551
+ sourcePath: block.filePath,
9552
+ dql: blockSource.slice(0, 40_000),
9553
+ sql: blockFilterApplication.sql.slice(0, 40_000),
9554
+ trustState: block.status === 'certified' ? 'certified' : 'review_required',
9555
+ explanation: [
9556
+ block.status === 'certified' ? 'This tile executed a certified project block.' : 'This project block is not certified and remains review-required.',
9557
+ 'Dashboard filters and typed parameters were bound before read-only execution.',
9558
+ ],
9559
+ executionTarget: executionTargetDescriptor(body),
9560
+ },
8256
9561
  });
8257
9562
  }
8258
9563
  catch (err) {
@@ -8265,11 +9570,87 @@ export async function startLocalServer(opts) {
8265
9570
  });
8266
9571
  }
8267
9572
  else {
9573
+ const repaired = await repairFailedAppTileExecution({
9574
+ source: 'certified_block',
9575
+ question: item.title ?? block.name,
9576
+ sourceSql: blockFilterApplication?.sql ?? blockPlan?.sql,
9577
+ sourceDql: blockSource,
9578
+ artifactName: block.name,
9579
+ error: err,
9580
+ targetConnection: blockTargetConnection,
9581
+ targetConnectionName: projectConfig.defaultConnectionName,
9582
+ sqlParams: blockFilterApplication?.sqlParams ?? blockPlan?.sqlParams,
9583
+ variables: {
9584
+ ...(blockPlan?.variables ?? {}),
9585
+ ...(blockFilterApplication?.variables ?? {}),
9586
+ ...(blockInvocation?.values ?? {}),
9587
+ },
9588
+ });
9589
+ if (repaired.ok) {
9590
+ tiles.push({
9591
+ tileId: item.i,
9592
+ status: 'ok',
9593
+ tileType: 'block',
9594
+ blockId: block.name,
9595
+ blockPath: block.filePath,
9596
+ certificationStatus: block.status ?? null,
9597
+ title: item.title ?? block.name,
9598
+ viz: item.viz,
9599
+ chartConfig: mergeDashboardChartConfig(blockPlan?.chartConfig, item),
9600
+ result: normalizeQueryResult(repaired.result),
9601
+ ...(blockFilterApplication ? {
9602
+ filters: {
9603
+ applied: blockFilterApplication.appliedFilters,
9604
+ skipped: blockFilterApplication.skippedFilters,
9605
+ },
9606
+ } : {}),
9607
+ ...(blockInvocation ? {
9608
+ invocation: {
9609
+ resolvedParameters: blockInvocation.resolvedParameters,
9610
+ unresolvedParameters: blockInvocation.unresolvedParameters,
9611
+ auditId: blockInvocation.auditId,
9612
+ },
9613
+ } : {}),
9614
+ trustState: 'review_required',
9615
+ repair: repaired.repair,
9616
+ citation: { kind: 'block_repaired', name: block.name, path: block.filePath },
9617
+ artifact: {
9618
+ version: 1,
9619
+ sourceKind: 'certified_block',
9620
+ name: block.name,
9621
+ sourcePath: block.filePath,
9622
+ ...(repaired.repairedDql ?? blockSource ? { dql: (repaired.repairedDql ?? blockSource).slice(0, 40_000) } : {}),
9623
+ sql: repaired.repairedSql.slice(0, 40_000),
9624
+ trustState: 'review_required',
9625
+ explanation: [
9626
+ 'The saved certified block was not changed.',
9627
+ 'A bounded repair executed on the original data target; this repaired result requires human review.',
9628
+ ],
9629
+ executionTarget: executionTargetDescriptor(body),
9630
+ },
9631
+ });
9632
+ continue;
9633
+ }
8268
9634
  tiles.push({
8269
9635
  tileId: item.i,
8270
9636
  status: 'error',
9637
+ tileType: 'block',
8271
9638
  blockId: block.name,
8272
9639
  error: err instanceof Error ? err.message : String(err),
9640
+ repair: repaired.repair,
9641
+ ...(blockSource ? {
9642
+ artifact: {
9643
+ version: 1,
9644
+ sourceKind: 'certified_block',
9645
+ name: block.name,
9646
+ sourcePath: block.filePath,
9647
+ dql: blockSource.slice(0, 40_000),
9648
+ ...(blockFilterApplication?.sql ?? blockPlan?.sql ? { sql: (blockFilterApplication?.sql ?? blockPlan.sql).slice(0, 40_000) } : {}),
9649
+ trustState: 'review_required',
9650
+ explanation: ['Automatic repair could not produce a safe executable result. The saved block was not changed.'],
9651
+ executionTarget: executionTargetDescriptor(body),
9652
+ },
9653
+ } : {}),
8273
9654
  });
8274
9655
  }
8275
9656
  }
@@ -8304,6 +9685,10 @@ export async function startLocalServer(opts) {
8304
9685
  filterFingerprint,
8305
9686
  resultFingerprint,
8306
9687
  personaFingerprint,
9688
+ successfulTileIds: tiles.filter((tile) => tile.status === 'ok').map((tile) => tile.tileId),
9689
+ semanticApprovalEligibleTileIds: tiles.filter((tile) => (tile.status === 'ok'
9690
+ && tile.tileType === 'semantic'
9691
+ && (!('repair' in tile) || tile.repair?.approvalEligible !== false))).map((tile) => tile.tileId),
8307
9692
  facts: storyResult.facts,
8308
9693
  story: storyResult.story,
8309
9694
  expiresAt: Date.now() + 15 * 60_000,
@@ -8355,7 +9740,30 @@ export async function startLocalServer(opts) {
8355
9740
  // Two-phase app build gap-fill: bounded governed answers for uncovered
8356
9741
  // questions. Throws when no provider is configured — propose degrades
8357
9742
  // gracefully by listing the gap instead.
8358
- generateGovernedAnswer: (question) => runGovernedAgentAnswerForRun({ question }),
9743
+ generateGovernedAnswer: (question, appContext) => generateAppBuildAnswerWithRepair(question, appContext),
9744
+ queueOperation: (input, task) => {
9745
+ const operation = operationCoordinator.create(input);
9746
+ void operationCoordinator.run(operation.id, task);
9747
+ return operation;
9748
+ },
9749
+ scheduleProjectRefresh: (reason) => { scheduleProjectRefresh(reason); },
9750
+ verifyDashboardRun: ({ runId, appId, dashboardId, tileIds }) => {
9751
+ const evidence = dashboardRunEvidence.get(runId);
9752
+ if (!evidence || evidence.expiresAt < Date.now())
9753
+ return { ok: false, error: 'The dashboard run receipt expired. Run the dashboard again.' };
9754
+ if (evidence.appId !== appId || evidence.dashboardId !== dashboardId)
9755
+ return { ok: false, error: 'The dashboard run receipt does not match this App page.' };
9756
+ const successful = new Set(evidence.successfulTileIds);
9757
+ const failed = tileIds.filter((tileId) => !successful.has(tileId));
9758
+ if (failed.length > 0)
9759
+ return { ok: false, error: `These semantic tiles did not complete successfully: ${failed.join(', ')}` };
9760
+ const approvalEligible = new Set(evidence.semanticApprovalEligibleTileIds);
9761
+ const repaired = tileIds.filter((tileId) => !approvalEligible.has(tileId));
9762
+ if (repaired.length > 0) {
9763
+ return { ok: false, error: `These semantic previews required SQL repair and cannot be approved as governed semantic results: ${repaired.join(', ')}. Review them as exploratory App analysis instead.` };
9764
+ }
9765
+ return { ok: true, snapshotId: evidence.snapshotId, resultFingerprint: evidence.resultFingerprint };
9766
+ },
8359
9767
  // Story narration for commit — LLM-backed with deterministic fallback.
8360
9768
  narrate: narrateForAgentRun,
8361
9769
  });
@@ -8531,13 +9939,21 @@ export async function startLocalServer(opts) {
8531
9939
  res.end(serializeJSON({ error: 'Invalid path' }));
8532
9940
  return;
8533
9941
  }
8534
- mkdirSync(dirname(absPath), { recursive: true });
9942
+ await mkdirAsync(dirname(absPath), { recursive: true });
8535
9943
  const toWrite = absPath.endsWith('.dql')
8536
9944
  ? canonicalizeSafe(content)
8537
9945
  : absPath.endsWith('.dqlnb')
8538
9946
  ? canonicalizeNotebookSafe(content)
8539
9947
  : content;
8540
- writeFileSync(absPath, toWrite, 'utf-8');
9948
+ const tempPath = `${absPath}.tmp-${process.pid}-${Date.now()}`;
9949
+ try {
9950
+ await writeFileAsync(tempPath, toWrite, 'utf-8');
9951
+ await renameAsync(tempPath, absPath);
9952
+ }
9953
+ catch (error) {
9954
+ await rmAsync(tempPath, { force: true }).catch(() => undefined);
9955
+ throw error;
9956
+ }
8541
9957
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
8542
9958
  res.end(serializeJSON({ ok: true }));
8543
9959
  }
@@ -9835,12 +11251,185 @@ export async function startLocalServer(opts) {
9835
11251
  passed: assertion.passed,
9836
11252
  actual: assertion.error ?? (assertion.actual !== undefined ? String(assertion.actual) : undefined),
9837
11253
  }));
9838
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
9839
- res.end(serializeJSON({ assertions: results, passed: summary.passed, failed: summary.failed, duration: summary.duration }));
11254
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
11255
+ res.end(serializeJSON({ assertions: results, passed: summary.passed, failed: summary.failed, duration: summary.duration }));
11256
+ }
11257
+ catch (error) {
11258
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
11259
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
11260
+ }
11261
+ return;
11262
+ }
11263
+ if (req.method === 'POST' && path === '/api/block-studio/certifications') {
11264
+ try {
11265
+ const body = await readJSON(req);
11266
+ const source = typeof body.source === 'string' ? body.source : '';
11267
+ const suppliedMetadata = body.metadata && typeof body.metadata === 'object'
11268
+ ? body.metadata
11269
+ : {};
11270
+ const currentPath = typeof body.path === 'string'
11271
+ ? normalize(body.path).replace(/^\/+/, '').replaceAll('\\', '/')
11272
+ : undefined;
11273
+ const expectedSourceFingerprint = typeof body.expectedSourceFingerprint === 'string'
11274
+ ? body.expectedSourceFingerprint
11275
+ : undefined;
11276
+ const idempotencyKey = typeof req.headers['idempotency-key'] === 'string'
11277
+ ? req.headers['idempotency-key'].trim()
11278
+ : '';
11279
+ if (!source.trim()) {
11280
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
11281
+ res.end(serializeJSON({ error: 'source is required' }));
11282
+ return;
11283
+ }
11284
+ if (idempotencyKey) {
11285
+ const existingId = operationIdempotency.get(idempotencyKey);
11286
+ const existing = existingId ? operationCoordinator.get(existingId) : null;
11287
+ if (existing) {
11288
+ res.writeHead(202, { 'Content-Type': 'application/json; charset=utf-8' });
11289
+ res.end(serializeJSON({ operation: existing, deduplicated: true }));
11290
+ return;
11291
+ }
11292
+ }
11293
+ if (currentPath && expectedSourceFingerprint) {
11294
+ const absoluteCurrentPath = safeJoin(projectRoot, currentPath);
11295
+ if (!absoluteCurrentPath || !existsSync(absoluteCurrentPath)) {
11296
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
11297
+ res.end(serializeJSON({ error: 'The block changed or moved. Reopen it before certifying.', code: 'SOURCE_CHANGED' }));
11298
+ return;
11299
+ }
11300
+ const actualFingerprint = createHash('sha256')
11301
+ .update(readFileSync(absoluteCurrentPath, 'utf-8'))
11302
+ .digest('hex');
11303
+ if (actualFingerprint !== expectedSourceFingerprint) {
11304
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
11305
+ res.end(serializeJSON({ error: 'The block changed after it was opened. Reopen it before certifying.', code: 'SOURCE_CHANGED' }));
11306
+ return;
11307
+ }
11308
+ }
11309
+ const draftSource = setBlockStudioStatusInSource(source, 'draft');
11310
+ const parsed = parseBlockSourceMetadata(draftSource);
11311
+ const name = typeof suppliedMetadata.name === 'string' && suppliedMetadata.name.trim()
11312
+ ? suppliedMetadata.name.trim()
11313
+ : parsed.name;
11314
+ if (!name) {
11315
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
11316
+ res.end(serializeJSON({ error: 'Block name is required.' }));
11317
+ return;
11318
+ }
11319
+ const canonicalPath = currentPath && !isDraftBlockPath(currentPath) ? currentPath : undefined;
11320
+ const stableSuffix = createHash('sha256')
11321
+ .update(currentPath ?? `${name}:${draftSource}`)
11322
+ .digest('hex')
11323
+ .slice(0, 16);
11324
+ const draftPath = saveBlockStudioDraftArtifacts(projectRoot, {
11325
+ currentPath: currentPath && isDraftBlockPath(currentPath) ? currentPath : undefined,
11326
+ source: draftSource,
11327
+ name,
11328
+ domain: typeof suppliedMetadata.domain === 'string' ? suppliedMetadata.domain : parsed.domain,
11329
+ description: typeof suppliedMetadata.description === 'string' ? suppliedMetadata.description : parsed.description,
11330
+ owner: typeof suppliedMetadata.owner === 'string' ? suppliedMetadata.owner : parsed.owner,
11331
+ tags: Array.isArray(suppliedMetadata.tags) ? suppliedMetadata.tags.map(String) : parsed.tags,
11332
+ lineage: Array.isArray(suppliedMetadata.lineage) ? suppliedMetadata.lineage.map(String) : undefined,
11333
+ stableSuffix,
11334
+ });
11335
+ const operation = operationCoordinator.create({
11336
+ type: 'block_certification',
11337
+ scope: `block:${draftPath}`,
11338
+ resourceRevision: typeof body.clientRevision === 'string'
11339
+ ? body.clientRevision
11340
+ : createHash('sha256').update(draftSource).digest('hex'),
11341
+ message: `Certification queued for ${name}.`,
11342
+ cancellable: true,
11343
+ });
11344
+ if (idempotencyKey) {
11345
+ operationIdempotency.set(idempotencyKey, operation.id);
11346
+ while (operationIdempotency.size > 100) {
11347
+ const oldest = operationIdempotency.keys().next().value;
11348
+ if (typeof oldest !== 'string')
11349
+ break;
11350
+ operationIdempotency.delete(oldest);
11351
+ }
11352
+ }
11353
+ operationCoordinator.run(operation.id, async (signal, report) => {
11354
+ report({ phase: 'validating', progress: 15, message: 'Validating and running the saved draft once.' });
11355
+ const result = await certifyBlockStudioSource(draftSource, draftPath, { enterprise: body.enterprise === true });
11356
+ const blockers = Array.from(new Set(result.checklist.blockers));
11357
+ if (signal.aborted)
11358
+ throw Object.assign(new Error('Certification cancelled.'), { code: 'OPERATION_CANCELLED' });
11359
+ if (!result.certification.certified || blockers.length > 0) {
11360
+ return {
11361
+ outcome: 'draft_saved_with_blockers',
11362
+ oldPath: currentPath ?? draftPath,
11363
+ draftPath,
11364
+ blockers,
11365
+ checklist: result.checklist,
11366
+ block: openBlockStudioDocument(projectRoot, draftPath, semanticLayer),
11367
+ };
11368
+ }
11369
+ report({ phase: 'publishing', progress: 80, message: 'Publishing the certified block.' });
11370
+ const certifiedSource = setBlockStudioStatusInSource(draftSource, 'certified');
11371
+ const certifiedMetadata = parseBlockSourceMetadata(certifiedSource);
11372
+ const savedPath = saveBlockStudioArtifacts(projectRoot, {
11373
+ currentPath: canonicalPath ?? draftPath,
11374
+ source: certifiedSource,
11375
+ name: certifiedMetadata.name || name,
11376
+ domain: certifiedMetadata.domain,
11377
+ description: certifiedMetadata.description,
11378
+ owner: certifiedMetadata.owner,
11379
+ tags: certifiedMetadata.tags,
11380
+ });
11381
+ if (canonicalPath && draftPath !== savedPath) {
11382
+ try {
11383
+ deleteBlockStudioArtifacts(projectRoot, draftPath);
11384
+ }
11385
+ catch { /* certified source is already durable */ }
11386
+ }
11387
+ // Shared learning remains the same deterministic, advisory write used
11388
+ // by the synchronous compatibility route. It never gates publication.
11389
+ try {
11390
+ const learnedOutputs = Array.isArray(certifiedMetadata.outputs)
11391
+ ? certifiedMetadata.outputs.filter((output) => typeof output === 'string')
11392
+ : [];
11393
+ if (certifiedMetadata.name) {
11394
+ const memory = new MemoryStore(defaultMemoryPath(projectRoot));
11395
+ memory.upsert({
11396
+ id: `mem_certify_${certifiedMetadata.name}`,
11397
+ scope: 'project',
11398
+ title: `Certified block: ${certifiedMetadata.name}`,
11399
+ content: `Prefer the certified block "${certifiedMetadata.name}" for ${certifiedMetadata.description?.trim() || `questions in the ${certifiedMetadata.domain ?? 'analytics'} domain`}.${certifiedMetadata.grain ? ` Grain: ${certifiedMetadata.grain}.` : ''}${learnedOutputs.length ? ` Outputs: ${learnedOutputs.slice(0, 8).join(', ')}.` : ''} It is the trusted source — reuse it instead of generating new SQL.`,
11400
+ tags: [certifiedMetadata.domain, certifiedMetadata.name, ...learnedOutputs.slice(0, 4)].filter((value) => Boolean(value)),
11401
+ source: 'certify',
11402
+ confidence: 0.95,
11403
+ importance: 0.85,
11404
+ enabled: true,
11405
+ });
11406
+ }
11407
+ }
11408
+ catch {
11409
+ /* best-effort: learning capture must never block certification */
11410
+ }
11411
+ const refreshOperation = scheduleProjectRefresh('block-studio-certification');
11412
+ return {
11413
+ outcome: 'certified',
11414
+ oldPath: currentPath ?? draftPath,
11415
+ draftPath,
11416
+ newPath: savedPath,
11417
+ block: openBlockStudioDocument(projectRoot, savedPath, semanticLayer),
11418
+ checklist: result.checklist,
11419
+ indexRefresh: { status: 'queued', operationId: refreshOperation.id },
11420
+ };
11421
+ });
11422
+ res.writeHead(202, { 'Content-Type': 'application/json; charset=utf-8' });
11423
+ res.end(serializeJSON({
11424
+ operation: operationCoordinator.get(operation.id) ?? operation,
11425
+ draft: openBlockStudioDocument(projectRoot, draftPath, semanticLayer),
11426
+ }));
9840
11427
  }
9841
11428
  catch (error) {
9842
- res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
9843
- res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
11429
+ const code = apiErrorCode(error, 'CERTIFICATION_REQUEST_FAILED');
11430
+ const status = code === 'SOURCE_CHANGED' || apiErrorMessage(error) === 'BLOCK_EXISTS' ? 409 : 500;
11431
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
11432
+ res.end(serializeJSON({ error: apiErrorMessage(error), code }));
9844
11433
  }
9845
11434
  return;
9846
11435
  }
@@ -9913,7 +11502,7 @@ export async function startLocalServer(opts) {
9913
11502
  catch {
9914
11503
  /* best-effort: learning capture must never block certification */
9915
11504
  }
9916
- await refreshLocalMetadataCatalog(projectRoot);
11505
+ const refreshOperation = scheduleProjectRefresh('block-studio-certify');
9917
11506
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
9918
11507
  res.end(serializeJSON({
9919
11508
  ok: true,
@@ -9924,6 +11513,7 @@ export async function startLocalServer(opts) {
9924
11513
  metadata: certifiedPayload?.metadata,
9925
11514
  companionPath: certifiedPayload?.companionPath ?? null,
9926
11515
  validation: certifiedPayload?.validation ?? result.validation,
11516
+ lineageRefresh: { status: 'queued', operationId: refreshOperation.id },
9927
11517
  }));
9928
11518
  }
9929
11519
  catch (error) {
@@ -10747,6 +12337,20 @@ export async function startLocalServer(opts) {
10747
12337
  res.end(serializeJSON({ error: 'Block name is required.' }));
10748
12338
  return;
10749
12339
  }
12340
+ const validation = validateBlockStudioSource(source, semanticLayer);
12341
+ if (!blockStudioValidationAllowsDraftSave(validation)) {
12342
+ const errors = validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'
12343
+ && !BLOCK_STUDIO_DRAFT_RUNTIME_DIAGNOSTICS.has(diagnostic.code ?? ''));
12344
+ res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
12345
+ res.end(serializeJSON({
12346
+ error: errors[0]?.message ?? 'Fix the block errors before saving.',
12347
+ code: 'BLOCK_VALIDATION_FAILED',
12348
+ recoverable: true,
12349
+ details: { validation: { ...validation, saveable: false } },
12350
+ nextActions: ['Edit the DQL source', 'Fix with AI and run'],
12351
+ }));
12352
+ return;
12353
+ }
10750
12354
  const currentPath = typeof body.path === 'string' ? body.path : undefined;
10751
12355
  const saveOptions = {
10752
12356
  currentPath,
@@ -10773,25 +12377,13 @@ export async function startLocalServer(opts) {
10773
12377
  stableSuffix: metadata.candidateId,
10774
12378
  })
10775
12379
  : saveBlockStudioArtifacts(projectRoot, saveOptions);
10776
- let compiledManifest;
10777
- let lineageRefresh;
10778
- try {
10779
- compiledManifest = compileBlockStudioManifest(projectRoot, projectConfig);
10780
- lineageRefresh = { status: 'ready', compiledAt: new Date().toISOString() };
10781
- }
10782
- catch (error) {
10783
- // The block is already safely stored. Preserve it and report the
10784
- // rebuild failure separately instead of turning a successful save
10785
- // into a misleading 500 response.
10786
- lineageRefresh = {
10787
- status: 'failed',
10788
- message: error instanceof Error ? error.message : String(error),
10789
- };
10790
- }
10791
- await refreshLocalMetadataCatalog(projectRoot, compiledManifest, semanticLayer);
12380
+ const refreshOperation = scheduleProjectRefresh('block-studio-save');
10792
12381
  const payload = {
10793
12382
  ...openBlockStudioDocument(projectRoot, savedPath, semanticLayer),
10794
- lineageRefresh,
12383
+ lineageRefresh: {
12384
+ status: 'queued',
12385
+ operationId: refreshOperation.id,
12386
+ },
10795
12387
  };
10796
12388
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
10797
12389
  res.end(serializeJSON(payload));
@@ -10811,21 +12403,13 @@ export async function startLocalServer(opts) {
10811
12403
  try {
10812
12404
  const blockPath = url.searchParams.get('path');
10813
12405
  const deleted = deleteBlockStudioArtifacts(projectRoot, blockPath ?? '');
10814
- let compiledManifest;
10815
- let lineageRefresh;
10816
- try {
10817
- compiledManifest = compileBlockStudioManifest(projectRoot, projectConfig);
10818
- lineageRefresh = { status: 'ready', compiledAt: new Date().toISOString() };
10819
- }
10820
- catch (error) {
10821
- lineageRefresh = {
10822
- status: 'failed',
10823
- message: error instanceof Error ? error.message : String(error),
10824
- };
10825
- }
10826
- await refreshLocalMetadataCatalog(projectRoot, compiledManifest, semanticLayer);
12406
+ const refreshOperation = scheduleProjectRefresh('block-studio-delete');
10827
12407
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
10828
- res.end(serializeJSON({ ok: true, ...deleted, lineageRefresh }));
12408
+ res.end(serializeJSON({
12409
+ ok: true,
12410
+ ...deleted,
12411
+ lineageRefresh: { status: 'queued', operationId: refreshOperation.id },
12412
+ }));
10829
12413
  }
10830
12414
  catch (error) {
10831
12415
  const message = error instanceof Error ? error.message : String(error);
@@ -12422,11 +14006,12 @@ export async function startLocalServer(opts) {
12422
14006
  return;
12423
14007
  }
12424
14008
  /**
12425
- * Repair an editable notebook cell without starting Notebook AI research.
12426
- * The returned source is applied only to the in-memory notebook cell by the
12427
- * client; this endpoint never writes a block/domain/dbt source file.
14009
+ * Repair an editable notebook cell or Block Studio draft without starting
14010
+ * an AI conversation. The returned source is applied only to the in-memory
14011
+ * editor by the client; this endpoint never writes governed source files.
12428
14012
  */
12429
- if (req.method === 'POST' && path === '/api/notebook/repair-execution') {
14013
+ if (req.method === 'POST' && (path === '/api/notebook/repair-execution'
14014
+ || path === '/api/block-studio/repair-and-run')) {
12430
14015
  let body = {};
12431
14016
  let execContext = null;
12432
14017
  const start = Date.now();
@@ -12445,9 +14030,9 @@ export async function startLocalServer(opts) {
12445
14030
  return;
12446
14031
  }
12447
14032
  const embeddedSourceSql = notebookCellRepairSql(cell);
12448
- if (!embeddedSourceSql) {
14033
+ if (!embeddedSourceSql && (cell.type !== 'dql' || !notebookDqlSourceAllowsBackgroundRepair(cell.source))) {
12449
14034
  res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
12450
- res.end(serializeJSON({ error: 'Background repair supports SQL cells and DQL cells with an embedded query. Edit this semantic DQL definition directly.' }));
14035
+ res.end(serializeJSON({ error: 'Background repair supports SQL cells and editable custom DQL blocks. Open governed references or semantic definitions in their owning editor.' }));
12451
14036
  return;
12452
14037
  }
12453
14038
  if (cell.type === 'sql' && /\{\{[^}]+\}\}/.test(cell.source)) {
@@ -12480,31 +14065,33 @@ export async function startLocalServer(opts) {
12480
14065
  const parameters = body.parameters && typeof body.parameters === 'object' && !Array.isArray(body.parameters)
12481
14066
  ? body.parameters
12482
14067
  : {};
12483
- const invocation = cell.type === 'dql'
12484
- ? prepareBlockInvocation({ source: cell.source, parameters, surface: 'notebook' })
12485
- : null;
12486
- if (invocation?.errors.length) {
12487
- throw boundedRepairError(invocation.errors.join(' '), 409);
12488
- }
12489
- if (invocation?.unresolvedParameters.length) {
12490
- throw boundedRepairError(`Provide required parameter${invocation.unresolvedParameters.length === 1 ? '' : 's'} before retrying: ${invocation.unresolvedParameters.join(', ')}.`, 409);
14068
+ let invocation = null;
14069
+ let plan = null;
14070
+ let dqlCompilationError;
14071
+ if (cell.type === 'dql') {
14072
+ try {
14073
+ invocation = prepareBlockInvocation({ source: cell.source, parameters, surface: 'notebook' });
14074
+ if (invocation.errors.length)
14075
+ throw boundedRepairError(invocation.errors.join(' '), 409);
14076
+ if (invocation.unresolvedParameters.length) {
14077
+ throw boundedRepairError(`Provide required parameter${invocation.unresolvedParameters.length === 1 ? '' : 's'} before retrying: ${invocation.unresolvedParameters.join(', ')}.`, 409);
14078
+ }
14079
+ plan = buildExecutionPlan(cell, {
14080
+ semanticLayer,
14081
+ driver: targetConnection.driver,
14082
+ parameters: invocation.values,
14083
+ });
14084
+ }
14085
+ catch (error) {
14086
+ const repairError = error;
14087
+ if (repairError.status === 409)
14088
+ throw error;
14089
+ dqlCompilationError = repairError.message || String(error);
14090
+ }
12491
14091
  }
12492
- const plan = cell.type === 'dql'
12493
- ? buildExecutionPlan(cell, {
12494
- semanticLayer,
12495
- driver: targetConnection.driver,
12496
- parameters: invocation?.values,
12497
- })
12498
- : null;
12499
14092
  const attemptedSql = typeof body.attemptedSql === 'string' && body.attemptedSql.trim()
12500
14093
  ? body.attemptedSql.trim()
12501
14094
  : embeddedSourceSql;
12502
- const sourceSql = plan?.sql ?? attemptedSql;
12503
- const sqlParams = plan?.sqlParams ?? [];
12504
- const variables = {
12505
- ...(plan?.variables ?? {}),
12506
- ...(invocation?.values ?? {}),
12507
- };
12508
14095
  const executionTarget = executionTargetDescriptor(body);
12509
14096
  const targetConnectionName = executionTarget.target === 'connection'
12510
14097
  ? executionTarget.connectionName
@@ -12515,24 +14102,48 @@ export async function startLocalServer(opts) {
12515
14102
  `Repair notebook cell ${cell.title?.trim() || cell.id}.`,
12516
14103
  submittedError ? `Failure: ${submittedError.slice(0, 1_000)}` : '',
12517
14104
  ].filter(Boolean).join(' ');
12518
- const repaired = await executeBoundedSqlRepair({
12519
- question,
12520
- sourceSql,
12521
- targetConnection,
12522
- targetConnectionName,
12523
- sqlParams,
12524
- variables,
12525
- });
14105
+ let repaired;
12526
14106
  let repairedSource;
12527
- if (cell.type === 'dql') {
12528
- const originalQuery = notebookCellRepairSql(cell);
12529
- const repairedQuery = repaired.repairMode === 'deterministic'
12530
- ? applyNotebookRepairRewrites(originalQuery, repaired.rewrites)
12531
- : restoreNotebookDqlParameterInterpolations(repaired.repairedSql, sqlParams);
12532
- repairedSource = replaceNotebookDqlQueryForRepair(cell.source, repairedQuery);
14107
+ if (cell.type === 'dql' && dqlCompilationError) {
14108
+ const repairedDql = await executeBoundedDqlRepair({
14109
+ question,
14110
+ source: cell.source,
14111
+ failure: [submittedError, dqlCompilationError].filter(Boolean).join('\n'),
14112
+ targetConnection,
14113
+ targetConnectionName,
14114
+ parameters,
14115
+ artifactName: cell.title,
14116
+ });
14117
+ repaired = repairedDql;
14118
+ repairedSource = repairedDql.repairedSource;
12533
14119
  }
12534
14120
  else {
12535
- repairedSource = repaired.repairedSql;
14121
+ const sourceSql = plan?.sql ?? attemptedSql;
14122
+ if (!sourceSql?.trim()) {
14123
+ throw boundedRepairError('DQL could not identify an embedded query to repair.', 422);
14124
+ }
14125
+ const sqlParams = plan?.sqlParams ?? [];
14126
+ repaired = await executeBoundedSqlRepair({
14127
+ question,
14128
+ sourceSql,
14129
+ targetConnection,
14130
+ targetConnectionName,
14131
+ sqlParams,
14132
+ variables: {
14133
+ ...(plan?.variables ?? {}),
14134
+ ...(invocation?.values ?? {}),
14135
+ },
14136
+ });
14137
+ if (cell.type === 'dql') {
14138
+ const originalQuery = notebookCellRepairSql(cell);
14139
+ const repairedQuery = repaired.repairMode === 'deterministic'
14140
+ ? applyNotebookRepairRewrites(originalQuery, repaired.rewrites)
14141
+ : restoreNotebookDqlParameterInterpolations(repaired.repairedSql, sqlParams);
14142
+ repairedSource = replaceNotebookDqlQueryForRepair(cell.source, repairedQuery);
14143
+ }
14144
+ else {
14145
+ repairedSource = repaired.repairedSql;
14146
+ }
12536
14147
  }
12537
14148
  if (!repairedSource) {
12538
14149
  throw boundedRepairError('DQL could not safely replace the embedded query in this cell.', 422);
@@ -12543,12 +14154,13 @@ export async function startLocalServer(opts) {
12543
14154
  const validationPlan = buildExecutionPlan({ ...cell, source: repairedSource }, {
12544
14155
  semanticLayer,
12545
14156
  driver: targetConnection.driver,
12546
- parameters: invocation?.values,
14157
+ parameters: invocation?.values ?? parameters,
12547
14158
  });
12548
14159
  if (!validationPlan?.sql) {
12549
14160
  throw boundedRepairError('The repaired SQL ran, but the DQL wrapper no longer compiles.', 422);
12550
14161
  }
12551
- const beforeParameters = sqlParams.map((parameter) => `${parameter.position}:${parameter.name}`);
14162
+ const beforeParameters = (plan?.sqlParams ?? ('sqlParams' in repaired ? repaired.sqlParams : []))
14163
+ .map((parameter) => `${parameter.position}:${parameter.name}`);
12552
14164
  const afterParameters = validationPlan.sqlParams.map((parameter) => `${parameter.position}:${parameter.name}`);
12553
14165
  if (beforeParameters.join('|') !== afterParameters.join('|')) {
12554
14166
  throw boundedRepairError('The repaired DQL changed the cell parameter contract, so the update was stopped.', 422);
@@ -13814,6 +15426,22 @@ table: ${table}${tagList}
13814
15426
  })));
13815
15427
  });
13816
15428
  });
15429
+ server.once('close', () => {
15430
+ runtimeClosing = true;
15431
+ for (const watcher of projectWatchers)
15432
+ watcher.close();
15433
+ projectWatchers.length = 0;
15434
+ unsubscribeOperationEvents();
15435
+ projectRefreshCoordinator.close();
15436
+ for (const client of operationSseClients) {
15437
+ try {
15438
+ client.end();
15439
+ }
15440
+ catch { /* connection already closed */ }
15441
+ }
15442
+ operationSseClients.clear();
15443
+ operationCoordinator.close();
15444
+ });
13817
15445
  opts.captureServer?.(server);
13818
15446
  return new Promise((resolvePromise, reject) => {
13819
15447
  let retriedWithRandomPort = false;
@@ -14255,7 +15883,7 @@ function loadAppDashboard(projectRoot, appId, dashboardId) {
14255
15883
  for (const d of findDashboardsForApp(appDir)) {
14256
15884
  const { document: dashboard } = loadDashboardDocument(d);
14257
15885
  if (dashboard?.id === dashboardId)
14258
- return { app, dashboard };
15886
+ return { app, dashboard, appDir };
14259
15887
  }
14260
15888
  }
14261
15889
  return null;
@@ -14360,6 +15988,7 @@ function serializeSkill(skill) {
14360
15988
  clarifyWhen: skill.clarifyWhen,
14361
15989
  examples: skill.examples,
14362
15990
  sourceRefs: skill.sourceRefs,
15991
+ analyticalPolicy: skill.analyticalPolicy,
14363
15992
  vocabulary: skill.vocabulary,
14364
15993
  sourcePath: skill.sourcePath,
14365
15994
  isStarter: skill.isStarter,
@@ -14415,9 +16044,27 @@ function parseSkillInput(raw, fallbackId) {
14415
16044
  examples: asStrings(skill.examples),
14416
16045
  sourceRefs: asStrings(skill.sourceRefs),
14417
16046
  vocabulary: asMap(skill.vocabulary),
16047
+ analyticalPolicy: parseSkillAnalyticalPolicyInput(skill.analyticalPolicy),
14418
16048
  isStarter: skill.isStarter === true ? true : undefined,
14419
16049
  };
14420
16050
  }
16051
+ function parseSkillAnalyticalPolicyInput(value) {
16052
+ const policy = contextRecord(value);
16053
+ if (!Object.keys(policy).length)
16054
+ return undefined;
16055
+ const strings = (input) => Array.isArray(input) ? input.filter((item) => typeof item === 'string') : [];
16056
+ const pick = (input) => typeof input === 'string' && input.trim() ? input.trim() : undefined;
16057
+ return {
16058
+ metricIds: strings(policy.metricIds ?? policy.metric_ids),
16059
+ timeRole: pick(policy.timeRole ?? policy.time_role),
16060
+ calendarId: pick(policy.calendarId ?? policy.calendar_id),
16061
+ timezone: pick(policy.timezone),
16062
+ completenessPolicy: ['partial_current', 'latest_complete', 'closed_period'].includes(String(policy.completenessPolicy ?? policy.completeness_policy)) ? String(policy.completenessPolicy ?? policy.completeness_policy) : undefined,
16063
+ comparisonAlignment: ['elapsed_period', 'calendar_period', 'fiscal_period'].includes(String(policy.comparisonAlignment ?? policy.comparison_alignment)) ? String(policy.comparisonAlignment ?? policy.comparison_alignment) : undefined,
16064
+ defaultRankingPeriod: ['current', 'comparison'].includes(String(policy.defaultRankingPeriod ?? policy.default_ranking_period)) ? String(policy.defaultRankingPeriod ?? policy.default_ranking_period) : undefined,
16065
+ narrativeGuidance: strings(policy.narrativeGuidance ?? policy.narrative_guidance),
16066
+ };
16067
+ }
14421
16068
  /** Loose, case/slug-insensitive domain key for counting membership. */
14422
16069
  function domainKey(value) {
14423
16070
  if (!value)
@@ -14577,6 +16224,553 @@ export function parseDomainInput(raw, fallbackId) {
14577
16224
  sourcePath: typeof domain.sourcePath === 'string' ? domain.sourcePath : undefined,
14578
16225
  };
14579
16226
  }
16227
+ function modelingImportDirectory(projectRoot) {
16228
+ return join(projectRoot, '.dql', 'local', 'modeling-imports');
16229
+ }
16230
+ function modelingImportSessionPath(projectRoot, id) {
16231
+ const safe = id.replace(/[^a-zA-Z0-9_-]/g, '');
16232
+ if (!safe || safe !== id)
16233
+ throw new Error('Invalid modeling import id.');
16234
+ return join(modelingImportDirectory(projectRoot), `${safe}.json`);
16235
+ }
16236
+ function writeModelingImportSession(projectRoot, session) {
16237
+ mkdirSync(modelingImportDirectory(projectRoot), { recursive: true });
16238
+ writeFileSync(modelingImportSessionPath(projectRoot, session.id), `${JSON.stringify(session, null, 2)}\n`, 'utf8');
16239
+ }
16240
+ function readModelingImportSession(projectRoot, id) {
16241
+ const path = modelingImportSessionPath(projectRoot, id);
16242
+ if (!existsSync(path))
16243
+ return null;
16244
+ try {
16245
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
16246
+ return parsed.version === 1 && parsed.id === id ? parsed : null;
16247
+ }
16248
+ catch {
16249
+ return null;
16250
+ }
16251
+ }
16252
+ function publicModelingImportSession(session) {
16253
+ return {
16254
+ ...session,
16255
+ candidates: session.candidates.map(({ document: _document, ...candidate }) => candidate),
16256
+ };
16257
+ }
16258
+ function createModelingImportSession(projectRoot, gitRoot, loopback, source) {
16259
+ const mode = source.mode;
16260
+ if (mode !== 'current_project' && mode !== 'path' && mode !== 'upload' && mode !== 'paste')
16261
+ throw Object.assign(new Error('Choose current_project, path, upload, or paste.'), { code: 'INVALID_REQUEST' });
16262
+ const files = [];
16263
+ if (mode === 'paste' || mode === 'upload') {
16264
+ const content = typeof source.content === 'string' ? source.content : '';
16265
+ if (!content.trim() || Buffer.byteLength(content) > 1_000_000)
16266
+ throw Object.assign(new Error('YAML content is required and must be 1 MB or smaller.'), { code: 'MODELING_IMPORT_LIMIT' });
16267
+ files.push({ name: typeof source.filename === 'string' ? source.filename : mode === 'paste' ? 'pasted-modeling.yaml' : 'uploaded-modeling.yaml', content, inProject: false });
16268
+ }
16269
+ else {
16270
+ if (mode === 'path' && !loopback)
16271
+ throw Object.assign(new Error('Local path import is available only on the loopback Notebook runtime.'), { code: 'PATH_ACCESS_FORBIDDEN' });
16272
+ const allowedRoot = realpathSync(resolve(gitRoot ?? projectRoot));
16273
+ const requestedInput = mode === 'current_project'
16274
+ ? resolve(projectRoot)
16275
+ : resolve(projectRoot, typeof source.path === 'string' ? source.path : '');
16276
+ if (!existsSync(requestedInput))
16277
+ throw Object.assign(new Error('The selected YAML path does not exist.'), { code: 'MODELING_IMPORT_NOT_FOUND' });
16278
+ const requested = realpathSync(requestedInput);
16279
+ if (requested !== allowedRoot && !requested.startsWith(`${allowedRoot}${sep}`))
16280
+ throw Object.assign(new Error('Import path must stay inside the current Git workspace.'), { code: 'PATH_ACCESS_FORBIDDEN' });
16281
+ const paths = collectModelingYamlPaths(requested, 100);
16282
+ for (const path of paths) {
16283
+ const size = statSync(path).size;
16284
+ if (size > 1_000_000)
16285
+ continue;
16286
+ const resolvedPath = realpathSync(path);
16287
+ if (resolvedPath !== allowedRoot && !resolvedPath.startsWith(`${allowedRoot}${sep}`))
16288
+ continue;
16289
+ const resolvedProject = realpathSync(projectRoot);
16290
+ 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}`) });
16291
+ }
16292
+ }
16293
+ if (files.length === 0)
16294
+ throw Object.assign(new Error('No YAML files were found in the selected source.'), { code: 'MODELING_IMPORT_EMPTY' });
16295
+ const candidates = files.map((file, index) => classifyModelingYamlCandidate(file, index));
16296
+ const now = new Date().toISOString();
16297
+ const session = {
16298
+ version: 1,
16299
+ id: apiRequestId('import'),
16300
+ createdAt: now,
16301
+ updatedAt: now,
16302
+ source: { mode, label: mode === 'path' ? String(source.path ?? '') : mode === 'current_project' ? 'Current project' : files[0].name },
16303
+ candidates,
16304
+ };
16305
+ writeModelingImportSession(projectRoot, session);
16306
+ return session;
16307
+ }
16308
+ function collectModelingYamlPaths(start, limit) {
16309
+ if (!existsSync(start))
16310
+ throw Object.assign(new Error('The selected YAML path does not exist.'), { code: 'MODELING_IMPORT_NOT_FOUND' });
16311
+ if (statSync(start).isFile())
16312
+ return /\.ya?ml$/i.test(start) ? [start] : [];
16313
+ const output = [];
16314
+ const visit = (directory) => {
16315
+ if (output.length >= limit)
16316
+ return;
16317
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
16318
+ if (output.length >= limit)
16319
+ return;
16320
+ if (entry.name.startsWith('.') || ['node_modules', 'target', 'dist', 'build'].includes(entry.name))
16321
+ continue;
16322
+ const path = join(directory, entry.name);
16323
+ if (entry.isDirectory())
16324
+ visit(path);
16325
+ else if (entry.isFile() && /\.ya?ml$/i.test(entry.name))
16326
+ output.push(path);
16327
+ }
16328
+ };
16329
+ visit(start);
16330
+ return output.sort();
16331
+ }
16332
+ function classifyModelingYamlCandidate(file, index) {
16333
+ let document = {};
16334
+ try {
16335
+ const parsed = loadYaml(file.content);
16336
+ document = contextRecord(parsed);
16337
+ }
16338
+ catch (error) {
16339
+ 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)] };
16340
+ }
16341
+ const keys = new Set(Object.keys(document));
16342
+ const dialect = keys.has('entities') || keys.has('relationships') || keys.has('area') || keys.has('domain')
16343
+ ? 'dql_modeling'
16344
+ : keys.has('semantic_models') || keys.has('metrics')
16345
+ ? 'dbt_semantic'
16346
+ : keys.has('models') || keys.has('sources')
16347
+ ? 'dbt_resource'
16348
+ : 'unsupported';
16349
+ const objectCount = ['entities', 'relationships', 'models', 'sources', 'semantic_models', 'metrics']
16350
+ .reduce((total, key) => total + (Array.isArray(document[key]) ? document[key].length : 0), 0);
16351
+ const openExisting = file.inProject && dialect === 'dql_modeling' && Boolean(file.path?.startsWith('domains/'));
16352
+ return {
16353
+ id: `file-${index + 1}`,
16354
+ name: file.name,
16355
+ path: file.path,
16356
+ dialect,
16357
+ action: dialect === 'unsupported' ? 'unsupported' : openExisting ? 'open_existing' : 'import',
16358
+ summary: dialect === 'unsupported' ? 'Unsupported generic YAML' : `${objectCount || 1} ${dialect.replace('_', ' ')} candidate${objectCount === 1 ? '' : 's'}`,
16359
+ 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.'] : [],
16360
+ document,
16361
+ };
16362
+ }
16363
+ function modelingImportOperations(session, selectedIds, domain, areaId, manifest) {
16364
+ const diagnostics = [];
16365
+ const operations = [];
16366
+ const selected = session.candidates.filter((candidate) => selectedIds.includes(candidate.id));
16367
+ if (!selected.length)
16368
+ throw Object.assign(new Error('Select at least one supported YAML candidate.'), { code: 'MODELING_IMPORT_EMPTY' });
16369
+ const packageExists = Boolean(manifest.modeling?.packages?.[domain]);
16370
+ const area = Object.values(manifest.modeling?.areas ?? {}).find((item) => item.domain === domain && (item.localId === areaId || item.qualifiedId === areaId));
16371
+ if (!packageExists)
16372
+ diagnostics.push({ code: 'DOMAIN_NOT_FOUND', severity: 'blocking', message: `Domain not found: ${domain}` });
16373
+ if (!area)
16374
+ diagnostics.push({ code: 'MODEL_AREA_NOT_FOUND', severity: 'blocking', message: `Model Area not found in ${domain}: ${areaId}` });
16375
+ const nodes = Object.values(manifest.dbtProvenance?.nodes ?? {});
16376
+ const resolveDbt = (reference, operationId) => {
16377
+ const cleaned = reference.replace(/^ref\(['"]|['"]\)$/g, '').replace(/^model\./, '');
16378
+ const matches = nodes.filter((node) => node.uniqueId === reference || node.name === reference || node.name === cleaned || node.uniqueId.endsWith(`.${cleaned}`));
16379
+ if (matches.length !== 1)
16380
+ 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'}.` });
16381
+ return matches.length === 1 ? matches[0].uniqueId : undefined;
16382
+ };
16383
+ const used = new Set(Object.values(manifest.modeling?.entities ?? {}).filter((entity) => entity.domain === domain).map((entity) => entity.localId));
16384
+ const allocate = (value) => {
16385
+ const base = authoringSlug(value).replace(/-/g, '_') || 'model';
16386
+ let id = base;
16387
+ let suffix = 2;
16388
+ while (used.has(id))
16389
+ id = `${base}_${suffix++}`;
16390
+ used.add(id);
16391
+ return id;
16392
+ };
16393
+ for (const candidate of selected) {
16394
+ if (candidate.action === 'open_existing') {
16395
+ diagnostics.push({ code: 'OPEN_EXISTING_SOURCE', severity: 'info', message: `${candidate.path ?? candidate.name} is already in the current Domain Package and was not copied.` });
16396
+ continue;
16397
+ }
16398
+ if (candidate.dialect === 'unsupported' || !candidate.document) {
16399
+ diagnostics.push({ code: 'UNSUPPORTED_YAML', severity: 'blocking', message: `${candidate.name} is not DQL or dbt modeling YAML.` });
16400
+ continue;
16401
+ }
16402
+ const document = candidate.document;
16403
+ if (candidate.dialect === 'dql_modeling') {
16404
+ const importedEntities = new Map();
16405
+ for (const [index, raw] of contextArray(document.entities).entries()) {
16406
+ const entity = contextRecord(raw);
16407
+ const operationId = `${candidate.id}:entity:${index + 1}`;
16408
+ const dbtModel = resolveDbt(String(entity.dbt_model ?? entity.dbtModel ?? ''), operationId);
16409
+ if (!dbtModel)
16410
+ continue;
16411
+ const sourceId = String(entity.id ?? nodes.find((node) => node.uniqueId === dbtModel)?.name ?? `model_${index + 1}`);
16412
+ const id = allocate(sourceId);
16413
+ importedEntities.set(sourceId, { id, operationId });
16414
+ importedEntities.set(String(entity.qualified_id ?? entity.qualifiedId ?? ''), { id, operationId });
16415
+ importedEntities.set(dbtModel, { id, operationId });
16416
+ 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) } } });
16417
+ }
16418
+ for (const [index, raw] of contextArray(document.relationships).entries()) {
16419
+ const relationship = contextRecord(raw);
16420
+ const operationId = `${candidate.id}:relationship:${index + 1}`;
16421
+ const keys = contextArray(relationship.keys).flatMap((value) => {
16422
+ const key = contextRecord(value);
16423
+ return typeof key.from === 'string' && typeof key.to === 'string' ? [{ from: key.from, to: key.to }] : [];
16424
+ });
16425
+ if (!relationship.from || !relationship.to || !keys.length) {
16426
+ diagnostics.push({ code: 'RELATIONSHIP_INCOMPLETE', severity: 'blocking', operationId, message: 'Imported relationship requires from, to, and at least one key pair.' });
16427
+ continue;
16428
+ }
16429
+ const resolveEndpoint = (rawRef) => {
16430
+ const ref = String(rawRef);
16431
+ const imported = importedEntities.get(ref);
16432
+ if (imported)
16433
+ return { id: imported.id, dependency: imported.operationId };
16434
+ const matches = Object.values(manifest.modeling?.entities ?? {}).filter((entity) => entity.domain === domain && (entity.localId === ref || entity.id === ref || entity.qualifiedId === ref));
16435
+ return matches.length === 1 ? { id: matches[0].localId } : {};
16436
+ };
16437
+ const from = resolveEndpoint(relationship.from);
16438
+ const to = resolveEndpoint(relationship.to);
16439
+ if (!from.id || !to.id) {
16440
+ diagnostics.push({ code: 'RELATIONSHIP_REFERENCE_UNRESOLVED', severity: 'blocking', operationId, message: `Relationship endpoints must resolve inside ${domain}; simple import cannot create cross-domain relationships.` });
16441
+ continue;
16442
+ }
16443
+ 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.' } } });
16444
+ }
16445
+ }
16446
+ else {
16447
+ const modelRecords = candidate.dialect === 'dbt_resource'
16448
+ ? contextArray(document.models)
16449
+ : contextArray(document.semantic_models);
16450
+ for (const [index, raw] of modelRecords.entries()) {
16451
+ const model = contextRecord(raw);
16452
+ const operationId = `${candidate.id}:model:${index + 1}`;
16453
+ const reference = String(model.model ?? model.name ?? '');
16454
+ const dbtModel = resolveDbt(reference, operationId);
16455
+ if (!dbtModel)
16456
+ continue;
16457
+ const existing = Object.values(manifest.modeling?.entities ?? {}).find((entity) => entity.dbtUniqueId === dbtModel);
16458
+ if (existing && existing.domain !== domain) {
16459
+ diagnostics.push({ code: 'OWNERSHIP_CONFLICT', severity: 'blocking', operationId, message: `${dbtModel} is already owned by ${existing.domain}; import cannot move it implicitly.` });
16460
+ continue;
16461
+ }
16462
+ const id = existing?.localId ?? allocate(String(model.name ?? nodes.find((node) => node.uniqueId === dbtModel)?.name ?? `model_${index + 1}`));
16463
+ 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' } } });
16464
+ }
16465
+ }
16466
+ }
16467
+ if (!operations.length)
16468
+ throw Object.assign(new Error('The selected YAML did not produce any resolvable modeling changes.'), { code: 'MODELING_IMPORT_EMPTY', diagnostics });
16469
+ return { operations, diagnostics };
16470
+ }
16471
+ function contextRecord(value) {
16472
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
16473
+ }
16474
+ function contextArray(value) {
16475
+ return Array.isArray(value) ? value : [];
16476
+ }
16477
+ function contextStrings(value) {
16478
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
16479
+ }
16480
+ function parseImportedAnalyticalRole(value) {
16481
+ return value === 'event' || value === 'dimension' || value === 'snapshot' || value === 'bridge' ? value : 'unknown';
16482
+ }
16483
+ function parseImportedCardinality(value) {
16484
+ return value === 'one_to_one' || value === 'one_to_many' || value === 'many_to_one' || value === 'many_to_many' ? value : 'unknown';
16485
+ }
16486
+ function parseImportedFanout(value) {
16487
+ if (value === 'unsafe' || value === 'forbidden')
16488
+ return 'forbidden';
16489
+ return value === 'safe' || value === 'dedupe_required' || value === 'attribution_required' ? value : 'unknown';
16490
+ }
16491
+ function contextSourceReferenceExists(manifest, reference) {
16492
+ if (!reference.includes('::entity::') && !reference.includes('::relationship::'))
16493
+ return true;
16494
+ return Object.values(manifest.modeling?.entities ?? {}).some((entity) => entity.qualifiedId === reference)
16495
+ || Object.values(manifest.modeling?.relationships ?? {}).some((relationship) => relationship.qualifiedId === reference);
16496
+ }
16497
+ function parseContextAuthoringOrigin(value) {
16498
+ if (value === 'manual' || value === 'yaml_import' || value === 'dbt_discovery' || value === 'ai' || value === 'correction')
16499
+ return value;
16500
+ throw Object.assign(new Error('A valid context proposal origin is required.'), { code: 'INVALID_REQUEST' });
16501
+ }
16502
+ function parseContextAuthoringOperations(value) {
16503
+ if (!Array.isArray(value) || value.length === 0 || value.length > 100)
16504
+ throw Object.assign(new Error('Provide 1-100 authoring operations.'), { code: 'INVALID_REQUEST' });
16505
+ const ids = new Set();
16506
+ return value.map((raw, index) => {
16507
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
16508
+ throw Object.assign(new Error(`Operation ${index + 1} is invalid.`), { code: 'INVALID_REQUEST' });
16509
+ const operation = raw;
16510
+ const id = typeof operation.id === 'string' ? operation.id.trim() : '';
16511
+ if (!id || ids.has(id))
16512
+ throw Object.assign(new Error(`Operation ${index + 1} requires a unique id.`), { code: 'INVALID_REQUEST' });
16513
+ ids.add(id);
16514
+ const dependsOn = Array.isArray(operation.dependsOn) ? operation.dependsOn.filter((item) => typeof item === 'string') : undefined;
16515
+ const evidence = Array.isArray(operation.evidence) ? operation.evidence.filter((item) => typeof item === 'string').slice(0, 32) : undefined;
16516
+ if (operation.kind === 'modeling_change') {
16517
+ const change = operation.change;
16518
+ const allowed = new Set(['upsert_domain', 'upsert_area', 'upsert_entity', 'upsert_relationship', 'upsert_contract', 'upsert_export', 'upsert_import', 'remove_entity', 'remove_relationship', 'remove_area']);
16519
+ if (!change || typeof change !== 'object' || !allowed.has(change.operation) || !('value' in change) || !change.value || typeof change.value !== 'object')
16520
+ throw Object.assign(new Error(`Modeling operation ${id} is invalid.`), { code: 'INVALID_REQUEST' });
16521
+ return { id, kind: 'modeling_change', change, dependsOn, evidence };
16522
+ }
16523
+ if (operation.kind === 'skill_change') {
16524
+ const skillOperation = operation.operation;
16525
+ const skill = operation.value;
16526
+ if ((skillOperation !== 'create' && skillOperation !== 'update' && skillOperation !== 'move') || !skill || typeof skill.id !== 'string' || !skill.id.trim() || typeof skill.body !== 'string')
16527
+ throw Object.assign(new Error(`Skill operation ${id} is invalid.`), { code: 'INVALID_REQUEST' });
16528
+ return {
16529
+ id,
16530
+ kind: 'skill_change',
16531
+ operation: skillOperation,
16532
+ value: { ...skill, scope: 'project', status: 'draft' },
16533
+ targetQualifiedId: typeof operation.targetQualifiedId === 'string' ? operation.targetQualifiedId : undefined,
16534
+ expectedSourceHash: typeof operation.expectedSourceHash === 'string' ? operation.expectedSourceHash : undefined,
16535
+ dependsOn,
16536
+ evidence,
16537
+ };
16538
+ }
16539
+ if (operation.kind === 'dbt_source_change') {
16540
+ const change = operation.change;
16541
+ if (!change || typeof change.uniqueId !== 'string' || !change.uniqueId.trim())
16542
+ throw Object.assign(new Error(`dbt source operation ${id} is invalid.`), { code: 'INVALID_REQUEST' });
16543
+ return { id, kind: 'dbt_source_change', change, dependsOn, evidence };
16544
+ }
16545
+ throw Object.assign(new Error(`Unsupported context operation kind for ${id}.`), { code: 'INVALID_REQUEST' });
16546
+ });
16547
+ }
16548
+ function resolveContextPatchPath(projectRoot, gitRoot, relativePath) {
16549
+ const allowedRoot = resolve(gitRoot ?? projectRoot);
16550
+ const absolute = resolve(projectRoot, relativePath);
16551
+ if (absolute !== allowedRoot && !absolute.startsWith(`${allowedRoot}${sep}`)) {
16552
+ throw Object.assign(new Error(`Proposal path escapes the Git workspace: ${relativePath}`), { code: 'OWNERSHIP_CONFLICT' });
16553
+ }
16554
+ return absolute;
16555
+ }
16556
+ function buildMetadataBoundModelingOperations(projectRoot, manifest, request) {
16557
+ const modeling = manifest.modeling;
16558
+ if (!modeling || !manifest.dbtProvenance)
16559
+ throw new Error('Modeling AI requires a compiled dbt-first project snapshot.');
16560
+ const context = request.workspaceContext ?? {};
16561
+ const requestedDomain = typeof context.domain === 'string'
16562
+ ? context.domain
16563
+ : request.selectedObject?.kind === 'domain'
16564
+ ? request.selectedObject.id
16565
+ : undefined;
16566
+ const domain = requestedDomain ?? Object.keys(modeling.packages)[0];
16567
+ if (!domain || !modeling.packages[domain])
16568
+ throw new Error('Select or create a Domain before building models with AI.');
16569
+ const selectedIdentity = request.selectedObject?.id;
16570
+ const selectedRelationship = request.selectedObject?.kind === 'relationship'
16571
+ ? Object.values(modeling.relationships).find((item) => item.qualifiedId === selectedIdentity || item.id === selectedIdentity || item.localId === selectedIdentity)
16572
+ : undefined;
16573
+ if (selectedRelationship) {
16574
+ const cardinality = /one[-_ ]to[-_ ]one|1\s*:\s*1/i.test(request.question)
16575
+ ? 'one_to_one'
16576
+ : /one[-_ ]to[-_ ]many|1\s*:\s*n/i.test(request.question)
16577
+ ? 'one_to_many'
16578
+ : /many[-_ ]to[-_ ]one|n\s*:\s*1/i.test(request.question)
16579
+ ? 'many_to_one'
16580
+ : /many[-_ ]to[-_ ]many|n\s*:\s*n/i.test(request.question)
16581
+ ? 'many_to_many'
16582
+ : selectedRelationship.cardinality;
16583
+ return [{
16584
+ id: `relationship:${selectedRelationship.qualifiedId}`,
16585
+ kind: 'modeling_change',
16586
+ evidence: [selectedRelationship.sourcePath, ...(selectedRelationship.validation?.queryFingerprint ? [`validation:${selectedRelationship.validation.queryFingerprint}`] : [])],
16587
+ change: {
16588
+ operation: 'upsert_relationship',
16589
+ value: {
16590
+ id: selectedRelationship.localId,
16591
+ domain: selectedRelationship.ownerDomain ?? domain,
16592
+ areaId: selectedRelationship.areaId?.split('::area::').at(-1),
16593
+ from: modeling.entities[selectedRelationship.from]?.localId ?? selectedRelationship.from,
16594
+ to: modeling.entities[selectedRelationship.to]?.localId ?? selectedRelationship.to,
16595
+ keys: selectedRelationship.keys,
16596
+ cardinality,
16597
+ fanout: selectedRelationship.fanout === 'unsafe' ? 'forbidden' : selectedRelationship.fanout,
16598
+ status: 'draft',
16599
+ owner: selectedRelationship.owner,
16600
+ ownerDomain: selectedRelationship.ownerDomain,
16601
+ verb: selectedRelationship.verb,
16602
+ description: selectedRelationship.description,
16603
+ rationale: request.question.trim(),
16604
+ roles: selectedRelationship.roles,
16605
+ optionality: selectedRelationship.optionality,
16606
+ joinTypes: selectedRelationship.joinTypes,
16607
+ aggregation: selectedRelationship.aggregation,
16608
+ temporal: selectedRelationship.temporal,
16609
+ attributionBlock: selectedRelationship.attributionBlock,
16610
+ importRefs: selectedRelationship.importRefs,
16611
+ evidenceExpiresAt: undefined,
16612
+ },
16613
+ },
16614
+ }];
16615
+ }
16616
+ const selectedEntity = request.selectedObject?.kind === 'model'
16617
+ ? Object.values(modeling.entities).find((item) => item.qualifiedId === selectedIdentity || item.id === selectedIdentity || item.localId === selectedIdentity)
16618
+ : undefined;
16619
+ if (selectedEntity) {
16620
+ return [{
16621
+ id: `entity:${selectedEntity.qualifiedId}`,
16622
+ kind: 'modeling_change',
16623
+ evidence: [selectedEntity.sourcePath, selectedEntity.dbtUniqueId],
16624
+ change: {
16625
+ operation: 'upsert_entity',
16626
+ value: {
16627
+ id: selectedEntity.localId,
16628
+ domain: selectedEntity.domain,
16629
+ areaId: selectedEntity.areaId?.split('::area::').at(-1),
16630
+ dbtModel: selectedEntity.dbtUniqueId,
16631
+ businessName: selectedEntity.businessName,
16632
+ businessContext: request.question.trim(),
16633
+ conceptRefs: selectedEntity.conceptRefs,
16634
+ analyticalRole: selectedEntity.analyticalRole,
16635
+ status: 'draft',
16636
+ owner: selectedEntity.owner,
16637
+ grain: selectedEntity.grain,
16638
+ keys: selectedEntity.keys,
16639
+ },
16640
+ },
16641
+ }];
16642
+ }
16643
+ const requestedArea = typeof context.modelAreaId === 'string' ? context.modelAreaId : typeof context.areaId === 'string' ? context.areaId : undefined;
16644
+ const existingArea = Object.values(modeling.areas).find((area) => area.domain === domain
16645
+ && (!requestedArea || area.qualifiedId === requestedArea || area.localId === requestedArea));
16646
+ const areaId = existingArea?.localId ?? requestedArea?.split('::area::').at(-1) ?? `${authoringSlug(request.question).slice(0, 36) || 'metadata'}_models`;
16647
+ const operations = [];
16648
+ if (!existingArea) {
16649
+ operations.push({
16650
+ id: `area:${domain}:${areaId}`,
16651
+ kind: 'modeling_change',
16652
+ evidence: [`domain:${domain}`],
16653
+ 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()] } },
16654
+ });
16655
+ }
16656
+ const bound = new Set(Object.values(modeling.entities).map((entity) => entity.dbtUniqueId));
16657
+ const tokens = authoringTokens(request.question);
16658
+ let candidates = Object.values(manifest.dbtProvenance.nodes).filter((node) => !bound.has(node.uniqueId));
16659
+ const matched = candidates.filter((node) => tokens.some((token) => `${node.name} ${node.uniqueId} ${node.sourcePath ?? ''}`.toLowerCase().includes(token)));
16660
+ if (matched.length)
16661
+ candidates = matched;
16662
+ candidates = candidates.slice(0, 12);
16663
+ if (candidates.length === 0)
16664
+ throw new Error('No unbound dbt models match this request. Focus an existing model to update its business context.');
16665
+ const usedIds = new Set(Object.values(modeling.entities).filter((entity) => entity.domain === domain).map((entity) => entity.localId));
16666
+ for (const node of candidates) {
16667
+ const base = authoringSlug(node.name).replace(/-/g, '_') || 'model';
16668
+ let id = base;
16669
+ let suffix = 2;
16670
+ while (usedIds.has(id))
16671
+ id = `${base}_${suffix++}`;
16672
+ usedIds.add(id);
16673
+ operations.push({
16674
+ id: `entity:${domain}:${id}`,
16675
+ kind: 'modeling_change',
16676
+ dependsOn: existingArea ? [] : [`area:${domain}:${areaId}`],
16677
+ evidence: [node.uniqueId, ...(node.sourcePath ? [node.sourcePath] : [])],
16678
+ change: { operation: 'upsert_entity', value: { id, domain, areaId, dbtModel: node.uniqueId, businessName: titleCaseAuthoring(node.name), status: 'draft', analyticalRole: 'unknown' } },
16679
+ });
16680
+ }
16681
+ if (/\b(connect|relationship|join|related)\b/i.test(request.question) && candidates.length >= 2) {
16682
+ const manifestPath = resolveDbtManifestPath(projectRoot);
16683
+ const left = manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, candidates[0].uniqueId) : undefined;
16684
+ const right = manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, candidates[1].uniqueId) : undefined;
16685
+ const rightColumns = new Set((right?.columns ?? []).map((column) => column.name.toLowerCase()));
16686
+ const sharedKey = (left?.columns ?? []).map((column) => column.name).find((name) => /(^id$|_id$)/i.test(name) && rightColumns.has(name.toLowerCase()));
16687
+ if (sharedKey) {
16688
+ 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);
16689
+ 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);
16690
+ if (fromOperation?.kind === 'modeling_change' && fromOperation.change.operation === 'upsert_entity' && toOperation?.kind === 'modeling_change' && toOperation.change.operation === 'upsert_entity') {
16691
+ const relationshipId = `${fromOperation.change.value.id}_to_${toOperation.change.value.id}`;
16692
+ operations.push({
16693
+ id: `relationship:${domain}:${relationshipId}`,
16694
+ kind: 'modeling_change',
16695
+ dependsOn: [fromOperation.id, toOperation.id],
16696
+ evidence: [`shared column suggestion:${sharedKey}`, left?.uniqueId ?? '', right?.uniqueId ?? ''].filter(Boolean),
16697
+ 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.' } },
16698
+ });
16699
+ }
16700
+ }
16701
+ }
16702
+ return operations;
16703
+ }
16704
+ function buildMetadataBoundSkillOperations(projectRoot, manifest, request) {
16705
+ const context = request.workspaceContext ?? {};
16706
+ const selectedIdentity = request.selectedObject?.kind === 'skill' ? request.selectedObject.id : undefined;
16707
+ const existing = selectedIdentity
16708
+ ? loadSkills(projectRoot).skills.find((skill) => skill.qualifiedId === selectedIdentity || skill.id === selectedIdentity)
16709
+ : undefined;
16710
+ const domain = typeof context.domain === 'string'
16711
+ ? context.domain
16712
+ : existing?.domain
16713
+ ?? (request.selectedObject?.kind === 'domain' ? request.selectedObject.id : undefined)
16714
+ ?? Object.keys(manifest.modeling?.packages ?? {})[0];
16715
+ if (!domain)
16716
+ throw new Error('Select a Domain before building a Skill with AI.');
16717
+ const area = typeof context.modelAreaId === 'string' ? context.modelAreaId : existing?.modelAreaRefs?.[0];
16718
+ const focusReference = request.selectedObject?.kind === 'model' || request.selectedObject?.kind === 'relationship'
16719
+ ? request.selectedObject.id
16720
+ : undefined;
16721
+ if (focusReference && !contextSourceReferenceExists(manifest, focusReference))
16722
+ throw new Error('The focused modeling object is not present in the current snapshot.');
16723
+ const id = existing?.localId ?? existing?.id ?? `${authoringSlug(request.question).slice(0, 48) || domain.replace(/[^a-z0-9]+/gi, '-')}-guidance`;
16724
+ const body = existing
16725
+ ? `${existing.body.trim()}\n\n## Proposed correction\n${request.question.trim()}\n`
16726
+ : `## 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.`;
16727
+ const value = {
16728
+ id,
16729
+ qualifiedId: existing?.qualifiedId,
16730
+ sourcePath: existing?.sourcePath,
16731
+ scope: 'project',
16732
+ domain,
16733
+ domains: [domain],
16734
+ modelAreaRefs: area ? [area] : [],
16735
+ kind: existing?.kind ?? 'domain_reference',
16736
+ status: 'draft',
16737
+ owner: existing?.owner,
16738
+ triggers: existing?.triggers ?? authoringTokens(request.question).slice(0, 8),
16739
+ exclusions: existing?.exclusions ?? [],
16740
+ description: existing?.description ?? `Metadata-backed guidance for ${titleCaseAuthoring(domain)}.`,
16741
+ preferredMetrics: existing?.preferredMetrics ?? Object.values(manifest.metrics ?? {}).filter((metric) => metric.domain === domain).slice(0, 12).map((metric) => metric.name),
16742
+ preferredBlocks: existing?.preferredBlocks ?? Object.values(manifest.blocks ?? {}).filter((block) => block.domain === domain && block.status === 'certified').slice(0, 12).map((block) => block.name),
16743
+ preferredDimensions: existing?.preferredDimensions ?? [],
16744
+ requiredFilters: existing?.requiredFilters ?? [],
16745
+ clarifyWhen: existing?.clarifyWhen ?? ['The request needs a model, metric, or relationship not present in the current governed context.'],
16746
+ examples: existing?.examples ?? [request.question.trim()],
16747
+ sourceRefs: [...new Set([...(existing?.sourceRefs ?? []), ...(focusReference ? [focusReference] : [])])],
16748
+ vocabulary: existing?.vocabulary ?? {},
16749
+ analyticalPolicy: existing?.analyticalPolicy,
16750
+ body,
16751
+ isStarter: existing?.isStarter,
16752
+ };
16753
+ const before = existing?.sourcePath && existsSync(existing.sourcePath) ? readFileSync(existing.sourcePath, 'utf8') : '';
16754
+ return [{
16755
+ id: `skill:${existing?.qualifiedId ?? `${domain}::skill::${id}`}`,
16756
+ kind: 'skill_change',
16757
+ operation: existing ? 'update' : 'create',
16758
+ targetQualifiedId: existing?.qualifiedId,
16759
+ expectedSourceHash: existing ? createHash('sha256').update(before).digest('hex') : undefined,
16760
+ evidence: [existing?.sourcePath ?? `domain:${domain}`, ...(focusReference ? [focusReference] : [])],
16761
+ value,
16762
+ }];
16763
+ }
16764
+ function authoringTokens(value) {
16765
+ const stop = new Set(['the', 'and', 'for', 'with', 'from', 'this', 'that', 'build', 'create', 'model', 'models', 'skill', 'please', 'into', 'about']);
16766
+ return [...new Set(value.toLowerCase().split(/[^a-z0-9_]+/).filter((token) => token.length > 2 && !stop.has(token)))];
16767
+ }
16768
+ function authoringSlug(value) {
16769
+ return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
16770
+ }
16771
+ function titleCaseAuthoring(value) {
16772
+ return value.replace(/[._-]+/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
16773
+ }
14580
16774
  function contextBootstrapDir(projectRoot) {
14581
16775
  return join(projectRoot, '.dql', 'local', 'context-bootstrap');
14582
16776
  }
@@ -17157,6 +19351,11 @@ export function notebookCellRepairSql(cell) {
17157
19351
  const match = /\bquery\s*=\s*"""([\s\S]*?)"""/i.exec(cell.source);
17158
19352
  return match?.[1]?.trim() || undefined;
17159
19353
  }
19354
+ export function notebookDqlSourceAllowsBackgroundRepair(source) {
19355
+ if (/^\s*@block\(/i.test(source) || /\btype\s*=\s*"semantic"/i.test(source))
19356
+ return false;
19357
+ return /\bblock\b/i.test(source) && /\bquery\b/i.test(source);
19358
+ }
17160
19359
  export function replaceNotebookDqlQueryForRepair(source, sql) {
17161
19360
  const queryField = /\b(query\s*=\s*""")([\s\S]*?)(""")/i;
17162
19361
  if (!queryField.test(source) || !sql.trim())
@@ -17618,6 +19817,7 @@ export function openBlockStudioDocument(projectRoot, relativePath, semanticLayer
17618
19817
  owner: parsedMetadata.owner || companion?.owner || '',
17619
19818
  tags: parsedMetadata.tags.length > 0 ? parsedMetadata.tags : companion?.tags ?? [],
17620
19819
  reviewStatus: parsedMetadata.status || companion?.reviewStatus || 'draft',
19820
+ sourceFingerprint: createHash('sha256').update(source).digest('hex'),
17621
19821
  };
17622
19822
  return {
17623
19823
  path: normalizedPath,
@@ -17627,6 +19827,22 @@ export function openBlockStudioDocument(projectRoot, relativePath, semanticLayer
17627
19827
  validation: validateBlockStudioSource(source, semanticLayer),
17628
19828
  };
17629
19829
  }
19830
+ const BLOCK_STUDIO_DRAFT_RUNTIME_DIAGNOSTICS = new Set([
19831
+ 'metricflow_unavailable',
19832
+ 'semantic_compose_failed',
19833
+ 'semantic_layer_missing',
19834
+ 'semantic_runtime_required',
19835
+ ]);
19836
+ /**
19837
+ * Draft persistence is stricter than loose text storage but does not require a
19838
+ * configured execution runtime. Syntax, unsafe SQL, invalid semantic identity,
19839
+ * and parameter-contract errors must be corrected before a block file is
19840
+ * written; runtime availability remains a Run/Certify concern.
19841
+ */
19842
+ export function blockStudioValidationAllowsDraftSave(validation) {
19843
+ return !validation.diagnostics.some((diagnostic) => diagnostic.severity === 'error'
19844
+ && !BLOCK_STUDIO_DRAFT_RUNTIME_DIAGNOSTICS.has(diagnostic.code ?? ''));
19845
+ }
17630
19846
  /**
17631
19847
  * Replace the CONTENTS of every string literal with same-length filler, keeping
17632
19848
  * the quotes and every offset intact.
@@ -18234,17 +20450,33 @@ export function validateBlockStudioSource(source, semanticLayer) {
18234
20450
  });
18235
20451
  }
18236
20452
  }
18237
- const parameterInvocation = prepareBlockInvocation({ source, surface: 'block_studio' });
18238
- if (parameterInvocation.errors.length > 0) {
18239
- diagnostics.push(...parameterInvocation.errors.map((message) => ({ severity: 'error', code: 'parameter_parse', message })));
20453
+ let parameters = [];
20454
+ try {
20455
+ const parameterInvocation = prepareBlockInvocation({ source, surface: 'block_studio' });
20456
+ parameters = parameterInvocation.parameters;
20457
+ if (parameterInvocation.errors.length > 0) {
20458
+ diagnostics.push(...parameterInvocation.errors.map((message) => ({ severity: 'error', code: 'parameter_parse', message })));
20459
+ }
20460
+ }
20461
+ catch (error) {
20462
+ // The main parser diagnostic above owns malformed source. Parameter
20463
+ // extraction must never turn a recoverable validation response into a 500.
20464
+ if (!diagnostics.some((diagnostic) => diagnostic.code === 'syntax')) {
20465
+ diagnostics.push({
20466
+ severity: 'error',
20467
+ code: 'parameter_parse',
20468
+ message: error instanceof Error ? error.message : String(error),
20469
+ });
20470
+ }
18240
20471
  }
18241
20472
  return {
18242
20473
  valid: diagnostics.every((diagnostic) => diagnostic.severity !== 'error'),
20474
+ saveable: blockStudioValidationAllowsDraftSave({ diagnostics }),
18243
20475
  diagnostics,
18244
20476
  semanticRefs,
18245
20477
  chartConfig: chartConfig ?? undefined,
18246
20478
  executableSql,
18247
- parameters: parameterInvocation.parameters,
20479
+ parameters,
18248
20480
  };
18249
20481
  }
18250
20482
  export async function createDqlArtifactGenerationSessionForProject(projectRoot, options, semanticLayer) {
@@ -19182,6 +21414,45 @@ function formatBlockStudioExpected(expected) {
19182
21414
  return String(normalized);
19183
21415
  return JSON.stringify(normalized);
19184
21416
  }
21417
+ function evaluateBlockStudioTests(tests, rows, columns, startedAt) {
21418
+ if (tests.length === 0) {
21419
+ return { passed: 0, failed: 0, skipped: 0, duration: Date.now() - startedAt, assertions: [], runAt: new Date() };
21420
+ }
21421
+ const assertions = [];
21422
+ let passed = 0;
21423
+ let failed = 0;
21424
+ for (const test of tests) {
21425
+ const name = `assert ${test.field} ${test.operator} ${formatBlockStudioExpected(test.expected)}`;
21426
+ let actual;
21427
+ if (test.field === 'row_count') {
21428
+ actual = rows.length;
21429
+ }
21430
+ else if (!columns.includes(test.field)) {
21431
+ assertions.push({ name, passed: false, expected: test.expected, error: `Column '${test.field}' not found in results` });
21432
+ failed += 1;
21433
+ continue;
21434
+ }
21435
+ else {
21436
+ actual = rows[0]?.[test.field];
21437
+ }
21438
+ const ok = compareBlockStudioValues(actual, test.operator, test.expected);
21439
+ if (ok) {
21440
+ assertions.push({ name, passed: true, actual, expected: test.expected });
21441
+ passed += 1;
21442
+ }
21443
+ else {
21444
+ assertions.push({
21445
+ name,
21446
+ passed: false,
21447
+ actual,
21448
+ expected: test.expected,
21449
+ error: `${String(actual)} ${test.operator} ${formatBlockStudioExpected(test.expected)} is false`,
21450
+ });
21451
+ failed += 1;
21452
+ }
21453
+ }
21454
+ return { passed, failed, skipped: 0, duration: Date.now() - startedAt, assertions, runAt: new Date() };
21455
+ }
19185
21456
  function buildBlockStudioCertificationChecklist(input) {
19186
21457
  const parsed = parseBlockSourceMetadata(input.source);
19187
21458
  const sql = extractBlockStudioSql(input.source) ?? '';
@@ -24272,6 +26543,46 @@ function mergeAgentSchemaContexts(...contexts) {
24272
26543
  }
24273
26544
  return Array.from(byRelation.values()).slice(0, 16);
24274
26545
  }
26546
+ function agentRelationMatchKey(relation) {
26547
+ return relation
26548
+ .replace(/["`\[\]]/g, '')
26549
+ .split('.')
26550
+ .filter(Boolean)
26551
+ .slice(-2)
26552
+ .join('.')
26553
+ .toLowerCase();
26554
+ }
26555
+ /**
26556
+ * Keep dbt descriptions and semantic meaning, but make a complete live point
26557
+ * lookup authoritative for executable column names on the selected connection.
26558
+ * No fuzzy aliases are invented: a stale `customer_name` declaration disappears
26559
+ * when the live relation exposes only `name`.
26560
+ */
26561
+ export function reconcileAgentSchemaContextWithLive(catalog, live) {
26562
+ const liveByRelation = new Map(live
26563
+ .filter((table) => table.columns.length > 0)
26564
+ .map((table) => [agentRelationMatchKey(table.relation), table]));
26565
+ return catalog.map((table) => {
26566
+ const verified = liveByRelation.get(agentRelationMatchKey(table.relation));
26567
+ if (!verified)
26568
+ return table;
26569
+ const metadataColumns = new Map(table.columns.map((column) => [column.name.toLowerCase(), column]));
26570
+ return {
26571
+ ...table,
26572
+ source: 'dbt metadata verified against live runtime schema',
26573
+ columnCompleteness: 'complete',
26574
+ columns: verified.columns.map((column) => {
26575
+ const metadata = metadataColumns.get(column.name.toLowerCase());
26576
+ return {
26577
+ ...column,
26578
+ type: column.type ?? metadata?.type,
26579
+ description: metadata?.description,
26580
+ sampleValues: metadata?.sampleValues,
26581
+ };
26582
+ }),
26583
+ };
26584
+ });
26585
+ }
24275
26586
  function mergeAgentSchemaSampleValues(snapshot, enriched) {
24276
26587
  const samples = new Map();
24277
26588
  for (const table of enriched) {