@duckcodeailabs/dql-cli 1.13.1 → 1.13.3

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