@duckcodeailabs/dql-cli 1.12.11 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/apps-api.d.ts +151 -3
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +675 -97
  4. package/dist/apps-api.js.map +1 -1
  5. package/dist/args.d.ts +2 -0
  6. package/dist/args.d.ts.map +1 -1
  7. package/dist/args.js +3 -0
  8. package/dist/args.js.map +1 -1
  9. package/dist/assets/dql-notebook/assets/AgentAnswerCard-c8QUo6NH.js +5 -0
  10. package/dist/assets/dql-notebook/assets/{AgentLogPage-hzHqpD3-.js → AgentLogPage-D6fgEAEy.js} +1 -1
  11. package/dist/assets/dql-notebook/assets/{AiBuildDialog-BQRWoxp9.js → AiBuildDialog-8miXFPse.js} +1 -1
  12. package/dist/assets/dql-notebook/assets/{AiBuildResult-D9t_4sQl.js → AiBuildResult-DPJ_SI0R.js} +3 -3
  13. package/dist/assets/dql-notebook/assets/AiSidePanel-NVA6lZnn.js +6 -0
  14. package/dist/assets/dql-notebook/assets/AnalyticsHome-DgwnRW71.js +6 -0
  15. package/dist/assets/dql-notebook/assets/AppsView-B6LYUpgm.js +4578 -0
  16. package/dist/assets/dql-notebook/assets/BlockStudio--jNw9XUI.js +60 -0
  17. package/dist/assets/dql-notebook/assets/{BusinessArtifactView-CnTbFVGT.js → BusinessArtifactView-DtRMiaF4.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-DhBsLCYr.js +22 -0
  19. package/dist/assets/dql-notebook/assets/GitPage-CazC-eTR.js +7 -0
  20. package/dist/assets/dql-notebook/assets/GlobalAiRail-1Tgweuu2.js +1 -0
  21. package/dist/assets/dql-notebook/assets/GovernedContextPage-C7qGrH3d.js +16 -0
  22. package/dist/assets/dql-notebook/assets/{HelpDocsPage-CL0vbSER.js → HelpDocsPage-DMHPsVqP.js} +1 -1
  23. package/dist/assets/dql-notebook/assets/{HomePage-DzAucwBW.js → HomePage-DcSI4dnU.js} +1 -1
  24. package/dist/assets/dql-notebook/assets/LineageDAG-BJ6xz06u.js +1 -0
  25. package/dist/assets/dql-notebook/assets/{LineageDetailView-DdVH-nz6.js → LineageDetailView-8WrQm-MF.js} +1 -1
  26. package/dist/assets/dql-notebook/assets/{LineageDrawer-BvrVZNKY.js → LineageDrawer-hnqCW_t-.js} +1 -1
  27. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-CSut-zvm.js → LineagePathBreadcrumb-B4LZTUsl.js} +3 -3
  28. package/dist/assets/dql-notebook/assets/{MiniLineageGraph-zg_1wW3M.js → MiniLineageGraph-CotnRuxa.js} +1 -1
  29. package/dist/assets/dql-notebook/assets/{NewBlockModal-9cDletl-.js → NewBlockModal-DHn-CEEW.js} +1 -1
  30. package/dist/assets/dql-notebook/assets/{NewNotebookModal-V6de8L4d.js → NewNotebookModal-BPGp-8oq.js} +2 -2
  31. package/dist/assets/dql-notebook/assets/NotebookEditor-B0OTHM6W.js +195 -0
  32. package/dist/assets/dql-notebook/assets/ReadinessPage-CoOIpxWq.js +1 -0
  33. package/dist/assets/dql-notebook/assets/{SetupOnboarding-Bg-MjbC9.js → SetupOnboarding-DxK_mFzf.js} +2 -2
  34. package/dist/assets/dql-notebook/assets/{TrustBadge-DGuHB8kP.js → TrustBadge-ZuD5N5Ww.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-duFXXNV_.js +88 -0
  36. package/dist/assets/dql-notebook/assets/{answer-to-notebook-BDzY4MuQ.js → answer-to-notebook-WAVAYjBv.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{arrow-left-IjlnAihE.js → arrow-left-L8odLbyN.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/{arrow-right-BI4cXgOY.js → arrow-right-WIF1V9a9.js} +1 -1
  39. package/dist/assets/dql-notebook/assets/{book-open-text-i6xhxu_I.js → book-open-text-BpqWMa4m.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/{bot-C6DE7Q6m.js → bot-CLR1V6dO.js} +1 -1
  41. package/dist/assets/dql-notebook/assets/{circle-x-BoAorVSm.js → circle-x-CQBOM0b0.js} +1 -1
  42. package/dist/assets/dql-notebook/assets/{dagre.esm-CycymVj6.js → dagre.esm-DlZi7NR3.js} +1 -1
  43. package/dist/assets/dql-notebook/assets/{external-link-ZwE5iHsX.js → external-link-DYqdQIGp.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/{grip-vertical-Dnp2st8l.js → grip-vertical-HmUijbwN.js} +1 -1
  45. package/dist/assets/dql-notebook/assets/index-Cpx47jCH.js +634 -0
  46. package/dist/assets/dql-notebook/assets/{list-tree-Ced8e_S1.js → list-tree-DGKqCxmz.js} +1 -1
  47. package/dist/assets/dql-notebook/assets/{minimize-2-CBIAE0XH.js → minimize-2-C8dvqCxX.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{panel-right-open-po3bWSeL.js → panel-right-open-yuVkIo5n.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{rotate-ccw-B9v1LTJ0.js → rotate-ccw-Di0kRnrM.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{semantic-fields-B6ii0an0.js → semantic-fields-BMQV0li8.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/sliders-horizontal-alebptlJ.js +6 -0
  52. package/dist/assets/dql-notebook/assets/{star-CO4tfaDq.js → star-CgCQbnXc.js} +1 -1
  53. package/dist/assets/dql-notebook/assets/{triangle-alert-BSdIu2El.js → triangle-alert-SyuSZ4ht.js} +1 -1
  54. package/dist/assets/dql-notebook/assets/upload-BPhfea_E.js +11 -0
  55. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Bnl9gWhj.js +28 -0
  56. package/dist/assets/dql-notebook/assets/{user-round-BfrV8U-W.js → user-round-J4PQHxo9.js} +1 -1
  57. package/dist/assets/dql-notebook/assets/{wand-sparkles-BgTxZNBc.js → wand-sparkles-c4WDXTCk.js} +1 -1
  58. package/dist/assets/dql-notebook/assets/{workflow--2ycp0yy.js → workflow-NtRLaq7n.js} +1 -1
  59. package/dist/assets/dql-notebook/assets/{wrench-LMCrpa9_.js → wrench-CS1f1Zv4.js} +1 -1
  60. package/dist/assets/dql-notebook/assets/{x-CeNgQwGm.js → x-CbKEfe5m.js} +1 -1
  61. package/dist/assets/dql-notebook/index.html +1 -1
  62. package/dist/commands/model.d.ts.map +1 -1
  63. package/dist/commands/model.js +62 -1
  64. package/dist/commands/model.js.map +1 -1
  65. package/dist/index.js +3 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/llm/providers/dql-agent-provider.d.ts +4 -3
  68. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  69. package/dist/llm/providers/dql-agent-provider.js +13 -10
  70. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  71. package/dist/local-operation-coordinator.d.ts +59 -0
  72. package/dist/local-operation-coordinator.d.ts.map +1 -0
  73. package/dist/local-operation-coordinator.js +256 -0
  74. package/dist/local-operation-coordinator.js.map +1 -0
  75. package/dist/local-runtime.d.ts +59 -1
  76. package/dist/local-runtime.d.ts.map +1 -1
  77. package/dist/local-runtime.js +2653 -342
  78. package/dist/local-runtime.js.map +1 -1
  79. package/dist/package.json +12 -11
  80. package/dist/project-refresh-coordinator.d.ts +19 -0
  81. package/dist/project-refresh-coordinator.d.ts.map +1 -0
  82. package/dist/project-refresh-coordinator.js +134 -0
  83. package/dist/project-refresh-coordinator.js.map +1 -0
  84. package/dist/project-refresh-worker.d.ts +13 -0
  85. package/dist/project-refresh-worker.d.ts.map +1 -0
  86. package/dist/project-refresh-worker.js +58 -0
  87. package/dist/project-refresh-worker.js.map +1 -0
  88. package/package.json +13 -11
  89. package/dist/assets/dql-notebook/assets/AgentAnswerCard-CpL2kDCV.js +0 -5
  90. package/dist/assets/dql-notebook/assets/AiSidePanel-BCQB9rVF.js +0 -6
  91. package/dist/assets/dql-notebook/assets/AnalyticsHome-Ba4Ve_Gd.js +0 -6
  92. package/dist/assets/dql-notebook/assets/AppsView-CdAT9BOq.js +0 -4466
  93. package/dist/assets/dql-notebook/assets/BlockStudio-Brg4fwn5.js +0 -60
  94. package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-CQe2FUZq.js +0 -22
  95. package/dist/assets/dql-notebook/assets/GitPage-DHyDBChc.js +0 -7
  96. package/dist/assets/dql-notebook/assets/GlobalAiRail-Cys4n_rq.js +0 -1
  97. package/dist/assets/dql-notebook/assets/GovernedContextPage-DyKoDSZ2.js +0 -7
  98. package/dist/assets/dql-notebook/assets/LineageDAG-QHqd_AM5.js +0 -1
  99. package/dist/assets/dql-notebook/assets/NotebookEditor-EZ0pfWVs.js +0 -200
  100. package/dist/assets/dql-notebook/assets/ReadinessPage-BaGvjyfd.js +0 -1
  101. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-BlWAVEwO.js +0 -83
  102. package/dist/assets/dql-notebook/assets/chevron-up-DSEILnDs.js +0 -6
  103. package/dist/assets/dql-notebook/assets/index-BTuliKhK.js +0 -634
  104. package/dist/assets/dql-notebook/assets/link-2-CTKe6Snr.js +0 -6
  105. package/dist/assets/dql-notebook/assets/maximize-2-Cojc6867.js +0 -6
  106. package/dist/assets/dql-notebook/assets/pencil-Cb45c6WU.js +0 -6
  107. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-CkVFrrjk.js +0 -18
package/dist/apps-api.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * `/api/persona`. Designed to be invoked from `local-runtime.ts`'s request
4
4
  * dispatcher — returns `true` if the request was handled, `false` otherwise.
5
5
  */
6
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
6
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { createHash } from 'node:crypto';
8
8
  import { join, dirname, relative, basename } from 'node:path';
9
9
  import { loadAppDocument, findAppDocuments, loadDashboardDocument, findDashboardsForApp, parseAppDocument, parseDashboardDocument, normalizeDqlArtifactReference, suggestAppId, } from '@duckcodeailabs/dql-core';
@@ -72,6 +72,41 @@ export async function handleAppsApi(ctx) {
72
72
  return true;
73
73
  }
74
74
  // Two-phase AI build: propose (plan + confirmable content list, no files) …
75
+ if (req.method === 'POST' && path === '/api/apps/ai-builds/propose-background') {
76
+ try {
77
+ const body = await readJson(req);
78
+ if (!ctx.queueOperation) {
79
+ sendJson(res, 501, { ok: false, error: 'Background App builds are unavailable in this runtime.' });
80
+ return true;
81
+ }
82
+ const sessionId = appAiBuildSessionId(body.sessionId);
83
+ const operation = ctx.queueOperation({
84
+ type: 'app_ai_build',
85
+ scope: `app-build:${sessionId}`,
86
+ resourceRevision: createHash('sha256').update(JSON.stringify(body)).digest('hex'),
87
+ message: body.existingAppId ? 'App page proposal queued.' : 'App proposal queued.',
88
+ cancellable: true,
89
+ }, async (signal, report) => {
90
+ report({ phase: 'planning', progress: 8, message: 'Planning the governed App proposal.' });
91
+ if (signal.aborted)
92
+ throw Object.assign(new Error('App build cancelled.'), { code: 'OPERATION_CANCELLED' });
93
+ const session = await proposeAppAiBuild(projectRoot, { ...body, sessionId }, {
94
+ generateGovernedAnswer: ctx.generateGovernedAnswer,
95
+ });
96
+ if (signal.aborted)
97
+ throw Object.assign(new Error('App build cancelled.'), { code: 'OPERATION_CANCELLED' });
98
+ if (session.status === 'error')
99
+ throw Object.assign(new Error(session.error ?? 'App proposal failed.'), { code: 'APP_BUILD_FAILED' });
100
+ report({ phase: 'review_ready', progress: 96, message: 'Build Brief ready for review.' });
101
+ return { sessionId: session.id, status: session.status, appId: session.appId, dashboardId: session.dashboardId };
102
+ });
103
+ sendJson(res, 202, { ok: true, operation, sessionId });
104
+ }
105
+ catch (err) {
106
+ sendJson(res, 500, { ok: false, error: err.message });
107
+ }
108
+ return true;
109
+ }
75
110
  if (req.method === 'POST' && path === '/api/apps/ai-builds/propose') {
76
111
  try {
77
112
  const body = await readJson(req);
@@ -169,6 +204,37 @@ export async function handleAppsApi(ctx) {
169
204
  }
170
205
  return true;
171
206
  }
207
+ m = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/approve-semantic$/);
208
+ if (m && req.method === 'POST') {
209
+ const appId = decodeURIComponent(m[1]);
210
+ const dashboardId = decodeURIComponent(m[2]);
211
+ try {
212
+ const body = await readJson(req);
213
+ const runId = cleanString(body.runId);
214
+ const tileIds = unique((body.tileIds ?? []).map(cleanString).filter(Boolean));
215
+ if (!runId || tileIds.length === 0 || !ctx.verifyDashboardRun) {
216
+ sendJson(res, 400, { ok: false, error: 'A successful dashboard run and semantic tile selection are required.' });
217
+ return true;
218
+ }
219
+ const verified = ctx.verifyDashboardRun({ runId, appId, dashboardId, tileIds });
220
+ if (!verified.ok) {
221
+ sendJson(res, 409, { ok: false, error: verified.error });
222
+ return true;
223
+ }
224
+ const result = approveAppSemanticTiles(projectRoot, appId, dashboardId, {
225
+ tileIds,
226
+ runId,
227
+ snapshotId: verified.snapshotId,
228
+ expectedDashboardFingerprint: cleanString(body.expectedDashboardFingerprint),
229
+ reviewer: cleanString(body.reviewer) || 'local-reviewer',
230
+ });
231
+ sendJson(res, result.ok ? 200 : 409, result);
232
+ }
233
+ catch (err) {
234
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
235
+ }
236
+ return true;
237
+ }
172
238
  m = path.match(/^\/api\/apps\/([^/]+)\/editor\/catalog$/);
173
239
  if (m && req.method === 'GET') {
174
240
  const appId = decodeURIComponent(m[1]);
@@ -645,6 +711,22 @@ export async function handleAppsApi(ctx) {
645
711
  }
646
712
  // /api/apps/:id — single App with dashboards summary
647
713
  m = path.match(/^\/api\/apps\/([^/]+)$/);
714
+ if (m && req.method === 'DELETE') {
715
+ const id = decodeURIComponent(m[1]);
716
+ try {
717
+ const result = deleteAppPackage(projectRoot, id);
718
+ if (!result.ok) {
719
+ sendJson(res, result.status, { ok: false, error: result.error });
720
+ return true;
721
+ }
722
+ ctx.scheduleProjectRefresh?.('app-deleted');
723
+ sendJson(res, 200, result);
724
+ }
725
+ catch (err) {
726
+ sendJson(res, 500, { ok: false, error: err.message });
727
+ }
728
+ return true;
729
+ }
648
730
  if (m && req.method === 'GET') {
649
731
  const id = m[1];
650
732
  const result = loadAppById(projectRoot, id);
@@ -764,6 +846,7 @@ function collectAppsList(projectRoot) {
764
846
  status: document.lifecycle === 'review' ? 'review' : dashboards.length > 0 ? 'ready' : 'empty',
765
847
  storage: document.visibility === 'private' ? 'mine' : document.visibility === 'template' ? 'template' : 'shared',
766
848
  visibility: document.visibility ?? 'shared',
849
+ publicationIntent: document.publicationIntent ?? (document.visibility === 'private' ? 'personal' : 'shared_project'),
767
850
  owners: document.owners,
768
851
  tags: document.tags ?? [],
769
852
  members: document.members.length,
@@ -975,12 +1058,12 @@ export async function generateAppPackage(projectRoot, input) {
975
1058
  owner: cleanString(input.owner) || undefined,
976
1059
  preferredBlockIds: selectedBlockIds,
977
1060
  plannerMode: input.plannerMode === 'ai_assisted' ? 'ai_assisted' : 'deterministic',
1061
+ mode: input.mode === 'personal' ? 'personal' : 'stakeholder',
978
1062
  });
979
1063
  const validation = validateAppPlan(plan, kg);
980
- const certifiedTiles = typeof validation.certifiedTiles === 'number'
981
- ? validation.certifiedTiles
982
- : 0;
983
- if (certifiedTiles === 0) {
1064
+ const governedTiles = plan.pages.flatMap((page) => page.tiles)
1065
+ .filter((tile) => tile.kind === 'certified_block' || tile.kind === 'semantic_query').length;
1066
+ if (governedTiles === 0) {
984
1067
  return {
985
1068
  ok: false,
986
1069
  error: appBuildBlockedMessage(validation, plan),
@@ -1009,7 +1092,7 @@ export async function generateAppPackage(projectRoot, input) {
1009
1092
  }
1010
1093
  export async function createAppAiBuildSession(projectRoot, input) {
1011
1094
  const now = new Date().toISOString();
1012
- const id = `app_build_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1095
+ const id = appAiBuildSessionId(input.sessionId);
1013
1096
  const selectedBlockIds = unique((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean));
1014
1097
  const base = {
1015
1098
  id,
@@ -1027,6 +1110,7 @@ export async function createAppAiBuildSession(projectRoot, input) {
1027
1110
  notebookPath: cleanString(input.notebookPath) || undefined,
1028
1111
  existingAppId: cleanString(input.existingAppId) || undefined,
1029
1112
  mode: input.mode === 'personal' ? 'personal' : 'stakeholder',
1113
+ exploreGaps: input.mode === 'personal' && input.exploreGaps === true,
1030
1114
  selectedBlockIds,
1031
1115
  },
1032
1116
  };
@@ -1093,7 +1177,18 @@ function buildAppProposal(plan) {
1093
1177
  description: tile.description,
1094
1178
  blockId: tile.blockId,
1095
1179
  viz: tile.viz,
1180
+ allowedVisualizations: tile.display?.genUi.allowedVisualizations ?? [tile.viz, 'table'],
1096
1181
  certification: 'certified',
1182
+ sourceClass: 'certified_block',
1183
+ reviewStatus: 'not_required',
1184
+ requirementIds: tile.requirementIds,
1185
+ sourceEvidence: tile.sourceEvidence,
1186
+ preflight: {
1187
+ status: 'passed',
1188
+ snapshotId: plan.snapshotId,
1189
+ sourceFingerprint: tile.sourceNodeId,
1190
+ message: 'Certified source and requirement compatibility were validated against this project snapshot.',
1191
+ },
1097
1192
  selectedByDefault: true,
1098
1193
  });
1099
1194
  }
@@ -1106,7 +1201,18 @@ function buildAppProposal(plan) {
1106
1201
  question: tile.title,
1107
1202
  semantic: tile.semantic,
1108
1203
  viz: tile.viz,
1204
+ allowedVisualizations: tile.display?.genUi.allowedVisualizations ?? [tile.viz, 'table'],
1109
1205
  certification: 'reviewed_semantic',
1206
+ sourceClass: 'governed_semantic',
1207
+ reviewStatus: 'required',
1208
+ requirementIds: tile.requirementIds,
1209
+ sourceEvidence: tile.sourceEvidence,
1210
+ preflight: {
1211
+ status: 'review_required',
1212
+ snapshotId: plan.snapshotId,
1213
+ sourceFingerprint: tile.semantic.definitionFingerprint,
1214
+ message: 'Semantic identities are resolved; run and review the result before shared publication.',
1215
+ },
1110
1216
  selectedByDefault: true,
1111
1217
  });
1112
1218
  }
@@ -1137,6 +1243,10 @@ function buildAppProposal(plan) {
1137
1243
  gaps.push({ id: `gap_${gaps.length + 1}`, question, reason: 'No certified block covers this question.' });
1138
1244
  }
1139
1245
  return {
1246
+ intent: {
1247
+ target: plan.publicationIntent,
1248
+ initialVisibility: 'private',
1249
+ },
1140
1250
  tiles,
1141
1251
  gaps,
1142
1252
  followUps: gaps.map((gap) => gap.question).slice(0, 4),
@@ -1213,6 +1323,11 @@ async function fillProposalGaps(proposal, hooks, maxGeneratedTiles) {
1213
1323
  remaining.push(gap);
1214
1324
  continue;
1215
1325
  }
1326
+ const preview = previewFromAgentResult(answer.result);
1327
+ const repair = answer.appBuilderRepair;
1328
+ const unresolvedExecutionError = !preview
1329
+ ? cleanString(answer.executionError ?? answer.warehouseFailure?.redactedMessage ?? '')
1330
+ : '';
1216
1331
  // Gap-fill tiles are AI-generated by definition (they answer questions the
1217
1332
  // certified blocks did NOT cover). They are ALWAYS ai_generated / review-
1218
1333
  // required — never auto-certified, even if the governed loop happened to
@@ -1226,8 +1341,28 @@ async function fillProposalGaps(proposal, hooks, maxGeneratedTiles) {
1226
1341
  answer: cleanString(answer.answer ?? answer.text ?? '') || undefined,
1227
1342
  viz: normalizeGeneratedViz(answer.suggestedViz),
1228
1343
  certification: 'ai_generated',
1229
- preview: previewFromAgentResult(answer.result),
1230
- selectedByDefault: true,
1344
+ sourceClass: 'exploratory_analysis',
1345
+ reviewStatus: 'required',
1346
+ preflight: {
1347
+ status: preview ? 'passed' : unresolvedExecutionError ? 'blocked' : 'review_required',
1348
+ sourceFingerprint: `sha256:${createHash('sha256').update(sql).digest('hex')}`,
1349
+ receiptId: preview
1350
+ ? `preview:${createHash('sha256').update(JSON.stringify(preview)).digest('hex')}`
1351
+ : undefined,
1352
+ message: preview
1353
+ ? repair?.status === 'repaired'
1354
+ ? 'The shared bounded repair produced a successful preview; the query remains review-required.'
1355
+ : 'A bounded preview ran successfully; business review is still required.'
1356
+ : repair?.status === 'failed'
1357
+ ? repair.message
1358
+ : unresolvedExecutionError
1359
+ ? 'The generated query failed its bounded preview and could not be selected.'
1360
+ : 'SQL was generated but has no successful preview receipt yet.',
1361
+ },
1362
+ preview,
1363
+ ...(repair ? { repair } : {}),
1364
+ ...(unresolvedExecutionError ? { error: unresolvedExecutionError } : {}),
1365
+ selectedByDefault: false,
1231
1366
  });
1232
1367
  }
1233
1368
  catch (err) {
@@ -1246,6 +1381,12 @@ async function fillProposalGaps(proposal, hooks, maxGeneratedTiles) {
1246
1381
  question: gap.question,
1247
1382
  viz: 'table',
1248
1383
  certification: 'ai_generated',
1384
+ sourceClass: 'exploratory_analysis',
1385
+ reviewStatus: 'required',
1386
+ preflight: {
1387
+ status: 'blocked',
1388
+ message: 'Exploratory analysis generation failed.',
1389
+ },
1249
1390
  error: message,
1250
1391
  selectedByDefault: false,
1251
1392
  });
@@ -1267,11 +1408,11 @@ async function fillProposalGaps(proposal, hooks, maxGeneratedTiles) {
1267
1408
  */
1268
1409
  export async function proposeAppAiBuild(projectRoot, input, hooks) {
1269
1410
  const now = new Date().toISOString();
1270
- const id = `app_build_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1411
+ const id = appAiBuildSessionId(input.sessionId);
1271
1412
  const selectedBlockIds = unique((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean));
1272
1413
  const base = {
1273
1414
  id,
1274
- status: 'proposed',
1415
+ status: 'building',
1275
1416
  createdAt: now,
1276
1417
  updatedAt: now,
1277
1418
  prompt: cleanString(input.prompt),
@@ -1285,6 +1426,7 @@ export async function proposeAppAiBuild(projectRoot, input, hooks) {
1285
1426
  notebookPath: cleanString(input.notebookPath) || undefined,
1286
1427
  existingAppId: cleanString(input.existingAppId) || undefined,
1287
1428
  mode: input.mode === 'personal' ? 'personal' : 'stakeholder',
1429
+ exploreGaps: input.mode === 'personal' && input.exploreGaps === true,
1288
1430
  selectedBlockIds,
1289
1431
  },
1290
1432
  };
@@ -1293,17 +1435,32 @@ export async function proposeAppAiBuild(projectRoot, input, hooks) {
1293
1435
  writeAppAiBuildSession(projectRoot, session);
1294
1436
  return session;
1295
1437
  }
1296
- if (base.inputs.existingAppId) {
1297
- const session = { ...base, status: 'error', error: 'Updating an existing App is not supported yet. Create a new App so no existing content is silently replaced.' };
1438
+ // Persist the durable identity before planning or provider work begins. The
1439
+ // browser can leave this page immediately and recover the same session later.
1440
+ writeAppAiBuildSession(projectRoot, base);
1441
+ const existingApp = base.inputs.existingAppId ? loadAppById(projectRoot, base.inputs.existingAppId) : null;
1442
+ if (base.inputs.existingAppId && !existingApp) {
1443
+ const session = { ...base, status: 'error', error: `App "${base.inputs.existingAppId}" not found.` };
1298
1444
  writeAppAiBuildSession(projectRoot, session);
1299
1445
  return session;
1300
1446
  }
1447
+ if (existingApp) {
1448
+ // Existing-App page generation inherits server-owned App context. The prompt
1449
+ // controls the new page, never the identity or governance boundary of the App.
1450
+ base.inputs.domain = existingApp.app.domain;
1451
+ base.inputs.audience = existingApp.app.audience;
1452
+ base.inputs.owner = existingApp.app.businessOwner ?? existingApp.app.owners[0];
1453
+ const publicationIntent = existingApp.app.publicationIntent
1454
+ ?? (existingApp.app.visibility === 'private' ? 'personal' : 'shared_project');
1455
+ base.inputs.mode = publicationIntent === 'personal' ? 'personal' : 'stakeholder';
1456
+ base.inputs.exploreGaps = base.inputs.mode === 'personal' && input.exploreGaps === true;
1457
+ }
1301
1458
  const { KGStore, defaultKgPath, planAppFromPrompt, reindexProject, validateAppPlan, } = await import('@duckcodeailabs/dql-agent');
1302
1459
  const kgPath = defaultKgPath(projectRoot);
1303
1460
  const index = await reindexProject(projectRoot, { kgPath });
1304
1461
  const kg = new KGStore(kgPath);
1305
1462
  try {
1306
- const plan = planAppFromPrompt({
1463
+ const planned = planAppFromPrompt({
1307
1464
  prompt: base.prompt,
1308
1465
  kg,
1309
1466
  domain: base.inputs.domain,
@@ -1311,22 +1468,48 @@ export async function proposeAppAiBuild(projectRoot, input, hooks) {
1311
1468
  owner: base.inputs.owner,
1312
1469
  preferredBlockIds: selectedBlockIds,
1313
1470
  plannerMode: input.plannerMode === 'ai_assisted' ? 'ai_assisted' : 'deterministic',
1314
- mode: input.mode === 'personal' ? 'personal' : 'stakeholder',
1471
+ mode: base.inputs.mode === 'personal' ? 'personal' : 'stakeholder',
1315
1472
  snapshotId: index.kgFingerprint,
1316
1473
  });
1474
+ const firstPage = planned.pages[0];
1475
+ if (!firstPage)
1476
+ throw new Error('The App planner did not return a page.');
1477
+ const existingDashboardIds = new Set(existingApp?.dashboards.map((dashboard) => dashboard.id) ?? []);
1478
+ const pageId = existingApp
1479
+ ? uniqueAppPageId(slugify(firstPage.title) || 'page', existingDashboardIds)
1480
+ : firstPage.id;
1481
+ const plan = existingApp ? {
1482
+ ...planned,
1483
+ appId: existingApp.app.id,
1484
+ name: existingApp.app.name,
1485
+ domain: existingApp.app.domain,
1486
+ audience: existingApp.app.audience ?? planned.audience,
1487
+ owner: existingApp.app.businessOwner ?? existingApp.app.owners[0] ?? planned.owner,
1488
+ publicationIntent: existingApp.app.publicationIntent
1489
+ ?? (existingApp.app.visibility === 'private' ? 'personal' : 'shared_project'),
1490
+ pages: [{ ...firstPage, id: pageId }],
1491
+ } : planned;
1317
1492
  const validation = validateAppPlan(plan, kg);
1318
1493
  const proposal = buildAppProposal(plan);
1319
- // App Builder v2 never turns generated SQL into a primary dashboard tile.
1320
- // Uncovered requirements stay visible gaps for Research/Copilot.
1321
- const proposalHash = createHash('sha256').update(JSON.stringify({ plan, proposal })).digest('hex');
1494
+ // Raw SQL exploration is opt-in, personal-only, preview-bounded, and remains
1495
+ // unselected until the user explicitly accepts it in the Build Brief.
1496
+ if (base.inputs.exploreGaps) {
1497
+ await fillProposalGaps(proposal, hooks, input.maxGeneratedTiles);
1498
+ }
1499
+ const existingAppFingerprint = existingApp ? fingerprintAppDirectory(existingApp.appDir) : undefined;
1500
+ const proposalHash = createHash('sha256')
1501
+ .update(JSON.stringify({ plan, proposal, existingAppFingerprint }))
1502
+ .digest('hex');
1322
1503
  const session = {
1323
1504
  ...base,
1505
+ status: 'proposed',
1324
1506
  appId: plan.appId,
1325
1507
  dashboardId: plan.pages[0]?.id ?? null,
1326
1508
  plan,
1327
1509
  validation,
1328
1510
  proposal,
1329
1511
  snapshotId: index.kgFingerprint,
1512
+ ...(existingAppFingerprint ? { existingAppFingerprint } : {}),
1330
1513
  proposalHash,
1331
1514
  warnings: appBuildWarnings(validation, plan),
1332
1515
  reviewTasks: reviewTasksFromPlan(plan),
@@ -1348,12 +1531,11 @@ export async function proposeAppAiBuild(projectRoot, input, hooks) {
1348
1531
  }
1349
1532
  }
1350
1533
  /**
1351
- * Persist selected AI-generated candidates as aiPins (certification preserved,
1352
- * never upgraded; review-required) and append them as dashboard tiles below the
1353
- * certified layout. The dashboard doc was just written by generateAppFromPlan, so
1354
- * this rewrites it with the extra items — the renderer already badges aiPin tiles.
1534
+ * Materialize explicitly selected exploratory candidates as Git-owned, app-scoped
1535
+ * review DQL. They are never promoted into the shared block library and the
1536
+ * dashboard stores a fingerprinted reference instead of inline SQL.
1355
1537
  */
1356
- function attachGeneratedTiles(projectRoot, plan, generatedPaths, candidates) {
1538
+ function attachGeneratedDraftAnalyses(projectRoot, plan, generatedPaths, candidates) {
1357
1539
  const dashboardId = plan.pages[0]?.id ?? 'overview';
1358
1540
  const dashboardPath = generatedPaths.find((path) => path.endsWith(`${dashboardId}.dqld`))
1359
1541
  ?? generatedPaths.find((path) => path.endsWith('.dqld'));
@@ -1363,7 +1545,7 @@ function attachGeneratedTiles(projectRoot, plan, generatedPaths, candidates) {
1363
1545
  if (!existsSync(absolutePath))
1364
1546
  return;
1365
1547
  const doc = JSON.parse(readFileSync(absolutePath, 'utf-8'));
1366
- // Story layout: generated tiles live in the review appendix. Classic grids
1548
+ // Story layout: exploratory tiles live in the review appendix. Classic grids
1367
1549
  // (no sections) just append at the bottom, untagged.
1368
1550
  const storyMode = Array.isArray(doc.sections) && doc.sections.length > 0;
1369
1551
  if (storyMode && !doc.sections.some((section) => section.kind === 'appendix')) {
@@ -1374,52 +1556,80 @@ function attachGeneratedTiles(projectRoot, plan, generatedPaths, candidates) {
1374
1556
  order: doc.sections.length,
1375
1557
  });
1376
1558
  }
1377
- const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
1378
- try {
1379
- let y = doc.layout.items.reduce((max, item) => Math.max(max, item.y + item.h), 0);
1380
- let x = 0;
1381
- for (const tile of candidates) {
1382
- const certified = tile.certification === 'certified';
1383
- const pin = storage.createAiPin({
1384
- appId: plan.appId,
1385
- dashboardId: doc.id,
1386
- title: tile.title,
1387
- answer: tile.answer ?? tile.question ?? tile.title,
1388
- question: tile.question,
1389
- sql: tile.sql,
1390
- certification: certified ? 'certified' : 'ai_generated',
1391
- reviewStatus: certified ? 'certified' : 'needs_review',
1392
- refreshCadence: 'none',
1393
- result: tile.preview,
1394
- followUps: tile.followUps ?? [],
1395
- });
1396
- const item = {
1397
- i: `aipin_${pin.id}`,
1398
- x,
1399
- y,
1400
- w: 6,
1401
- h: 4,
1402
- aiPin: { id: pin.id },
1403
- viz: { type: normalizeGeneratedViz(tile.viz) },
1404
- title: tile.title,
1405
- trustState: certified ? 'certified' : 'review_required',
1406
- reviewStatus: certified ? 'certified' : 'review_required',
1407
- ...(storyMode ? { sectionId: 'appendix' } : {}),
1408
- };
1409
- doc.layout.items.push(item);
1410
- if (x === 0) {
1411
- x = 6;
1412
- }
1413
- else {
1414
- x = 0;
1415
- y += 4;
1416
- }
1559
+ let y = doc.layout.items.reduce((max, item) => Math.max(max, item.y + item.h), 0);
1560
+ let x = 0;
1561
+ const draftsDir = join(projectRoot, 'apps', plan.appId, 'drafts');
1562
+ mkdirSync(draftsDir, { recursive: true });
1563
+ for (const tile of candidates) {
1564
+ if (!tile.sql)
1565
+ continue;
1566
+ if (tile.sql.includes('"""'))
1567
+ throw new Error(`Exploratory SQL for "${tile.title}" contains an unsupported triple-quote delimiter.`);
1568
+ const draftId = slugify(tile.title) || slugify(tile.id) || `analysis-${Date.now()}`;
1569
+ const draftPath = join(draftsDir, `${draftId}.dql`);
1570
+ const source = [
1571
+ `block "${escapeDqlString(draftId)}" {`,
1572
+ ` domain = "${escapeDqlString(plan.domain)}"`,
1573
+ ' type = "custom"',
1574
+ ' status = "review"',
1575
+ ` owner = "${escapeDqlString(plan.owner)}"`,
1576
+ ` description = "${escapeDqlString((tile.answer ?? tile.question ?? tile.title).slice(0, 240))}"`,
1577
+ tile.question ? ` llmContext = "${escapeDqlString(`App-scoped exploratory analysis. Question: ${tile.question}`)}"` : '',
1578
+ tile.question ? ` examples = [{ question = "${escapeDqlString(tile.question)}" }]` : '',
1579
+ ' caveats = ["AI-generated app draft. Validate joins, filters, grain, and business interpretation before promotion or publication."]',
1580
+ ' tags = ["app-scoped", "ai-generated", "needs-review"]',
1581
+ '',
1582
+ ' query = """',
1583
+ tile.sql,
1584
+ ' """',
1585
+ '',
1586
+ ' visualization {',
1587
+ ` chart = "${escapeDqlString(normalizeGeneratedViz(tile.viz))}"`,
1588
+ ' }',
1589
+ '}',
1590
+ '',
1591
+ ].filter(Boolean).join('\n');
1592
+ const fingerprint = `sha256:${createHash('sha256').update(source).digest('hex')}`;
1593
+ writeFileSync(draftPath, source, 'utf-8');
1594
+ generatedPaths.push(relative(projectRoot, draftPath));
1595
+ const item = {
1596
+ i: `draft_${draftId}`,
1597
+ x,
1598
+ y,
1599
+ w: 6,
1600
+ h: 4,
1601
+ draftAnalysis: {
1602
+ ref: `drafts/${draftId}.dql`,
1603
+ artifactFingerprint: fingerprint,
1604
+ ...(plan.snapshotId ? { snapshotId: plan.snapshotId } : {}),
1605
+ ...(tile.preflight.receiptId ? { executionReceiptId: tile.preflight.receiptId } : {}),
1606
+ },
1607
+ viz: { type: normalizeGeneratedViz(tile.viz) },
1608
+ title: tile.title,
1609
+ sourceClass: 'exploratory_analysis',
1610
+ review: {
1611
+ status: 'required',
1612
+ sourceFingerprint: fingerprint,
1613
+ ...(tile.preflight.receiptId ? { preflightReceiptId: tile.preflight.receiptId } : {}),
1614
+ },
1615
+ trustState: 'review_required',
1616
+ reviewStatus: 'review_required',
1617
+ ...(storyMode ? { sectionId: 'appendix' } : {}),
1618
+ };
1619
+ doc.layout.items.push(item);
1620
+ if (x === 0) {
1621
+ x = 6;
1622
+ }
1623
+ else {
1624
+ x = 0;
1625
+ y += 4;
1417
1626
  }
1418
- writeFileSync(absolutePath, JSON.stringify(doc, null, 2) + '\n', 'utf-8');
1419
1627
  }
1420
- finally {
1421
- storage.close();
1628
+ const parsed = parseDashboardDocument(JSON.stringify(doc), absolutePath);
1629
+ if (!parsed.document || parsed.errors.length > 0) {
1630
+ throw new Error(`Exploratory App dashboard is invalid: ${parsed.errors.map((error) => error.message).join('; ')}`);
1422
1631
  }
1632
+ writeFileSync(absolutePath, JSON.stringify(doc, null, 2) + '\n', 'utf-8');
1423
1633
  }
1424
1634
  /**
1425
1635
  * Phase 2 of the two-phase app build: the user confirmed the proposal — filter the
@@ -1439,7 +1649,29 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1439
1649
  return { ok: false, error: 'The App proposal changed or is stale. Refresh the proposal before creating the App.', status: 409 };
1440
1650
  }
1441
1651
  const plan = session.plan;
1652
+ const existingAppId = cleanString(session.inputs.existingAppId);
1653
+ const isExistingPageBuild = Boolean(existingAppId);
1654
+ const existingApp = existingAppId ? loadAppById(projectRoot, existingAppId) : null;
1655
+ if (isExistingPageBuild) {
1656
+ if (!existingApp)
1657
+ return { ok: false, error: `App "${existingAppId}" no longer exists.`, status: 409 };
1658
+ if (!session.existingAppFingerprint
1659
+ || fingerprintAppDirectory(existingApp.appDir) !== session.existingAppFingerprint) {
1660
+ return {
1661
+ ok: false,
1662
+ error: 'The App changed after this page proposal was created. Refresh and review the page proposal again.',
1663
+ status: 409,
1664
+ };
1665
+ }
1666
+ }
1442
1667
  const proposal = session.proposal;
1668
+ const proposalTileIds = new Set(proposal.tiles.map((tile) => tile.id));
1669
+ const tileOverrides = Object.fromEntries(Object.entries(input.tileOverrides ?? {})
1670
+ .filter(([tileId, value]) => proposalTileIds.has(tileId) && value && typeof value === 'object')
1671
+ .map(([tileId, value]) => [tileId, {
1672
+ ...(cleanString(value.title) ? { title: cleanString(value.title).slice(0, 120) } : {}),
1673
+ ...(cleanString(value.viz) ? { viz: normalizeGeneratedViz(value.viz) } : {}),
1674
+ }]));
1443
1675
  const selectable = new Set(proposal.tiles.filter((tile) => !tile.error).map((tile) => tile.id));
1444
1676
  const selected = new Set((input.selectedTileIds ?? Array.from(selectable)).filter((tileId) => selectable.has(tileId)));
1445
1677
  if (selected.size === 0) {
@@ -1449,11 +1681,43 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1449
1681
  // tiles (narrative, draft placeholders) stay — they document the story + gaps.
1450
1682
  const committedPlan = {
1451
1683
  ...plan,
1452
- pages: plan.pages.map((page) => ({
1684
+ ...(!isExistingPageBuild && cleanString(input.appName) ? { name: cleanString(input.appName).slice(0, 120) } : {}),
1685
+ ...(!isExistingPageBuild && cleanString(input.audience) ? { audience: cleanString(input.audience).slice(0, 120) } : {}),
1686
+ pages: plan.pages.map((page, pageIndex) => ({
1453
1687
  ...page,
1454
- tiles: page.tiles.filter((tile) => !['certified_block', 'semantic_query'].includes(tile.kind) || selected.has(tile.id)),
1688
+ ...(isExistingPageBuild && pageIndex === 0 && cleanString(input.pageTitle)
1689
+ ? { title: cleanString(input.pageTitle).slice(0, 120) }
1690
+ : {}),
1691
+ tiles: page.tiles
1692
+ .filter((tile) => !['certified_block', 'semantic_query'].includes(tile.kind) || selected.has(tile.id))
1693
+ .map((tile) => {
1694
+ const override = tileOverrides[tile.id];
1695
+ if (!override)
1696
+ return tile;
1697
+ const viz = override.viz;
1698
+ return {
1699
+ ...tile,
1700
+ ...(override.title ? { title: override.title } : {}),
1701
+ ...(viz ? { viz } : {}),
1702
+ ...(viz && tile.display ? {
1703
+ display: {
1704
+ ...tile.display,
1705
+ recommendedDisplayType: viz,
1706
+ genUi: {
1707
+ ...tile.display.genUi,
1708
+ defaultVisualization: viz,
1709
+ allowedVisualizations: Array.from(new Set([viz, ...tile.display.genUi.allowedVisualizations])),
1710
+ },
1711
+ },
1712
+ } : {}),
1713
+ };
1714
+ }),
1455
1715
  })),
1456
1716
  };
1717
+ const selectedExploratory = proposal.tiles.filter((tile) => (tile.source === 'ai_generated' && selected.has(tile.id) && !tile.error && Boolean(tile.sql))).map((tile) => ({ ...tile, ...tileOverrides[tile.id] }));
1718
+ if (plan.mode !== 'personal' && selectedExploratory.length > 0) {
1719
+ return { ok: false, error: 'Exploratory SQL can only be accepted into a Personal Draft.', status: 400 };
1720
+ }
1457
1721
  const { KGStore, defaultKgPath, ensureMetadataCatalogFresh, generateAppFromPlan, reindexProject, validateAppPlan, } = await import('@duckcodeailabs/dql-agent');
1458
1722
  const index = await reindexProject(projectRoot, { kgPath: defaultKgPath(projectRoot) });
1459
1723
  if (!session.snapshotId || index.kgFingerprint !== session.snapshotId) {
@@ -1465,8 +1729,8 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1465
1729
  const validation = validateAppPlan(committedPlan, kg);
1466
1730
  const governedTileCount = committedPlan.pages.flatMap((page) => page.tiles)
1467
1731
  .filter((tile) => tile.kind === 'certified_block' || tile.kind === 'semantic_query').length;
1468
- if (governedTileCount === 0) {
1469
- return { ok: false, error: 'Select at least one certified block or governed semantic query.', status: 409 };
1732
+ if (governedTileCount === 0 && selectedExploratory.length === 0) {
1733
+ return { ok: false, error: 'Select a certified block, governed semantic query, or explicitly accepted Personal Draft analysis.', status: 409 };
1470
1734
  }
1471
1735
  // Copilot follow-ups the created app carries: still-uncovered gaps first, then
1472
1736
  // the questions behind any candidates the user chose to leave out.
@@ -1478,33 +1742,104 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1478
1742
  ]).slice(0, 4);
1479
1743
  let generated;
1480
1744
  const stagingRoot = join(projectRoot, '.dql', 'local', 'app-build-staging', session.id);
1481
- const stagedAppDir = join(stagingRoot, 'apps', committedPlan.appId);
1745
+ const generatedRoot = isExistingPageBuild ? join(stagingRoot, 'generated') : stagingRoot;
1746
+ const generatedAppDir = join(generatedRoot, 'apps', committedPlan.appId);
1747
+ const stagedAppDir = isExistingPageBuild
1748
+ ? join(stagingRoot, 'candidate', 'apps', committedPlan.appId)
1749
+ : generatedAppDir;
1482
1750
  const targetAppDir = join(projectRoot, 'apps', committedPlan.appId);
1483
1751
  const backupAppDir = join(projectRoot, '.dql', 'local', 'app-build-staging', `${session.id}-backup`);
1484
1752
  rmSync(stagingRoot, { recursive: true, force: true });
1485
1753
  try {
1486
- generated = generateAppFromPlan(stagingRoot, committedPlan, kg, { overwrite: true, copilotQuestions });
1754
+ generated = generateAppFromPlan(generatedRoot, committedPlan, kg, { overwrite: true, copilotQuestions });
1755
+ if (selectedExploratory.length > 0) {
1756
+ attachGeneratedDraftAnalyses(generatedRoot, committedPlan, generated.paths, selectedExploratory);
1757
+ }
1487
1758
  mkdirSync(dirname(targetAppDir), { recursive: true });
1488
- if (existsSync(targetAppDir)) {
1759
+ if (isExistingPageBuild) {
1760
+ if (!existingApp || !existsSync(targetAppDir)) {
1761
+ return { ok: false, error: `App "${existingAppId}" no longer exists.`, status: 409 };
1762
+ }
1763
+ if (fingerprintAppDirectory(existingApp.appDir) !== session.existingAppFingerprint) {
1764
+ return {
1765
+ ok: false,
1766
+ error: 'The App changed while this page was being prepared. Refresh and review the page proposal again.',
1767
+ status: 409,
1768
+ };
1769
+ }
1770
+ const page = committedPlan.pages[0];
1771
+ if (!page)
1772
+ return { ok: false, error: 'The approved plan has no page to add.', status: 400 };
1773
+ const generatedDashboardPath = join(generatedAppDir, 'dashboards', `${page.id}.dqld`);
1774
+ const targetDashboardPath = join(stagedAppDir, 'dashboards', `${page.id}.dqld`);
1775
+ if (existingApp.dashboards.some((dashboard) => dashboard.id === page.id)) {
1776
+ return { ok: false, error: `Dashboard page already exists: ${page.id}`, status: 409 };
1777
+ }
1778
+ cpSync(existingApp.appDir, stagedAppDir, { recursive: true });
1779
+ if (existsSync(targetDashboardPath)) {
1780
+ return { ok: false, error: `Dashboard page already exists: ${page.id}`, status: 409 };
1781
+ }
1782
+ mkdirSync(dirname(targetDashboardPath), { recursive: true });
1783
+ cpSync(generatedDashboardPath, targetDashboardPath);
1784
+ const committedPaths = [relative(projectRoot, join(targetAppDir, 'dashboards', `${page.id}.dqld`))];
1785
+ for (const generatedPath of generated.paths.filter((path) => path.includes('/drafts/') && path.endsWith('.dql'))) {
1786
+ const sourcePath = join(generatedRoot, generatedPath);
1787
+ const packageRelativePath = relative(generatedAppDir, sourcePath);
1788
+ const candidatePath = join(stagedAppDir, packageRelativePath);
1789
+ const finalPath = join(targetAppDir, packageRelativePath);
1790
+ if (existsSync(candidatePath)) {
1791
+ return { ok: false, error: `App draft already exists: ${relative(projectRoot, finalPath)}`, status: 409 };
1792
+ }
1793
+ mkdirSync(dirname(candidatePath), { recursive: true });
1794
+ cpSync(sourcePath, candidatePath);
1795
+ committedPaths.push(relative(projectRoot, finalPath));
1796
+ }
1797
+ generated = { ...generated, paths: committedPaths };
1798
+ if (fingerprintAppDirectory(existingApp.appDir) !== session.existingAppFingerprint) {
1799
+ return {
1800
+ ok: false,
1801
+ error: 'The App changed while this page was being staged. Refresh and review the page proposal again.',
1802
+ status: 409,
1803
+ };
1804
+ }
1805
+ rmSync(backupAppDir, { recursive: true, force: true });
1806
+ renameSync(targetAppDir, backupAppDir);
1807
+ try {
1808
+ renameSync(stagedAppDir, targetAppDir);
1809
+ }
1810
+ catch (error) {
1811
+ if (!existsSync(targetAppDir) && existsSync(backupAppDir))
1812
+ renameSync(backupAppDir, targetAppDir);
1813
+ throw error;
1814
+ }
1815
+ rmSync(backupAppDir, { recursive: true, force: true });
1816
+ }
1817
+ else if (existsSync(targetAppDir)) {
1489
1818
  if (!input.force)
1490
1819
  return { ok: false, error: `App already exists: apps/${committedPlan.appId}`, status: 409 };
1491
1820
  rmSync(backupAppDir, { recursive: true, force: true });
1492
1821
  renameSync(targetAppDir, backupAppDir);
1822
+ try {
1823
+ renameSync(stagedAppDir, targetAppDir);
1824
+ rmSync(backupAppDir, { recursive: true, force: true });
1825
+ }
1826
+ catch (error) {
1827
+ if (existsSync(backupAppDir) && !existsSync(targetAppDir))
1828
+ renameSync(backupAppDir, targetAppDir);
1829
+ throw error;
1830
+ }
1493
1831
  }
1494
- try {
1832
+ else {
1495
1833
  renameSync(stagedAppDir, targetAppDir);
1496
- rmSync(backupAppDir, { recursive: true, force: true });
1497
- }
1498
- catch (error) {
1499
- if (existsSync(backupAppDir) && !existsSync(targetAppDir))
1500
- renameSync(backupAppDir, targetAppDir);
1501
- throw error;
1502
1834
  }
1503
1835
  }
1504
1836
  catch (err) {
1505
1837
  const message = err instanceof Error ? err.message : String(err);
1506
1838
  return { ok: false, error: message, status: message.includes('already exists') ? 409 : 400 };
1507
1839
  }
1840
+ finally {
1841
+ rmSync(stagingRoot, { recursive: true, force: true });
1842
+ }
1508
1843
  const attachWarnings = [];
1509
1844
  await ensureMetadataCatalogFresh(projectRoot, { force: true });
1510
1845
  const app = collectAppsList(projectRoot).find((entry) => entry.id === committedPlan.appId) ?? null;
@@ -1518,6 +1853,13 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1518
1853
  validation,
1519
1854
  generatedPaths: generated.paths,
1520
1855
  committedTileIds: Array.from(selected),
1856
+ committedBrief: {
1857
+ ...(isExistingPageBuild
1858
+ ? { pageTitle: committedPlan.pages[0]?.title }
1859
+ : { appName: committedPlan.name }),
1860
+ audience: committedPlan.audience,
1861
+ tileOverrides,
1862
+ },
1521
1863
  warnings: [...session.warnings, ...attachWarnings],
1522
1864
  };
1523
1865
  writeAppAiBuildSession(projectRoot, next);
@@ -1578,6 +1920,11 @@ async function routeAppAskQuestion(ctx, appId, input) {
1578
1920
  const tile = dashboard?.layout.items.find((item) => item.i === input.tileId ||
1579
1921
  (input.blockId && item.block && 'blockId' in item.block && item.block.blockId === input.blockId));
1580
1922
  const blockId = cleanString(input.blockId) || (tile?.block && 'blockId' in tile.block ? tile.block.blockId : undefined);
1923
+ const appContext = buildAppContextEnvelope(loaded.app, loaded.appDir, {
1924
+ dashboardId,
1925
+ tileId: cleanString(input.tileId) || undefined,
1926
+ blockId,
1927
+ });
1581
1928
  const citations = [
1582
1929
  { kind: 'app', name: loaded.app.name, path: loaded.appPath },
1583
1930
  ...(dashboard ? [{ kind: 'dashboard', name: dashboard.metadata.title, path: `${loaded.appPath}/dashboards/${dashboard.id}.dqld` }] : []),
@@ -1601,11 +1948,20 @@ async function routeAppAskQuestion(ctx, appId, input) {
1601
1948
  decision,
1602
1949
  proposal: {
1603
1950
  type: 'app_change',
1951
+ version: 1,
1604
1952
  dashboardId,
1605
1953
  tileId: input.tileId,
1606
1954
  blockId,
1607
1955
  question,
1608
1956
  reviewRequired: true,
1957
+ expectedDashboardFingerprint: dashboard
1958
+ ? `sha256:${createHash('sha256').update(JSON.stringify(dashboard)).digest('hex')}`
1959
+ : undefined,
1960
+ operations: [{
1961
+ op: 'review_in_build_mode',
1962
+ target: cleanString(input.tileId) || dashboardId || appId,
1963
+ suggestion: appChangeSuggestion(question),
1964
+ }],
1609
1965
  },
1610
1966
  };
1611
1967
  }
@@ -1635,6 +1991,7 @@ async function routeAppAskQuestion(ctx, appId, input) {
1635
1991
  variables: input.variables,
1636
1992
  selectedTile: tile ? appAskTileContext(tile) : undefined,
1637
1993
  userContext: input.context,
1994
+ appContext,
1638
1995
  },
1639
1996
  };
1640
1997
  if (input.runInvestigation === false) {
@@ -1669,6 +2026,7 @@ async function routeAppAskQuestion(ctx, appId, input) {
1669
2026
  variables: input.variables,
1670
2027
  selectedTile: tile ? appAskTileContext(tile) : undefined,
1671
2028
  userContext: input.context,
2029
+ appContext,
1672
2030
  routeDecision: decision,
1673
2031
  },
1674
2032
  });
@@ -1719,7 +2077,7 @@ async function routeAppAskQuestion(ctx, appId, input) {
1719
2077
  // surfaces use: question-first retrieval finds the best-matching certified block
1720
2078
  // across the app, generates review-required SQL, or digs deeper — instead of
1721
2079
  // narrating whichever tile happens to be focused.
1722
- const governed = await tryGovernedAppAnswer(ctx, question, { blockId, appName: loaded.app.name, citations });
2080
+ const governed = await tryGovernedAppAnswer(ctx, question, { blockId, appName: loaded.app.name, citations, appContext });
1723
2081
  if (governed)
1724
2082
  return governed;
1725
2083
  // Offline / no AI provider: keep the focused tile as a graceful fallback.
@@ -1847,7 +2205,7 @@ async function tryGovernedAppAnswer(ctx, question, input) {
1847
2205
  if (!ctx.generateGovernedAnswer)
1848
2206
  return null;
1849
2207
  try {
1850
- const governed = await ctx.generateGovernedAnswer(question);
2208
+ const governed = await ctx.generateGovernedAnswer(question, input.appContext);
1851
2209
  return governed ? mapGovernedToAppAsk(governed, { question, ...input }) : null;
1852
2210
  }
1853
2211
  catch {
@@ -2032,24 +2390,32 @@ export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
2032
2390
  const loaded = loadAppById(projectRoot, appId);
2033
2391
  if (!loaded)
2034
2392
  return { ok: false, error: `App "${appId}" not found` };
2393
+ const dashboardSources = findDashboardsForApp(loaded.appDir)
2394
+ .map((path) => ({ path, document: loadDashboardDocument(path).document }))
2395
+ .filter((item) => Boolean(item.document));
2396
+ const readiness = appPublicationReadiness(dashboardSources.map((item) => item.document));
2397
+ if (!readiness.ready) {
2398
+ return {
2399
+ ok: false,
2400
+ error: `App is not ready for shared publication: ${readiness.blockers.map((blocker) => blocker.message).join('; ')}`,
2401
+ readiness,
2402
+ };
2403
+ }
2035
2404
  const lifecycle = input.lifecycle === 'certified' || input.lifecycle === 'deprecated' ? input.lifecycle : 'review';
2036
2405
  const app = {
2037
2406
  ...loaded.app,
2038
2407
  visibility: 'shared',
2408
+ publicationIntent: 'shared_project',
2039
2409
  lifecycle,
2040
2410
  };
2041
2411
  const appPath = join(loaded.appDir, 'dql.app.json');
2042
2412
  const parsedApp = parseAppDocument(JSON.stringify(app), appPath);
2043
2413
  if (!parsedApp.document)
2044
2414
  return { ok: false, error: parsedApp.errors.map((err) => err.message).join('; ') };
2045
- writeFileSync(appPath, JSON.stringify(parsedApp.document, null, 2) + '\n', 'utf-8');
2046
2415
  let removedLocalTiles = 0;
2047
- const paths = [relative(projectRoot, appPath)];
2048
- for (const dashboardPath of findDashboardsForApp(loaded.appDir)) {
2049
- const loadedDashboard = loadDashboardDocument(dashboardPath).document;
2050
- if (!loadedDashboard)
2051
- continue;
2052
- const items = loadedDashboard.layout.items
2416
+ const dashboards = [];
2417
+ for (const source of dashboardSources) {
2418
+ const items = source.document.layout.items
2053
2419
  .filter((item) => {
2054
2420
  if (item.aiPin) {
2055
2421
  removedLocalTiles += 1;
@@ -2059,24 +2425,129 @@ export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
2059
2425
  })
2060
2426
  .map((item) => promoteSharedDashboardItem(item));
2061
2427
  const dashboard = {
2062
- ...loadedDashboard,
2428
+ ...source.document,
2063
2429
  metadata: {
2064
- ...loadedDashboard.metadata,
2430
+ ...source.document.metadata,
2065
2431
  visibility: 'shared',
2066
2432
  lifecycle,
2067
2433
  },
2068
2434
  layout: {
2069
- ...loadedDashboard.layout,
2435
+ ...source.document.layout,
2070
2436
  items,
2071
2437
  },
2072
2438
  };
2073
- const parsed = parseDashboardDocument(JSON.stringify(dashboard), dashboardPath);
2439
+ const parsed = parseDashboardDocument(JSON.stringify(dashboard), source.path);
2074
2440
  if (!parsed.document)
2075
2441
  return { ok: false, error: parsed.errors.map((err) => err.message).join('; ') };
2076
- writeFileSync(dashboardPath, JSON.stringify(parsed.document, null, 2) + '\n', 'utf-8');
2077
- paths.push(relative(projectRoot, dashboardPath));
2442
+ dashboards.push({ sourcePath: source.path, document: parsed.document });
2443
+ }
2444
+ // Stage and swap the whole App directory so validation or write failures do
2445
+ // not leave a partially published manifest/dashboard set.
2446
+ const stagingBase = join(projectRoot, '.dql', 'local', 'app-publish-staging');
2447
+ const stagedAppDir = join(stagingBase, `${appId}-next`);
2448
+ const backupAppDir = join(stagingBase, `${appId}-backup`);
2449
+ rmSync(stagedAppDir, { recursive: true, force: true });
2450
+ rmSync(backupAppDir, { recursive: true, force: true });
2451
+ mkdirSync(stagingBase, { recursive: true });
2452
+ cpSync(loaded.appDir, stagedAppDir, { recursive: true });
2453
+ writeFileSync(join(stagedAppDir, 'dql.app.json'), JSON.stringify(parsedApp.document, null, 2) + '\n', 'utf-8');
2454
+ for (const dashboard of dashboards) {
2455
+ const stagedPath = join(stagedAppDir, relative(loaded.appDir, dashboard.sourcePath));
2456
+ writeFileSync(stagedPath, JSON.stringify(dashboard.document, null, 2) + '\n', 'utf-8');
2457
+ }
2458
+ try {
2459
+ renameSync(loaded.appDir, backupAppDir);
2460
+ renameSync(stagedAppDir, loaded.appDir);
2461
+ rmSync(backupAppDir, { recursive: true, force: true });
2462
+ }
2463
+ catch (error) {
2464
+ if (existsSync(backupAppDir) && !existsSync(loaded.appDir))
2465
+ renameSync(backupAppDir, loaded.appDir);
2466
+ throw error;
2078
2467
  }
2079
- return { ok: true, app: parsedApp.document, paths, removedLocalTiles };
2468
+ const paths = [relative(projectRoot, appPath), ...dashboards.map((dashboard) => relative(projectRoot, dashboard.sourcePath))];
2469
+ return { ok: true, app: parsedApp.document, paths, removedLocalTiles, readiness };
2470
+ }
2471
+ export function approveAppSemanticTiles(projectRoot, appId, dashboardId, input) {
2472
+ const loaded = loadDashboardForApp(projectRoot, appId, dashboardId);
2473
+ if (!loaded)
2474
+ return { ok: false, error: `Dashboard "${dashboardId}" not found in App "${appId}".` };
2475
+ const currentFingerprint = `sha256:${createHash('sha256').update(JSON.stringify(loaded.dashboard)).digest('hex')}`;
2476
+ if (!input.expectedDashboardFingerprint || input.expectedDashboardFingerprint !== currentFingerprint) {
2477
+ return { ok: false, error: 'The dashboard changed after the reviewed run. Run it again before approval.' };
2478
+ }
2479
+ const selected = new Set(input.tileIds);
2480
+ const semanticIds = new Set(loaded.dashboard.layout.items.filter((item) => item.semantic).map((item) => item.i));
2481
+ const invalid = input.tileIds.filter((tileId) => !semanticIds.has(tileId));
2482
+ if (invalid.length > 0)
2483
+ return { ok: false, error: `Only semantic tiles can be approved here: ${invalid.join(', ')}` };
2484
+ const reviewedAt = new Date().toISOString();
2485
+ const dashboard = {
2486
+ ...loaded.dashboard,
2487
+ layout: {
2488
+ ...loaded.dashboard.layout,
2489
+ items: loaded.dashboard.layout.items.map((item) => {
2490
+ if (!selected.has(item.i) || !item.semantic)
2491
+ return item;
2492
+ return {
2493
+ ...item,
2494
+ semantic: { ...item.semantic, snapshotId: input.snapshotId },
2495
+ review: {
2496
+ status: 'approved',
2497
+ sourceFingerprint: item.semantic.definitionFingerprint,
2498
+ preflightReceiptId: input.runId,
2499
+ reviewedAt,
2500
+ reviewedBy: input.reviewer,
2501
+ },
2502
+ trustState: 'review_required',
2503
+ reviewStatus: 'review_required',
2504
+ };
2505
+ }),
2506
+ },
2507
+ };
2508
+ const parsed = parseDashboardDocument(JSON.stringify(dashboard), loaded.path);
2509
+ if (!parsed.document)
2510
+ return { ok: false, error: parsed.errors.map((error) => error.message).join('; ') };
2511
+ writeFileSync(loaded.path, JSON.stringify(parsed.document, null, 2) + '\n', 'utf-8');
2512
+ return { ok: true, dashboard: parsed.document, path: relative(projectRoot, loaded.path) };
2513
+ }
2514
+ function appPublicationReadiness(dashboards) {
2515
+ const blockers = [];
2516
+ let governedTiles = 0;
2517
+ for (const dashboard of dashboards) {
2518
+ for (const item of dashboard.layout.items) {
2519
+ if (item.block)
2520
+ governedTiles += 1;
2521
+ if (item.draftAnalysis) {
2522
+ blockers.push({
2523
+ dashboardId: dashboard.id,
2524
+ tileId: item.i,
2525
+ code: 'exploratory_source',
2526
+ message: `${dashboard.id}/${item.i} references app-scoped exploratory DQL; promote it to governed semantic logic or a certified block first.`,
2527
+ });
2528
+ }
2529
+ if (item.semantic) {
2530
+ governedTiles += 1;
2531
+ if (item.review?.status !== 'approved') {
2532
+ blockers.push({
2533
+ dashboardId: dashboard.id,
2534
+ tileId: item.i,
2535
+ code: 'semantic_review',
2536
+ message: `${dashboard.id}/${item.i} semantic result has not been explicitly approved.`,
2537
+ });
2538
+ }
2539
+ if (!item.semantic.snapshotId || !item.review?.preflightReceiptId) {
2540
+ blockers.push({
2541
+ dashboardId: dashboard.id,
2542
+ tileId: item.i,
2543
+ code: 'semantic_preflight',
2544
+ message: `${dashboard.id}/${item.i} needs a snapshot-bound semantic preflight receipt.`,
2545
+ });
2546
+ }
2547
+ }
2548
+ }
2549
+ }
2550
+ return { ready: blockers.length === 0, governedTiles, blockers };
2080
2551
  }
2081
2552
  function appAiBuildSessionDir(projectRoot) {
2082
2553
  return join(projectRoot, '.dql', 'local', 'app-ai-builds');
@@ -2163,6 +2634,10 @@ function appAskTileContext(tile) {
2163
2634
  tileId: tile.i,
2164
2635
  title: tile.title,
2165
2636
  blockId: tile.block && 'blockId' in tile.block ? tile.block.blockId : undefined,
2637
+ semantic: tile.semantic,
2638
+ draftAnalysis: tile.draftAnalysis,
2639
+ sourceClass: tile.sourceClass ?? (tile.block ? 'certified_block' : tile.semantic ? 'governed_semantic' : tile.draftAnalysis ? 'exploratory_analysis' : 'narrative'),
2640
+ review: tile.review,
2166
2641
  viz: tile.viz.type,
2167
2642
  trustState: tile.trustState ?? tile.display?.trustState,
2168
2643
  reviewStatus: tile.reviewStatus ?? tile.display?.reviewStatus,
@@ -2171,6 +2646,34 @@ function appAskTileContext(tile) {
2171
2646
  sourceEvidence: tile.sourceEvidence,
2172
2647
  };
2173
2648
  }
2649
+ function buildAppContextEnvelope(app, appDir, focus) {
2650
+ const dashboards = findDashboardsForApp(appDir)
2651
+ .map((path) => loadDashboardDocument(path).document)
2652
+ .filter((document) => Boolean(document))
2653
+ .map((document) => ({
2654
+ id: document.id,
2655
+ title: document.metadata.title,
2656
+ filters: document.filters,
2657
+ story: document.story,
2658
+ tiles: document.layout.items.map(appAskTileContext),
2659
+ }));
2660
+ return {
2661
+ version: 1,
2662
+ app: {
2663
+ id: app.id,
2664
+ name: app.name,
2665
+ domain: app.domain,
2666
+ audience: app.audience,
2667
+ lifecycle: app.lifecycle ?? 'draft',
2668
+ visibility: app.visibility ?? 'shared',
2669
+ publicationIntent: app.publicationIntent ?? (app.visibility === 'private' ? 'personal' : 'shared_project'),
2670
+ businessOutcome: app.businessOutcome,
2671
+ caveats: app.caveats ?? [],
2672
+ },
2673
+ dashboards,
2674
+ focus,
2675
+ };
2676
+ }
2174
2677
  function filterBindingsForBlockSource(projectRoot, block) {
2175
2678
  const absPath = join(projectRoot, block.path);
2176
2679
  if (!existsSync(absPath))
@@ -2354,6 +2857,35 @@ function promoteSharedDashboardItem(item) {
2354
2857
  reviewStatus,
2355
2858
  };
2356
2859
  }
2860
+ export function deleteAppPackage(projectRoot, id) {
2861
+ const cleanId = cleanString(id);
2862
+ if (!cleanId || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(cleanId)) {
2863
+ return { ok: false, status: 400, error: 'A valid App id is required.' };
2864
+ }
2865
+ const loaded = loadAppById(projectRoot, cleanId);
2866
+ if (!loaded)
2867
+ return { ok: false, status: 404, error: `App "${cleanId}" not found.` };
2868
+ const relativeAppDir = relative(projectRoot, loaded.appDir).replaceAll('\\', '/');
2869
+ const isProjectApp = /^apps\/[^/]+$/.test(relativeAppDir);
2870
+ const isDomainApp = /^domains\/(?:[^/]+\/)*apps\/[^/]+$/.test(relativeAppDir);
2871
+ if ((!isProjectApp && !isDomainApp) || relativeAppDir.startsWith('../')) {
2872
+ return { ok: false, status: 400, error: 'DQL refused to delete an App outside this project.' };
2873
+ }
2874
+ const trashRoot = join(projectRoot, '.dql', 'trash', 'apps');
2875
+ mkdirSync(trashRoot, { recursive: true });
2876
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
2877
+ let trashDir = join(trashRoot, `${stamp}-${cleanId}`);
2878
+ let suffix = 2;
2879
+ while (existsSync(trashDir))
2880
+ trashDir = join(trashRoot, `${stamp}-${cleanId}-${suffix++}`);
2881
+ renameSync(loaded.appDir, trashDir);
2882
+ return {
2883
+ ok: true,
2884
+ id: cleanId,
2885
+ deletedPath: relativeAppDir,
2886
+ trashPath: relative(projectRoot, trashDir).replaceAll('\\', '/'),
2887
+ };
2888
+ }
2357
2889
  export function createAppPackage(projectRoot, input) {
2358
2890
  const name = cleanString(input.name);
2359
2891
  const domain = cleanString(input.ownerDomain) || cleanString(input.domain);
@@ -4474,6 +5006,15 @@ function asRecord(value) {
4474
5006
  function cleanString(value) {
4475
5007
  return typeof value === 'string' ? value.trim() : '';
4476
5008
  }
5009
+ function appAiBuildSessionId(requested) {
5010
+ const clean = cleanString(requested);
5011
+ if (clean) {
5012
+ if (!/^app_build_[a-z0-9_:-]+$/i.test(clean))
5013
+ throw new Error('Invalid App build session id.');
5014
+ return clean;
5015
+ }
5016
+ return `app_build_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
5017
+ }
4477
5018
  function stringArray(value) {
4478
5019
  if (!Array.isArray(value))
4479
5020
  return undefined;
@@ -4495,6 +5036,43 @@ function slugify(value) {
4495
5036
  .replace(/[^a-z0-9]+/g, '-')
4496
5037
  .replace(/^-+|-+$/g, '');
4497
5038
  }
5039
+ function uniqueAppPageId(base, existingIds) {
5040
+ if (!existingIds.has(base))
5041
+ return base;
5042
+ for (let index = 2; index < 1000; index += 1) {
5043
+ const candidate = `${base}-${index}`;
5044
+ if (!existingIds.has(candidate))
5045
+ return candidate;
5046
+ }
5047
+ return `${base}-${Date.now()}`;
5048
+ }
5049
+ /**
5050
+ * API-009: bind an existing-App proposal to every Git-owned byte in the package. This is
5051
+ * deliberately stricter than the metadata snapshot: page, notebook, draft, and
5052
+ * App-document edits all invalidate approval before the atomic directory swap.
5053
+ */
5054
+ function fingerprintAppDirectory(appDir) {
5055
+ const files = [];
5056
+ const visit = (directory) => {
5057
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
5058
+ const absolutePath = join(directory, entry.name);
5059
+ if (entry.isDirectory())
5060
+ visit(absolutePath);
5061
+ else if (entry.isFile() || entry.isSymbolicLink()) {
5062
+ files.push({ path: absolutePath, symbolicLink: entry.isSymbolicLink() });
5063
+ }
5064
+ }
5065
+ };
5066
+ visit(appDir);
5067
+ const hash = createHash('sha256');
5068
+ for (const file of files.sort((left, right) => left.path.localeCompare(right.path))) {
5069
+ hash.update(relative(appDir, file.path));
5070
+ hash.update('\0');
5071
+ hash.update(file.symbolicLink ? `link:${readlinkSync(file.path)}` : readFileSync(file.path));
5072
+ hash.update('\0');
5073
+ }
5074
+ return `sha256:${hash.digest('hex')}`;
5075
+ }
4498
5076
  function resolveAppPackageDir(projectRoot, id) {
4499
5077
  return join(projectRoot, 'apps', id);
4500
5078
  }
@@ -5204,7 +5782,7 @@ function loadDashboardForApp(projectRoot, appId, dashboardId) {
5204
5782
  for (const d of findDashboardsForApp(appDir)) {
5205
5783
  const { document: dd } = loadDashboardDocument(d);
5206
5784
  if (dd && dd.id === dashboardId) {
5207
- return { app: document, dashboard: dd };
5785
+ return { app: document, dashboard: dd, path: d };
5208
5786
  }
5209
5787
  }
5210
5788
  }