@duckcodeailabs/dql-cli 1.13.1 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/apps-api.d.ts +225 -5
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +1950 -117
  4. package/dist/apps-api.js.map +1 -1
  5. package/dist/assets/dql-notebook/assets/{AgentLogPage-D6fgEAEy.js → AgentLogPage-BlUE3qsX.js} +1 -1
  6. package/dist/assets/dql-notebook/assets/{AiBuildDialog-8miXFPse.js → AiBuildDialog-O88iK6UW.js} +1 -1
  7. package/dist/assets/dql-notebook/assets/{AiBuildResult-DPJ_SI0R.js → AiBuildResult-D-6G4Sh3.js} +8 -8
  8. package/dist/assets/dql-notebook/assets/{AiSidePanel-NVA6lZnn.js → AiSidePanel-MRSGtQDV.js} +1 -1
  9. package/dist/assets/dql-notebook/assets/AnalyticsHome-CcRVO6zN.js +6 -0
  10. package/dist/assets/dql-notebook/assets/AppsView-BTkGC-wl.js +5588 -0
  11. package/dist/assets/dql-notebook/assets/{BlockStudio--jNw9XUI.js → BlockStudio-BrTNaDpb.js} +10 -15
  12. package/dist/assets/dql-notebook/assets/{BusinessArtifactView-DtRMiaF4.js → BusinessArtifactView-itEIUrYy.js} +1 -1
  13. package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-CbAqOpNc.js +22 -0
  14. package/dist/assets/dql-notebook/assets/{GitPage-CazC-eTR.js → GitPage-BxOqRYu2.js} +1 -1
  15. package/dist/assets/dql-notebook/assets/GlobalAiRail-Tfkx76CI.js +1 -0
  16. package/dist/assets/dql-notebook/assets/GovernedContextPage-CeaDbepP.js +7 -0
  17. package/dist/assets/dql-notebook/assets/{HelpDocsPage-DMHPsVqP.js → HelpDocsPage-D1lAryYv.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/{HomePage-DcSI4dnU.js → HomePage-GB6se6xW.js} +1 -1
  19. package/dist/assets/dql-notebook/assets/{LineageDAG-BJ6xz06u.js → LineageDAG-zCyN-hdg.js} +1 -1
  20. package/dist/assets/dql-notebook/assets/{LineageDetailView-8WrQm-MF.js → LineageDetailView-Cg0TgG2Y.js} +1 -1
  21. package/dist/assets/dql-notebook/assets/{LineageDrawer-hnqCW_t-.js → LineageDrawer-DwMcenDf.js} +1 -1
  22. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-B4LZTUsl.js → LineagePathBreadcrumb-DUZx3ksU.js} +1 -1
  23. package/dist/assets/dql-notebook/assets/{MiniLineageGraph-CotnRuxa.js → MiniLineageGraph-DT_ycl6s.js} +1 -1
  24. package/dist/assets/dql-notebook/assets/{NewBlockModal-DHn-CEEW.js → NewBlockModal-qdhzeJPF.js} +1 -1
  25. package/dist/assets/dql-notebook/assets/{NewNotebookModal-BPGp-8oq.js → NewNotebookModal-BnaDzs1A.js} +1 -1
  26. package/dist/assets/dql-notebook/assets/{NotebookEditor-B0OTHM6W.js → NotebookEditor-CouPFzdo.js} +5 -5
  27. package/dist/assets/dql-notebook/assets/{ReadinessPage-CoOIpxWq.js → ReadinessPage-DB_KbH9Y.js} +1 -1
  28. package/dist/assets/dql-notebook/assets/{SetupOnboarding-DxK_mFzf.js → SetupOnboarding-CamQq_N2.js} +1 -1
  29. package/dist/assets/dql-notebook/assets/SkillsPage--mYkOAK2.js +10 -0
  30. package/dist/assets/dql-notebook/assets/{TrustBadge-ZuD5N5Ww.js → TrustBadge-ClEYiYeA.js} +1 -1
  31. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-DBfsmXes.js +88 -0
  32. package/dist/assets/dql-notebook/assets/{answer-to-notebook-WAVAYjBv.js → answer-to-notebook-Cg3UyGwh.js} +1 -1
  33. package/dist/assets/dql-notebook/assets/{arrow-left-L8odLbyN.js → arrow-left-DnYPGqlr.js} +1 -1
  34. package/dist/assets/dql-notebook/assets/{arrow-right-WIF1V9a9.js → arrow-right-Ct_YGrmU.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/{book-open-text-BpqWMa4m.js → book-open-text-DULlwbj6.js} +1 -1
  36. package/dist/assets/dql-notebook/assets/{circle-x-CQBOM0b0.js → circle-x-ByYxoiS9.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{dagre.esm-DlZi7NR3.js → dagre.esm-BjYSO53F.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/{external-link-DYqdQIGp.js → external-link-DprWh9Yf.js} +1 -1
  39. package/dist/assets/dql-notebook/assets/{grip-vertical-HmUijbwN.js → grip-vertical-CrEEYkXN.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/{index-Cpx47jCH.js → index-9w93YPF9.js} +127 -126
  41. package/dist/assets/dql-notebook/assets/index-B3shyZsg.css +1 -0
  42. package/dist/assets/dql-notebook/assets/link-2-BkW-rkEC.js +6 -0
  43. package/dist/assets/dql-notebook/assets/{list-tree-DGKqCxmz.js → list-tree-BipBLmIE.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/{minimize-2-C8dvqCxX.js → minimize-2-BhXsglrO.js} +1 -1
  45. package/dist/assets/dql-notebook/assets/{panel-right-open-yuVkIo5n.js → panel-right-open-BoCSilYB.js} +1 -1
  46. package/dist/assets/dql-notebook/assets/{bot-CLR1V6dO.js → play-mdoZawPu.js} +7 -2
  47. package/dist/assets/dql-notebook/assets/{rotate-ccw-Di0kRnrM.js → rotate-ccw-ELiDRUmj.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{semantic-fields-BMQV0li8.js → semantic-fields-DVNRLfeU.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{sliders-horizontal-alebptlJ.js → sliders-horizontal-GeUzpmKz.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{star-CgCQbnXc.js → star-D63O81Wp.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/{triangle-alert-SyuSZ4ht.js → triangle-alert-BWrCxQVE.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/upload-CI-ldXhC.js +6 -0
  53. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Cv7Yq2hK.js +32 -0
  54. package/dist/assets/dql-notebook/assets/{user-round-J4PQHxo9.js → user-round-Do21aFGo.js} +1 -1
  55. package/dist/assets/dql-notebook/assets/{wand-sparkles-c4WDXTCk.js → wand-sparkles-DT7u7F_x.js} +1 -1
  56. package/dist/assets/dql-notebook/assets/{workflow-NtRLaq7n.js → workflow-DHh2mMbn.js} +1 -1
  57. package/dist/assets/dql-notebook/assets/{wrench-CS1f1Zv4.js → wrench-7tYr_GKb.js} +1 -1
  58. package/dist/assets/dql-notebook/assets/{x-CbKEfe5m.js → x-CcYceFMz.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 +65 -0
  70. package/dist/local-runtime.d.ts.map +1 -1
  71. package/dist/local-runtime.js +1008 -125
  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/DbtFirstModelingPage-DhBsLCYr.js +0 -22
  83. package/dist/assets/dql-notebook/assets/GlobalAiRail-1Tgweuu2.js +0 -1
  84. package/dist/assets/dql-notebook/assets/GovernedContextPage-C7qGrH3d.js +0 -16
  85. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-duFXXNV_.js +0 -88
  86. package/dist/assets/dql-notebook/assets/index-DKu4CZt3.css +0 -1
  87. package/dist/assets/dql-notebook/assets/upload-BPhfea_E.js +0 -11
  88. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Bnl9gWhj.js +0 -28
package/dist/apps-api.js CHANGED
@@ -6,7 +6,7 @@
6
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
- import { loadAppDocument, findAppDocuments, loadDashboardDocument, findDashboardsForApp, parseAppDocument, parseDashboardDocument, normalizeDqlArtifactReference, suggestAppId, } from '@duckcodeailabs/dql-core';
9
+ import { loadAppDocument, findAppDocuments, loadDashboardDocument, findDashboardsForApp, parseAppDocument, parseDashboardDocument, normalizeDqlArtifactReference, suggestAppId, createAppBuildDraft, applyAppBuildDraftOperations, } from '@duckcodeailabs/dql-core';
10
10
  import { defaultPersonaRegistry, defaultLocalAppsDbPath, LocalAppStorage, personaFromMember, } from '@duckcodeailabs/dql-project';
11
11
  export async function handleAppsApi(ctx) {
12
12
  const { req, res, path, projectRoot } = ctx;
@@ -26,6 +26,188 @@ export async function handleAppsApi(ctx) {
26
26
  return true;
27
27
  }
28
28
  // ── Apps ────────────────────────────────────────────────────────────────
29
+ if (req.method === 'POST' && path === '/api/app-builds') {
30
+ try {
31
+ const body = await readJson(req);
32
+ const draft = createStoredAppBuildDraft(projectRoot, body);
33
+ sendJson(res, 201, { ok: true, draft });
34
+ }
35
+ catch (err) {
36
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
37
+ }
38
+ return true;
39
+ }
40
+ if (req.method === 'GET' && path === '/api/app-builds') {
41
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
42
+ try {
43
+ sendJson(res, 200, { ok: true, drafts: storage.listAppBuildDrafts() });
44
+ }
45
+ finally {
46
+ storage.close();
47
+ }
48
+ return true;
49
+ }
50
+ let buildMatch = path.match(/^\/api\/app-builds\/([^/]+)$/);
51
+ if (buildMatch && req.method === 'GET') {
52
+ const draft = loadStoredAppBuildDraft(projectRoot, decodeURIComponent(buildMatch[1]));
53
+ sendJson(res, draft ? 200 : 404, draft ? { ok: true, draft } : { ok: false, error: 'App Build Draft not found.' });
54
+ return true;
55
+ }
56
+ if (buildMatch && req.method === 'PATCH') {
57
+ try {
58
+ const id = decodeURIComponent(buildMatch[1]);
59
+ const current = loadStoredAppBuildDraft(projectRoot, id);
60
+ if (!current) {
61
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
62
+ return true;
63
+ }
64
+ const body = await readJson(req);
65
+ if (body.expectedProposalHash && body.expectedProposalHash !== current.proposalHash) {
66
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_PROPOSAL_CONFLICT: refresh the local draft before applying changes.' });
67
+ return true;
68
+ }
69
+ const next = applyAppBuildDraftOperations(current, body.expectedRevision ?? -1, body.operations ?? []);
70
+ writeStoredAppBuildDraft(projectRoot, next, {
71
+ expectedRevision: current.revision,
72
+ operations: body.operations ?? [],
73
+ });
74
+ sendJson(res, 200, { ok: true, draft: next });
75
+ }
76
+ catch (err) {
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ sendJson(res, message.includes('APP_BUILD_REVISION_CONFLICT') ? 409 : 400, { ok: false, error: message });
79
+ }
80
+ return true;
81
+ }
82
+ if (buildMatch && req.method === 'DELETE') {
83
+ try {
84
+ const id = decodeURIComponent(buildMatch[1]);
85
+ const current = loadStoredAppBuildDraft(projectRoot, id);
86
+ if (!current) {
87
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
88
+ return true;
89
+ }
90
+ const body = await readJson(req);
91
+ if (body.expectedRevision !== current.revision || body.proposalHash !== current.proposalHash) {
92
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh before deleting this local draft.' });
93
+ return true;
94
+ }
95
+ sendJson(res, 200, deleteStoredAppBuildDraft(projectRoot, current));
96
+ }
97
+ catch (err) {
98
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
99
+ }
100
+ return true;
101
+ }
102
+ const askResultMatch = path.match(/^\/api\/app-builds\/([^/]+)\/ask-results$/);
103
+ if (askResultMatch && req.method === 'POST') {
104
+ try {
105
+ const id = decodeURIComponent(askResultMatch[1]);
106
+ const body = await readJson(req);
107
+ const result = addAskResultToAppBuildDraft(projectRoot, id, body);
108
+ sendJson(res, result.deduped ? 200 : 201, result);
109
+ }
110
+ catch (err) {
111
+ const message = err instanceof Error ? err.message : String(err);
112
+ sendJson(res, message.includes('APP_BUILD_') ? 409 : 400, { ok: false, error: message });
113
+ }
114
+ return true;
115
+ }
116
+ const draftAiProposalMatch = path.match(/^\/api\/app-builds\/([^/]+)\/ai-proposals$/);
117
+ if (draftAiProposalMatch && req.method === 'POST') {
118
+ try {
119
+ const id = decodeURIComponent(draftAiProposalMatch[1]);
120
+ const current = loadStoredAppBuildDraft(projectRoot, id);
121
+ if (!current) {
122
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
123
+ return true;
124
+ }
125
+ const body = await readJson(req);
126
+ if (body.expectedRevision !== current.revision || body.proposalHash !== current.proposalHash) {
127
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh before asking AI to change this draft.' });
128
+ return true;
129
+ }
130
+ const proposal = await proposeAppBuildDraftOperations(projectRoot, current, {
131
+ prompt: cleanString(body.prompt) || current.frame.goal,
132
+ selectedBlockIds: body.selectedBlockIds,
133
+ }, { generateGovernedAnswer: ctx.generateGovernedAnswer });
134
+ sendJson(res, 201, { ok: true, proposal });
135
+ }
136
+ catch (err) {
137
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
138
+ }
139
+ return true;
140
+ }
141
+ buildMatch = path.match(/^\/api\/app-builds\/([^/]+)\/preflight$/);
142
+ if (buildMatch && req.method === 'POST') {
143
+ const current = loadStoredAppBuildDraft(projectRoot, decodeURIComponent(buildMatch[1]));
144
+ if (!current) {
145
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
146
+ return true;
147
+ }
148
+ const body = await readJson(req);
149
+ if ((body.expectedRevision !== undefined && body.expectedRevision !== current.revision)
150
+ || (body.proposalHash && body.proposalHash !== current.proposalHash)) {
151
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh before preflight.' });
152
+ return true;
153
+ }
154
+ const errors = preflightAppBuildRequestErrors(projectRoot, current, ctx.verifyAppBuildPreview);
155
+ if (errors.length) {
156
+ sendJson(res, 400, {
157
+ ok: false,
158
+ code: 'APP_BUILD_NOT_READY',
159
+ message: 'This App is not ready to publish yet.',
160
+ draft: current,
161
+ errors,
162
+ details: { draft: current, errors },
163
+ });
164
+ return true;
165
+ }
166
+ const next = markStoredAppBuildDraftPreflighted(projectRoot, current);
167
+ sendJson(res, 200, { ok: true, draft: next, errors: [] });
168
+ return true;
169
+ }
170
+ buildMatch = path.match(/^\/api\/app-builds\/([^/]+)\/(publish-to-project|commit)$/);
171
+ if (buildMatch && req.method === 'POST') {
172
+ try {
173
+ const current = loadStoredAppBuildDraft(projectRoot, decodeURIComponent(buildMatch[1]));
174
+ if (!current) {
175
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
176
+ return true;
177
+ }
178
+ const body = await readJson(req);
179
+ if (body.expectedRevision !== current.revision || body.proposalHash !== current.proposalHash) {
180
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh the Build Frame before committing.' });
181
+ return true;
182
+ }
183
+ const errors = preflightAppBuildRequestErrors(projectRoot, current, ctx.verifyAppBuildPreview);
184
+ if (errors.length) {
185
+ sendJson(res, 400, {
186
+ ok: false,
187
+ code: 'APP_BUILD_NOT_READY',
188
+ message: 'This App is not ready to publish yet.',
189
+ draft: current,
190
+ errors,
191
+ details: { draft: current, errors },
192
+ });
193
+ return true;
194
+ }
195
+ // The final confirmation consumes the exact receipt the user reviewed.
196
+ // Compatibility callers that arrive without one are preflighted once in
197
+ // this same request so they cannot enter a client-side recheck loop.
198
+ const prepared = hasCurrentAppBuildPreflightReceipt(current)
199
+ ? current
200
+ : markStoredAppBuildDraftPreflighted(projectRoot, current);
201
+ const result = publishStoredAppBuildDraft(projectRoot, prepared);
202
+ await refreshGeneratedMetadata(projectRoot);
203
+ sendJson(res, 201, { ...result, compatibilityRoute: buildMatch[2] === 'commit' });
204
+ }
205
+ catch (err) {
206
+ const message = err instanceof Error ? err.message : String(err);
207
+ sendJson(res, message.includes('already exists') ? 409 : 400, { ok: false, error: message });
208
+ }
209
+ return true;
210
+ }
29
211
  if (req.method === 'GET' && path === '/api/apps') {
30
212
  const apps = collectAppsList(projectRoot);
31
213
  sendJson(res, 200, { apps });
@@ -156,7 +338,9 @@ export async function handleAppsApi(ctx) {
156
338
  if (req.method === 'POST' && path === '/api/apps') {
157
339
  try {
158
340
  const body = await readJson(req);
159
- const result = createAppPackage(projectRoot, body);
341
+ // Compatibility adapter: the legacy manual endpoint now traverses the
342
+ // same versioned draft, preflight, and private commit service as Studio.
343
+ const result = createAppPackageViaBuildDraft(projectRoot, body);
160
344
  if (!result.ok) {
161
345
  sendJson(res, 400, { error: result.error });
162
346
  return true;
@@ -204,6 +388,24 @@ export async function handleAppsApi(ctx) {
204
388
  }
205
389
  return true;
206
390
  }
391
+ m = path.match(/^\/api\/apps\/([^/]+)\/publish-to-project$/);
392
+ if (m && req.method === 'POST') {
393
+ const appId = decodeURIComponent(m[1]);
394
+ try {
395
+ const body = await readJson(req);
396
+ const result = promoteAppForStakeholders(projectRoot, appId, body);
397
+ if (!result.ok) {
398
+ sendJson(res, 409, result);
399
+ return true;
400
+ }
401
+ await refreshGeneratedMetadata(projectRoot);
402
+ sendJson(res, 200, result);
403
+ }
404
+ catch (err) {
405
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
406
+ }
407
+ return true;
408
+ }
207
409
  m = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/approve-semantic$/);
208
410
  if (m && req.method === 'POST') {
209
411
  const appId = decodeURIComponent(m[1]);
@@ -714,7 +916,8 @@ export async function handleAppsApi(ctx) {
714
916
  if (m && req.method === 'DELETE') {
715
917
  const id = decodeURIComponent(m[1]);
716
918
  try {
717
- const result = deleteAppPackage(projectRoot, id);
919
+ const body = await readJson(req);
920
+ const result = deleteAppPackage(projectRoot, id, body);
718
921
  if (!result.ok) {
719
922
  sendJson(res, result.status, { ok: false, error: result.error });
720
923
  return true;
@@ -727,6 +930,39 @@ export async function handleAppsApi(ctx) {
727
930
  }
728
931
  return true;
729
932
  }
933
+ m = path.match(/^\/api\/app-recoveries\/([^/]+)\/restore$/);
934
+ if (m && req.method === 'POST') {
935
+ try {
936
+ const result = restoreAppRecovery(projectRoot, decodeURIComponent(m[1]));
937
+ if (!result.ok) {
938
+ sendJson(res, result.status, result);
939
+ return true;
940
+ }
941
+ ctx.scheduleProjectRefresh?.('app-restored');
942
+ sendJson(res, 200, result);
943
+ }
944
+ catch (err) {
945
+ sendJson(res, 500, { ok: false, error: err.message });
946
+ }
947
+ return true;
948
+ }
949
+ if (m && req.method === 'PATCH') {
950
+ const id = decodeURIComponent(m[1]);
951
+ try {
952
+ const body = await readJson(req);
953
+ const result = renameApp(projectRoot, id, body);
954
+ if (!result.ok) {
955
+ sendJson(res, result.code === 'APP_NOT_FOUND' ? 404 : result.code === 'APP_CHANGED' ? 409 : 400, result);
956
+ return true;
957
+ }
958
+ ctx.scheduleProjectRefresh?.('app-renamed');
959
+ sendJson(res, 200, result);
960
+ }
961
+ catch (err) {
962
+ sendJson(res, 500, { ok: false, error: err.message });
963
+ }
964
+ return true;
965
+ }
730
966
  if (m && req.method === 'GET') {
731
967
  const id = m[1];
732
968
  const result = loadAppById(projectRoot, id);
@@ -830,6 +1066,7 @@ function collectAppsList(projectRoot) {
830
1066
  out.push({
831
1067
  id: document.id,
832
1068
  name: document.name,
1069
+ fingerprint: fingerprintAppDirectory(appDir),
833
1070
  filePath: relative(projectRoot, appDir),
834
1071
  domain: document.domain,
835
1072
  ownerDomain: document.ownerDomain,
@@ -1242,13 +1479,51 @@ function buildAppProposal(plan) {
1242
1479
  seenGap.add(question.toLowerCase());
1243
1480
  gaps.push({ id: `gap_${gaps.length + 1}`, question, reason: 'No certified block covers this question.' });
1244
1481
  }
1482
+ const metrics = Array.from(new Set(plan.requirements.flatMap((requirement) => requirement.measures)));
1483
+ const dimensions = Array.from(new Set(plan.requirements.flatMap((requirement) => requirement.dimensions)));
1484
+ const filters = Array.from(new Set(plan.requirements.flatMap((requirement) => requirement.filters)));
1485
+ const clarifications = [];
1486
+ if (metrics.length === 0) {
1487
+ clarifications.push({
1488
+ id: 'metric',
1489
+ question: 'Which governed business metric should lead this App?',
1490
+ choices: ['Choose a certified block', 'Choose a semantic metric', 'Describe the calculation as review-required analysis'],
1491
+ required: true,
1492
+ });
1493
+ }
1494
+ if (tiles.length === 0 || gaps.length > 0) {
1495
+ clarifications.push({
1496
+ id: 'coverage',
1497
+ question: 'How should DQL handle requirements without governed coverage?',
1498
+ choices: ['Refine metrics or dimensions', 'Use governed semantic coverage', 'Include review-required analysis', 'Keep visible gaps'],
1499
+ required: tiles.length === 0,
1500
+ });
1501
+ }
1502
+ if (!plan.prompt.match(/\b(day|week|month|quarter|year|today|yesterday|current|previous|over time)\b/i)) {
1503
+ clarifications.push({
1504
+ id: 'time',
1505
+ question: 'What time window and comparison should the App use?',
1506
+ choices: ['Current period vs previous period', 'Trailing 30 days', 'Year to date', 'No time comparison'],
1507
+ required: false,
1508
+ });
1509
+ }
1245
1510
  return {
1246
1511
  intent: {
1247
1512
  target: plan.publicationIntent,
1248
1513
  initialVisibility: 'private',
1249
1514
  },
1515
+ buildFrame: {
1516
+ goal: plan.businessGoal,
1517
+ audience: plan.audience,
1518
+ metrics,
1519
+ dimensions,
1520
+ grain: plan.requirements.find((requirement) => requirement.grain)?.grain,
1521
+ filters,
1522
+ desiredOutput: plan.planning.displayStrategy,
1523
+ },
1250
1524
  tiles,
1251
1525
  gaps,
1526
+ clarifications,
1252
1527
  followUps: gaps.map((gap) => gap.question).slice(0, 4),
1253
1528
  coverage: {
1254
1529
  certifiedTiles: tiles.filter((tile) => tile.certification === 'certified').length,
@@ -1343,6 +1618,7 @@ async function fillProposalGaps(proposal, hooks, maxGeneratedTiles) {
1343
1618
  certification: 'ai_generated',
1344
1619
  sourceClass: 'exploratory_analysis',
1345
1620
  reviewStatus: 'required',
1621
+ requirementIds: gap.id.startsWith('gap_') ? [gap.id.slice('gap_'.length)] : undefined,
1346
1622
  preflight: {
1347
1623
  status: preview ? 'passed' : unresolvedExecutionError ? 'blocked' : 'review_required',
1348
1624
  sourceFingerprint: `sha256:${createHash('sha256').update(sql).digest('hex')}`,
@@ -1383,6 +1659,7 @@ async function fillProposalGaps(proposal, hooks, maxGeneratedTiles) {
1383
1659
  certification: 'ai_generated',
1384
1660
  sourceClass: 'exploratory_analysis',
1385
1661
  reviewStatus: 'required',
1662
+ requirementIds: gap.id.startsWith('gap_') ? [gap.id.slice('gap_'.length)] : undefined,
1386
1663
  preflight: {
1387
1664
  status: 'blocked',
1388
1665
  message: 'Exploratory analysis generation failed.',
@@ -1530,6 +1807,309 @@ export async function proposeAppAiBuild(projectRoot, input, hooks) {
1530
1807
  kg.close();
1531
1808
  }
1532
1809
  }
1810
+ function materializeAppBuildProposalReviewAnalyses(projectRoot, draft, plan, proposalId, candidates, inputDashboards) {
1811
+ const eligible = candidates.filter((candidate) => (candidate.source === 'ai_generated'
1812
+ && Boolean(candidate.sql)
1813
+ && !candidate.error
1814
+ && candidate.preflight.status === 'passed'));
1815
+ if (eligible.length === 0 || inputDashboards.length === 0) {
1816
+ return { dashboards: inputDashboards, sourceOperations: [], reviewTasks: [], coverageByRequirement: new Map() };
1817
+ }
1818
+ const proposalKey = slugify(proposalId) || `proposal-${Date.now()}`;
1819
+ const relativeDraftDir = `proposals/${proposalKey}/drafts`;
1820
+ const absoluteDraftDir = join(projectRoot, '.dql', 'local', 'app-builds', draft.id, relativeDraftDir);
1821
+ mkdirSync(absoluteDraftDir, { recursive: true });
1822
+ const sourceOperations = [];
1823
+ const reviewTasks = [];
1824
+ const coverageByRequirement = new Map();
1825
+ const generatedItems = [];
1826
+ const normalizeQuestion = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
1827
+ for (const [index, candidate] of eligible.entries()) {
1828
+ const sql = candidate.sql;
1829
+ if (sql.includes('"""'))
1830
+ throw new Error(`Exploratory SQL for "${candidate.title}" contains an unsupported triple-quote delimiter.`);
1831
+ const candidateKey = slugify(candidate.id) || `analysis-${index + 1}`;
1832
+ const blockName = `app_${slugify(draft.appId)}_${candidateKey}`;
1833
+ const sourceRef = `${relativeDraftDir}/${candidateKey}.dql`;
1834
+ const source = [
1835
+ `block "${escapeDqlString(blockName)}" {`,
1836
+ ` domain = "${escapeDqlString(plan.domain)}"`,
1837
+ ' type = "custom"',
1838
+ ' status = "review"',
1839
+ ` owner = "${escapeDqlString(plan.owner)}"`,
1840
+ ` description = "${escapeDqlString((candidate.answer ?? candidate.question ?? candidate.title).slice(0, 240))}"`,
1841
+ candidate.question ? ` llmContext = "${escapeDqlString(`App-scoped exploratory analysis. Question: ${candidate.question}`)}"` : '',
1842
+ ' caveats = ["AI-generated App analysis. Validate joins, filters, grain, and business interpretation before promotion."]',
1843
+ ' tags = ["app-scoped", "ai-generated", "needs-review"]',
1844
+ '',
1845
+ ' query = """',
1846
+ sql,
1847
+ ' """',
1848
+ '',
1849
+ ' visualization {',
1850
+ ` chart = "${escapeDqlString(normalizeGeneratedViz(candidate.viz))}"`,
1851
+ ' }',
1852
+ '}',
1853
+ '',
1854
+ ].filter(Boolean).join('\n');
1855
+ const fingerprint = `sha256:${createHash('sha256').update(source).digest('hex')}`;
1856
+ writeFileSync(join(absoluteDraftDir, `${candidateKey}.dql`), source, 'utf-8');
1857
+ const sourceId = `review-dql:${candidate.id}`;
1858
+ const componentId = `review_${candidateKey}`;
1859
+ sourceOperations.push({
1860
+ type: 'upsert_source',
1861
+ source: {
1862
+ id: sourceId,
1863
+ kind: 'review_dql',
1864
+ sourceRef,
1865
+ qualifiedIdentity: candidate.title,
1866
+ snapshotId: candidate.preflight.snapshotId ?? plan.snapshotId,
1867
+ sourceFingerprint: fingerprint,
1868
+ receiptId: candidate.preflight.receiptId,
1869
+ trustState: 'review_required',
1870
+ reviewStatus: 'required',
1871
+ },
1872
+ });
1873
+ reviewTasks.push({
1874
+ type: 'set_review_task',
1875
+ task: {
1876
+ id: `review-generated-${candidateKey}`,
1877
+ message: `Validate the joins, grain, filters, and business interpretation for ${candidate.title}.`,
1878
+ status: 'open',
1879
+ sourceId,
1880
+ pageId: inputDashboards[0].id,
1881
+ tileId: componentId,
1882
+ },
1883
+ });
1884
+ const requirementIds = candidate.requirementIds?.length
1885
+ ? candidate.requirementIds
1886
+ : plan.requirements.filter((requirement) => normalizeQuestion(requirement.question) === normalizeQuestion(candidate.question ?? '')).map((requirement) => requirement.id);
1887
+ for (const requirementId of requirementIds) {
1888
+ const current = coverageByRequirement.get(requirementId) ?? { sourceIds: [], componentIds: [] };
1889
+ current.sourceIds.push(sourceId);
1890
+ current.componentIds.push(componentId);
1891
+ coverageByRequirement.set(requirementId, current);
1892
+ }
1893
+ generatedItems.push({
1894
+ i: componentId,
1895
+ x: (index % 2) * 6,
1896
+ y: Math.floor(index / 2) * 4,
1897
+ w: 6,
1898
+ h: 4,
1899
+ draftAnalysis: {
1900
+ ref: sourceRef,
1901
+ artifactFingerprint: fingerprint,
1902
+ ...(candidate.preflight.snapshotId ?? plan.snapshotId ? { snapshotId: candidate.preflight.snapshotId ?? plan.snapshotId } : {}),
1903
+ ...(candidate.preflight.receiptId ? { executionReceiptId: candidate.preflight.receiptId } : {}),
1904
+ },
1905
+ viz: { type: normalizeGeneratedViz(candidate.viz) },
1906
+ title: candidate.title,
1907
+ sourceClass: 'exploratory_analysis',
1908
+ sourceEvidence: [
1909
+ ...(candidate.sourceEvidence ?? []),
1910
+ {
1911
+ source: sourceRef,
1912
+ reason: candidate.question
1913
+ ? `AI generated this private analysis for: ${candidate.question}`
1914
+ : 'AI generated this private analysis for a requirement without governed coverage.',
1915
+ kind: 'review_dql',
1916
+ trustState: 'review_required',
1917
+ },
1918
+ ],
1919
+ review: { status: 'required', sourceFingerprint: fingerprint, ...(candidate.preflight.receiptId ? { preflightReceiptId: candidate.preflight.receiptId } : {}) },
1920
+ trustState: 'review_required',
1921
+ reviewStatus: 'review_required',
1922
+ });
1923
+ }
1924
+ const first = inputDashboards[0];
1925
+ const baseY = first.layout.items.reduce((max, item) => Math.max(max, item.y + item.h), 0);
1926
+ const items = [...first.layout.items, ...generatedItems.map((item) => ({ ...item, y: item.y + baseY }))];
1927
+ const sections = first.sections
1928
+ ? first.sections.some((section) => section.kind === 'appendix')
1929
+ ? first.sections
1930
+ : [...first.sections, { id: 'review-appendix', title: 'Review-required analysis', kind: 'appendix', order: first.sections.length }]
1931
+ : undefined;
1932
+ const appendixId = sections?.find((section) => section.kind === 'appendix')?.id;
1933
+ const finalItems = appendixId
1934
+ ? items.map((item) => generatedItems.some((generated) => generated.i === item.i) ? { ...item, sectionId: appendixId } : item)
1935
+ : items;
1936
+ const page = {
1937
+ ...first,
1938
+ ...(sections ? { sections } : {}),
1939
+ layout: {
1940
+ ...first.layout,
1941
+ items: finalItems,
1942
+ responsive: responsiveLayoutsForDashboardItems(finalItems),
1943
+ },
1944
+ };
1945
+ return {
1946
+ dashboards: [page, ...inputDashboards.slice(1)],
1947
+ sourceOperations,
1948
+ reviewTasks,
1949
+ coverageByRequirement,
1950
+ };
1951
+ }
1952
+ /**
1953
+ * AI is a coauthor of the same local AppBuildDraft used by manual authoring.
1954
+ * This adapter reuses the governed planner, compiles its result outside the
1955
+ * project, and returns typed operations. It does not mutate the draft or write
1956
+ * any canonical App source.
1957
+ */
1958
+ export async function proposeAppBuildDraftOperations(projectRoot, draft, input, hooks) {
1959
+ const session = await proposeAppAiBuild(projectRoot, {
1960
+ sessionId: `app_build_${draft.id}_${draft.revision}`,
1961
+ prompt: input.prompt,
1962
+ domain: draft.pages[0]?.metadata.domain,
1963
+ audience: draft.frame.audience,
1964
+ mode: draft.sourcePolicy === 'include_review_required' ? 'personal' : 'stakeholder',
1965
+ exploreGaps: draft.sourcePolicy === 'include_review_required',
1966
+ selectedBlockIds: input.selectedBlockIds,
1967
+ ...(draft.baseApp ? { existingAppId: draft.baseApp.appId } : {}),
1968
+ }, hooks);
1969
+ if (session.status !== 'proposed' || !session.plan || !session.proposal) {
1970
+ throw new Error(session.error ?? 'The App planner did not produce a proposal.');
1971
+ }
1972
+ const plan = session.plan;
1973
+ const { KGStore, defaultKgPath, materializeAppPlanDocuments } = await import('@duckcodeailabs/dql-agent');
1974
+ const kg = new KGStore(defaultKgPath(projectRoot));
1975
+ let dashboards;
1976
+ try {
1977
+ dashboards = materializeAppPlanDocuments(plan, kg).dashboards;
1978
+ }
1979
+ finally {
1980
+ kg.close();
1981
+ }
1982
+ const proposalTileById = new Map(session.proposal.tiles.map((tile) => [tile.id, tile]));
1983
+ const plannedTiles = plan.pages.flatMap((page) => page.tiles);
1984
+ const sourceOperations = plannedTiles.flatMap((tile) => {
1985
+ const proposalTile = proposalTileById.get(tile.id);
1986
+ if (tile.kind === 'certified_block' && tile.blockId) {
1987
+ return [{
1988
+ type: 'upsert_source',
1989
+ source: {
1990
+ id: `block:${tile.blockId}`,
1991
+ kind: 'certified_block',
1992
+ sourceRef: tile.blockId,
1993
+ sourceFingerprint: proposalTile?.preflight.sourceFingerprint,
1994
+ receiptId: proposalTile?.preflight.receiptId,
1995
+ trustState: 'certified',
1996
+ reviewStatus: 'not_required',
1997
+ },
1998
+ }];
1999
+ }
2000
+ if (tile.kind === 'semantic_query' && tile.semantic) {
2001
+ return [{
2002
+ type: 'upsert_source',
2003
+ source: {
2004
+ id: `semantic:${tile.semantic.id}`,
2005
+ kind: 'governed_semantic',
2006
+ sourceRef: tile.semantic.id,
2007
+ qualifiedIdentity: tile.semantic.qualifiedMetricIds?.join(',') || tile.semantic.id,
2008
+ snapshotId: tile.semantic.snapshotId,
2009
+ sourceFingerprint: tile.semantic.definitionFingerprint,
2010
+ receiptId: proposalTile?.preflight.receiptId,
2011
+ trustState: 'review_required',
2012
+ reviewStatus: 'required',
2013
+ },
2014
+ }];
2015
+ }
2016
+ return [];
2017
+ });
2018
+ const reviewAnalyses = materializeAppBuildProposalReviewAnalyses(projectRoot, draft, plan, session.id, session.proposal.tiles, dashboards);
2019
+ dashboards = reviewAnalyses.dashboards;
2020
+ sourceOperations.push(...reviewAnalyses.sourceOperations);
2021
+ const sourceIdForCoverage = (source, sourceId) => {
2022
+ if (!sourceId)
2023
+ return [];
2024
+ if (source === 'certified_block')
2025
+ return [sourceId.startsWith('block:') ? sourceId : `block:${sourceId}`];
2026
+ if (source === 'semantic_query')
2027
+ return [sourceId.startsWith('semantic:') ? sourceId : `semantic:${sourceId}`];
2028
+ return [];
2029
+ };
2030
+ const clarifications = session.proposal.clarifications.map((clarification) => ({
2031
+ id: clarification.id,
2032
+ question: clarification.question,
2033
+ choices: clarification.choices.map((label, index) => ({ id: slugify(label) || `choice-${index + 1}`, label })),
2034
+ required: clarification.required,
2035
+ }));
2036
+ const replaceInitialBlank = !draft.baseApp
2037
+ && draft.pages.length === 1
2038
+ && draft.pages[0].layout.items.length === 0
2039
+ && !dashboards.some((page) => page.id === draft.pages[0].id);
2040
+ const coverage = plan.requirementCoverage.map((item) => {
2041
+ const reviewCoverage = reviewAnalyses.coverageByRequirement.get(item.requirementId);
2042
+ if (reviewCoverage) {
2043
+ return {
2044
+ requirementId: item.requirementId,
2045
+ status: 'covered',
2046
+ sourceIds: reviewCoverage.sourceIds,
2047
+ componentIds: reviewCoverage.componentIds,
2048
+ reasons: ['Covered by explicitly selectable review-required App analysis.'],
2049
+ };
2050
+ }
2051
+ return {
2052
+ requirementId: item.requirementId,
2053
+ status: item.status === 'covered' ? 'covered' : 'gap',
2054
+ sourceIds: sourceIdForCoverage(item.source, item.sourceId),
2055
+ componentIds: item.tileId ? [item.tileId] : [],
2056
+ reasons: item.reasons,
2057
+ };
2058
+ });
2059
+ const operations = [
2060
+ { type: 'set_name', name: plan.name },
2061
+ {
2062
+ type: 'set_frame',
2063
+ frame: {
2064
+ goal: session.proposal.buildFrame.goal,
2065
+ decision: plan.businessGoal,
2066
+ audience: session.proposal.buildFrame.audience,
2067
+ metrics: session.proposal.buildFrame.metrics,
2068
+ dimensions: session.proposal.buildFrame.dimensions,
2069
+ grain: session.proposal.buildFrame.grain,
2070
+ timeRange: session.proposal.buildFrame.timeRange,
2071
+ comparison: session.proposal.buildFrame.comparison,
2072
+ filters: session.proposal.buildFrame.filters,
2073
+ desiredOutput: session.proposal.buildFrame.desiredOutput,
2074
+ clarificationQuestions: clarifications,
2075
+ },
2076
+ },
2077
+ {
2078
+ type: 'set_requirements',
2079
+ requirements: plan.requirements.map((requirement) => ({
2080
+ id: requirement.id,
2081
+ question: requirement.question,
2082
+ role: requirement.role,
2083
+ required: true,
2084
+ measures: requirement.measures,
2085
+ dimensions: requirement.dimensions,
2086
+ filters: requirement.filters,
2087
+ grain: requirement.grain,
2088
+ })),
2089
+ coverage,
2090
+ },
2091
+ ...(replaceInitialBlank ? [{ type: 'remove_page', pageId: draft.pages[0].id }] : []),
2092
+ ...dashboards.map((page) => ({ type: 'upsert_page', page })),
2093
+ ...sourceOperations,
2094
+ ...reviewAnalyses.reviewTasks,
2095
+ ];
2096
+ return {
2097
+ id: session.id,
2098
+ draftId: draft.id,
2099
+ baseRevision: draft.revision,
2100
+ baseProposalHash: draft.proposalHash,
2101
+ operations,
2102
+ defaultSelectedSourceIds: sourceOperations.flatMap((operation) => (operation.type === 'upsert_source' && operation.source.kind !== 'review_dql' ? [operation.source.id] : [])),
2103
+ clarifications,
2104
+ summary: {
2105
+ requirements: plan.requirements.length,
2106
+ covered: plan.requirementCoverage.filter((item) => item.status === 'covered').length,
2107
+ gaps: plan.requirementCoverage.filter((item) => item.status === 'gap').length,
2108
+ certifiedSources: session.proposal.coverage.certifiedTiles,
2109
+ semanticSources: session.proposal.coverage.semanticTiles,
2110
+ },
2111
+ };
2112
+ }
1533
2113
  /**
1534
2114
  * Materialize explicitly selected exploratory candidates as Git-owned, app-scoped
1535
2115
  * review DQL. They are never promoted into the shared block library and the
@@ -1677,10 +2257,20 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1677
2257
  if (selected.size === 0) {
1678
2258
  return { ok: false, error: 'Select at least one tile to create the app.', status: 400 };
1679
2259
  }
2260
+ const plannedFilterIds = new Set([
2261
+ ...(plan.globalFilters ?? []).map((filter) => filter.id),
2262
+ ...plan.pages.flatMap((page) => (page.filters ?? []).map((filter) => filter.id)),
2263
+ ]);
2264
+ const selectedFilterIds = new Set((input.filterIds ?? Array.from(plannedFilterIds)).filter((filterId) => plannedFilterIds.has(filterId)));
1680
2265
  // Filter the plan's certified tiles down to the confirmed selection; structural
1681
2266
  // tiles (narrative, draft placeholders) stay — they document the story + gaps.
1682
2267
  const committedPlan = {
1683
2268
  ...plan,
2269
+ globalFilters: (plan.globalFilters ?? []).filter((filter) => selectedFilterIds.has(filter.id)),
2270
+ // `+ Add page` must not rewrite the App it is adding to (`UI-017`), but the
2271
+ // fields were dropped with no error, so a name typed in that brief silently
2272
+ // vanished. Adding a page keeps the App's own name; use `PATCH /api/apps/:id`
2273
+ // to rename, and see `appliedNameWarnings` below for the reported diagnostic.
1684
2274
  ...(!isExistingPageBuild && cleanString(input.appName) ? { name: cleanString(input.appName).slice(0, 120) } : {}),
1685
2275
  ...(!isExistingPageBuild && cleanString(input.audience) ? { audience: cleanString(input.audience).slice(0, 120) } : {}),
1686
2276
  pages: plan.pages.map((page, pageIndex) => ({
@@ -1688,15 +2278,20 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1688
2278
  ...(isExistingPageBuild && pageIndex === 0 && cleanString(input.pageTitle)
1689
2279
  ? { title: cleanString(input.pageTitle).slice(0, 120) }
1690
2280
  : {}),
2281
+ filters: (page.filters ?? []).filter((filter) => selectedFilterIds.has(filter.id)),
1691
2282
  tiles: page.tiles
1692
2283
  .filter((tile) => !['certified_block', 'semantic_query'].includes(tile.kind) || selected.has(tile.id))
1693
2284
  .map((tile) => {
1694
2285
  const override = tileOverrides[tile.id];
2286
+ const filteredTile = {
2287
+ ...tile,
2288
+ filterBindings: (tile.filterBindings ?? []).filter((binding) => selectedFilterIds.has(binding.filter)),
2289
+ };
1695
2290
  if (!override)
1696
- return tile;
2291
+ return filteredTile;
1697
2292
  const viz = override.viz;
1698
2293
  return {
1699
- ...tile,
2294
+ ...filteredTile,
1700
2295
  ...(override.title ? { title: override.title } : {}),
1701
2296
  ...(viz ? { viz } : {}),
1702
2297
  ...(viz && tile.display ? {
@@ -1721,7 +2316,15 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1721
2316
  const { KGStore, defaultKgPath, ensureMetadataCatalogFresh, generateAppFromPlan, reindexProject, validateAppPlan, } = await import('@duckcodeailabs/dql-agent');
1722
2317
  const index = await reindexProject(projectRoot, { kgPath: defaultKgPath(projectRoot) });
1723
2318
  if (!session.snapshotId || index.kgFingerprint !== session.snapshotId) {
1724
- return { ok: false, error: 'Project sources changed after this proposal was created. Refresh and review the App proposal again.', status: 409 };
2319
+ // This compares a whole-project index, so an unrelated block or model edit
2320
+ // invalidates a pending proposal. Kept deliberately strict — the proposal's
2321
+ // preflight receipts are bound to that snapshot — but the message now says
2322
+ // what to do instead of only reporting a conflict.
2323
+ return {
2324
+ ok: false,
2325
+ error: 'Project sources changed after this proposal was created, so its preflight evidence no longer applies. Re-run the build request to get a fresh proposal.',
2326
+ status: 409,
2327
+ };
1725
2328
  }
1726
2329
  const kg = new KGStore(defaultKgPath(projectRoot));
1727
2330
  try {
@@ -1747,7 +2350,10 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1747
2350
  const stagedAppDir = isExistingPageBuild
1748
2351
  ? join(stagingRoot, 'candidate', 'apps', committedPlan.appId)
1749
2352
  : generatedAppDir;
1750
- const targetAppDir = join(projectRoot, 'apps', committedPlan.appId);
2353
+ // Apps also resolve under `domains/**/apps/<id>`. Hardcoding the canonical
2354
+ // root meant a domain-hosted App always failed the existence check below
2355
+ // and could never have a page added.
2356
+ const targetAppDir = existingApp?.appDir ?? join(projectRoot, 'apps', committedPlan.appId);
1751
2357
  const backupAppDir = join(projectRoot, '.dql', 'local', 'app-build-staging', `${session.id}-backup`);
1752
2358
  rmSync(stagingRoot, { recursive: true, force: true });
1753
2359
  try {
@@ -1841,6 +2447,15 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1841
2447
  rmSync(stagingRoot, { recursive: true, force: true });
1842
2448
  }
1843
2449
  const attachWarnings = [];
2450
+ // Adding a page deliberately cannot rename or re-audience the App it joins.
2451
+ // Say so instead of discarding the input silently — this array existed and
2452
+ // was never written to, so the drop had no surface at all.
2453
+ if (isExistingPageBuild && cleanString(input.appName)) {
2454
+ attachWarnings.push('The App name was not changed: adding a page cannot rename the App. Rename it from the App header.');
2455
+ }
2456
+ if (isExistingPageBuild && cleanString(input.audience)) {
2457
+ attachWarnings.push('The audience was not changed: adding a page keeps the App\'s existing audience.');
2458
+ }
1844
2459
  await ensureMetadataCatalogFresh(projectRoot, { force: true });
1845
2460
  const app = collectAppsList(projectRoot).find((entry) => entry.id === committedPlan.appId) ?? null;
1846
2461
  const next = {
@@ -1858,6 +2473,7 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1858
2473
  ? { pageTitle: committedPlan.pages[0]?.title }
1859
2474
  : { appName: committedPlan.name }),
1860
2475
  audience: committedPlan.audience,
2476
+ filterIds: Array.from(selectedFilterIds),
1861
2477
  tileOverrides,
1862
2478
  },
1863
2479
  warnings: [...session.warnings, ...attachWarnings],
@@ -1920,7 +2536,7 @@ async function routeAppAskQuestion(ctx, appId, input) {
1920
2536
  const tile = dashboard?.layout.items.find((item) => item.i === input.tileId ||
1921
2537
  (input.blockId && item.block && 'blockId' in item.block && item.block.blockId === input.blockId));
1922
2538
  const blockId = cleanString(input.blockId) || (tile?.block && 'blockId' in tile.block ? tile.block.blockId : undefined);
1923
- const appContext = buildAppContextEnvelope(loaded.app, loaded.appDir, {
2539
+ const appContext = buildAppContextEnvelope(ctx.projectRoot, loaded.app, loaded.appDir, {
1924
2540
  dashboardId,
1925
2541
  tileId: cleanString(input.tileId) || undefined,
1926
2542
  blockId,
@@ -2170,17 +2786,80 @@ function questionIsAboutFocusedTile(question) {
2170
2786
  return true;
2171
2787
  }
2172
2788
  /** Map a governed AgentAnswer into the App Copilot's response shape. */
2789
+ /** Overlap needed before a draft is offered as covering the question. */
2790
+ const APP_DRAFT_MATCH_MIN_OVERLAP = 2;
2791
+ const APP_DRAFT_STOPWORDS = new Set([
2792
+ 'the', 'a', 'an', 'and', 'or', 'for', 'with', 'what', 'which', 'who', 'how', 'why', 'is', 'are',
2793
+ 'do', 'does', 'can', 'you', 'me', 'my', 'our', 'this', 'that', 'show', 'give', 'get', 'by', 'of',
2794
+ 'in', 'on', 'to', 'from', 'all', 'complete', 'view',
2795
+ ]);
2796
+ function appDraftTokens(value) {
2797
+ return new Set(value.toLowerCase().split(/[^a-z0-9]+/)
2798
+ .filter((word) => word.length > 2 && !APP_DRAFT_STOPWORDS.has(word)));
2799
+ }
2800
+ /**
2801
+ * The app's own draft that best covers a question the governed loop could not answer.
2802
+ *
2803
+ * Drafts are review-required exploratory analyses saved in the app. They are not
2804
+ * indexed into the manifest, so retrieval cannot find them — but when the loop
2805
+ * has just refused for want of a certified path, the app frequently already
2806
+ * contains a draft that does exactly the thing. Offering it is strictly better
2807
+ * than explaining the gap and stopping, and it never launders trust: the answer
2808
+ * stays review-required and says the source is an unreviewed draft.
2809
+ */
2810
+ export function matchAppDraftForQuestion(drafts, question) {
2811
+ const asked = appDraftTokens(question);
2812
+ if (asked.size === 0)
2813
+ return undefined;
2814
+ let best;
2815
+ for (const draft of drafts ?? []) {
2816
+ const candidate = appDraftTokens([draft.name, draft.question ?? '', draft.description ?? ''].join(' '));
2817
+ let score = 0;
2818
+ for (const token of asked)
2819
+ if (candidate.has(token))
2820
+ score += 1;
2821
+ if (score >= APP_DRAFT_MATCH_MIN_OVERLAP && (!best || score > best.score))
2822
+ best = { draft, score };
2823
+ }
2824
+ return best?.draft;
2825
+ }
2173
2826
  function mapGovernedToAppAsk(governed, input) {
2174
2827
  const isCertified = governed.certification === 'certified' || governed.kind === 'certified';
2175
2828
  const noAnswer = governed.kind === 'no_answer';
2176
- const answer = cleanString(governed.answer) || cleanString(governed.text) || 'No governed answer was available for this question.';
2829
+ const governedText = cleanString(governed.answer) || cleanString(governed.text) || 'No governed answer was available for this question.';
2830
+ // The loop just declined for want of certified evidence. Before returning only
2831
+ // the gap, check whether this app already saved a draft that covers it.
2832
+ const draft = noAnswer ? matchAppDraftForQuestion(input.appContext?.drafts ?? [], input.question) : undefined;
2833
+ // App drafts are often generated FROM a refusal like this one, so their
2834
+ // description can be the same paragraph the loop just returned. Only add it
2835
+ // when it actually says something new.
2836
+ const normalize = (value) => value.toLowerCase().replace(/\s+/g, ' ').trim();
2837
+ const draftAdds = draft?.description && !normalize(governedText).includes(normalize(draft.description).slice(0, 80))
2838
+ ? draft.description
2839
+ : '';
2840
+ const answer = draft
2841
+ ? [
2842
+ governedText,
2843
+ '',
2844
+ `This app already has a saved draft for it: "${draft.name}" (${draft.status}).`,
2845
+ draftAdds,
2846
+ 'It is an unreviewed draft, not a certified answer — open it under Drafts, check the join, grain, and filters, then certify it to make it governed.',
2847
+ ].filter(Boolean).join('\n')
2848
+ : governedText;
2177
2849
  const route = noAnswer ? 'metadata_answer' : isCertified ? 'certified_answer' : 'generated_answer';
2178
- const trustState = noAnswer ? 'draft_ready' : isCertified ? 'certified' : 'review_required';
2850
+ // Offering a draft is actionable but unreviewed, so it is review_required
2851
+ // rather than the bare draft_ready used when there is nothing to offer.
2852
+ const trustState = draft ? 'review_required' : noAnswer ? 'draft_ready' : isCertified ? 'certified' : 'review_required';
2179
2853
  const govCitations = Array.isArray(governed.citations)
2180
2854
  ? governed.citations.map((c) => ({ kind: String(c.kind ?? 'source'), name: String(c.name ?? '') })).filter((c) => c.name)
2181
2855
  : [];
2182
2856
  const seen = new Set();
2183
- const citations = [...input.citations, ...govCitations].filter((c) => {
2857
+ const citations = [
2858
+ ...input.citations,
2859
+ ...govCitations,
2860
+ // Cite the draft so the user can open the exact file being offered.
2861
+ ...(draft ? [{ kind: 'draft', name: draft.name, path: draft.path }] : []),
2862
+ ].filter((c) => {
2184
2863
  const key = `${c.kind}:${c.name}`;
2185
2864
  if (seen.has(key))
2186
2865
  return false;
@@ -2194,9 +2873,11 @@ function mapGovernedToAppAsk(governed, input) {
2194
2873
  trustState,
2195
2874
  reviewStatus: trustState,
2196
2875
  citations,
2197
- followUps: noAnswer
2198
- ? ['Name the metric and grain', 'Pick a certified tile', 'Start driver analysis']
2199
- : ['Investigate drivers', 'Break this down further', 'Create a draft DQL block'],
2876
+ followUps: draft
2877
+ ? [`Open the "${draft.name}" draft`, 'Review its join and grain', 'Certify it to make it governed']
2878
+ : noAnswer
2879
+ ? ['Name the metric and grain', 'Pick a certified tile', 'Start driver analysis']
2880
+ : ['Investigate drivers', 'Break this down further', 'Create a draft DQL block'],
2200
2881
  decision: buildAppAskDecision(route, { question: input.question, blockId: input.blockId, appName: input.appName }),
2201
2882
  };
2202
2883
  }
@@ -2386,6 +3067,75 @@ export function recommendDashboardTile(projectRoot, appId, dashboardId, input) {
2386
3067
  warnings: recommendation.warnings,
2387
3068
  };
2388
3069
  }
3070
+ /**
3071
+ * Rename an App, and optionally a page inside it.
3072
+ *
3073
+ * `dql.app.json` previously had exactly two writers — create and publish — so
3074
+ * an App could never be renamed after it was built. Only the display `name`
3075
+ * changes here: the App **id** is also its folder name and is referenced by
3076
+ * notebooks, product context, and deep links, so renaming must not move it.
3077
+ *
3078
+ * The write is staged and swapped like the publish path, and guarded by the
3079
+ * caller-supplied fingerprint so a concurrent edit cannot be clobbered.
3080
+ */
3081
+ export function renameApp(projectRoot, appId, input) {
3082
+ const loaded = loadAppById(projectRoot, appId);
3083
+ if (!loaded)
3084
+ return { ok: false, error: `App "${appId}" not found`, code: 'APP_NOT_FOUND' };
3085
+ if (input.expectedFingerprint && fingerprintAppDirectory(loaded.appDir) !== input.expectedFingerprint) {
3086
+ return { ok: false, error: 'This App changed since it was loaded. Refresh and rename again.', code: 'APP_CHANGED' };
3087
+ }
3088
+ const name = cleanString(input.name);
3089
+ const pageTitle = cleanString(input.pageTitle);
3090
+ if (!name && !pageTitle)
3091
+ return { ok: false, error: 'Provide a name or a pageTitle.', code: 'INVALID_REQUEST' };
3092
+ const appPath = join(loaded.appDir, 'dql.app.json');
3093
+ const parsedApp = parseAppDocument(JSON.stringify({ ...loaded.app, ...(name ? { name } : {}) }), appPath);
3094
+ if (!parsedApp.document)
3095
+ return { ok: false, error: parsedApp.errors.map((err) => err.message).join('; ') };
3096
+ const renamedDashboards = [];
3097
+ if (pageTitle) {
3098
+ const homepage = loaded.app.homepage;
3099
+ const dashboardId = cleanString(input.dashboardId)
3100
+ || (homepage?.type === 'dashboard' ? homepage.id : undefined);
3101
+ const source = findDashboardsForApp(loaded.appDir)
3102
+ .map((path) => ({ path, document: loadDashboardDocument(path).document }))
3103
+ .find((item) => item.document && (!dashboardId || item.document.id === dashboardId));
3104
+ if (!source?.document)
3105
+ return { ok: false, error: `Dashboard "${dashboardId ?? ''}" not found in app "${appId}"`, code: 'DASHBOARD_NOT_FOUND' };
3106
+ const next = { ...source.document, metadata: { ...source.document.metadata, title: pageTitle } };
3107
+ const parsed = parseDashboardDocument(JSON.stringify(next), source.path);
3108
+ if (!parsed.document)
3109
+ return { ok: false, error: parsed.errors.map((err) => err.message).join('; ') };
3110
+ renamedDashboards.push({ sourcePath: source.path, document: parsed.document });
3111
+ }
3112
+ const stagingBase = join(projectRoot, '.dql', 'local', 'app-rename-staging');
3113
+ const stagedAppDir = join(stagingBase, `${appId}-next`);
3114
+ const backupAppDir = join(stagingBase, `${appId}-backup`);
3115
+ rmSync(stagedAppDir, { recursive: true, force: true });
3116
+ rmSync(backupAppDir, { recursive: true, force: true });
3117
+ mkdirSync(stagingBase, { recursive: true });
3118
+ cpSync(loaded.appDir, stagedAppDir, { recursive: true });
3119
+ writeFileSync(join(stagedAppDir, 'dql.app.json'), JSON.stringify(parsedApp.document, null, 2) + '\n', 'utf-8');
3120
+ for (const dashboard of renamedDashboards) {
3121
+ writeFileSync(join(stagedAppDir, relative(loaded.appDir, dashboard.sourcePath)), JSON.stringify(dashboard.document, null, 2) + '\n', 'utf-8');
3122
+ }
3123
+ try {
3124
+ renameSync(loaded.appDir, backupAppDir);
3125
+ renameSync(stagedAppDir, loaded.appDir);
3126
+ rmSync(backupAppDir, { recursive: true, force: true });
3127
+ }
3128
+ catch (error) {
3129
+ if (existsSync(backupAppDir) && !existsSync(loaded.appDir))
3130
+ renameSync(backupAppDir, loaded.appDir);
3131
+ throw error;
3132
+ }
3133
+ return {
3134
+ ok: true,
3135
+ app: parsedApp.document,
3136
+ paths: [relative(projectRoot, appPath), ...renamedDashboards.map((item) => relative(projectRoot, item.sourcePath))],
3137
+ };
3138
+ }
2389
3139
  export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
2390
3140
  const loaded = loadAppById(projectRoot, appId);
2391
3141
  if (!loaded)
@@ -2393,7 +3143,7 @@ export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
2393
3143
  const dashboardSources = findDashboardsForApp(loaded.appDir)
2394
3144
  .map((path) => ({ path, document: loadDashboardDocument(path).document }))
2395
3145
  .filter((item) => Boolean(item.document));
2396
- const readiness = appPublicationReadiness(dashboardSources.map((item) => item.document));
3146
+ const readiness = appPublicationReadiness(projectRoot, dashboardSources.map((item) => item.document));
2397
3147
  if (!readiness.ready) {
2398
3148
  return {
2399
3149
  ok: false,
@@ -2456,101 +3206,853 @@ export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
2456
3206
  writeFileSync(stagedPath, JSON.stringify(dashboard.document, null, 2) + '\n', 'utf-8');
2457
3207
  }
2458
3208
  try {
2459
- renameSync(loaded.appDir, backupAppDir);
2460
- renameSync(stagedAppDir, loaded.appDir);
2461
- rmSync(backupAppDir, { recursive: true, force: true });
3209
+ renameSync(loaded.appDir, backupAppDir);
3210
+ renameSync(stagedAppDir, loaded.appDir);
3211
+ rmSync(backupAppDir, { recursive: true, force: true });
3212
+ }
3213
+ catch (error) {
3214
+ if (existsSync(backupAppDir) && !existsSync(loaded.appDir))
3215
+ renameSync(backupAppDir, loaded.appDir);
3216
+ throw error;
3217
+ }
3218
+ const paths = [relative(projectRoot, appPath), ...dashboards.map((dashboard) => relative(projectRoot, dashboard.sourcePath))];
3219
+ return { ok: true, app: parsedApp.document, paths, removedLocalTiles, readiness };
3220
+ }
3221
+ export function approveAppSemanticTiles(projectRoot, appId, dashboardId, input) {
3222
+ const loaded = loadDashboardForApp(projectRoot, appId, dashboardId);
3223
+ if (!loaded)
3224
+ return { ok: false, error: `Dashboard "${dashboardId}" not found in App "${appId}".` };
3225
+ const currentFingerprint = `sha256:${createHash('sha256').update(JSON.stringify(loaded.dashboard)).digest('hex')}`;
3226
+ if (!input.expectedDashboardFingerprint || input.expectedDashboardFingerprint !== currentFingerprint) {
3227
+ return { ok: false, error: 'The dashboard changed after the reviewed run. Run it again before approval.' };
3228
+ }
3229
+ const selected = new Set(input.tileIds);
3230
+ const semanticIds = new Set(loaded.dashboard.layout.items.filter((item) => item.semantic).map((item) => item.i));
3231
+ const invalid = input.tileIds.filter((tileId) => !semanticIds.has(tileId));
3232
+ if (invalid.length > 0)
3233
+ return { ok: false, error: `Only semantic tiles can be approved here: ${invalid.join(', ')}` };
3234
+ const reviewedAt = new Date().toISOString();
3235
+ const dashboard = {
3236
+ ...loaded.dashboard,
3237
+ layout: {
3238
+ ...loaded.dashboard.layout,
3239
+ items: loaded.dashboard.layout.items.map((item) => {
3240
+ if (!selected.has(item.i) || !item.semantic)
3241
+ return item;
3242
+ return {
3243
+ ...item,
3244
+ semantic: { ...item.semantic, snapshotId: input.snapshotId },
3245
+ review: {
3246
+ status: 'approved',
3247
+ sourceFingerprint: item.semantic.definitionFingerprint,
3248
+ preflightReceiptId: input.runId,
3249
+ reviewedAt,
3250
+ reviewedBy: input.reviewer,
3251
+ },
3252
+ trustState: 'review_required',
3253
+ reviewStatus: 'review_required',
3254
+ };
3255
+ }),
3256
+ },
3257
+ };
3258
+ const parsed = parseDashboardDocument(JSON.stringify(dashboard), loaded.path);
3259
+ if (!parsed.document)
3260
+ return { ok: false, error: parsed.errors.map((error) => error.message).join('; ') };
3261
+ writeFileSync(loaded.path, JSON.stringify(parsed.document, null, 2) + '\n', 'utf-8');
3262
+ return { ok: true, dashboard: parsed.document, path: relative(projectRoot, loaded.path) };
3263
+ }
3264
+ function appPublicationReadiness(projectRoot, dashboards, options = {}) {
3265
+ const blockers = [];
3266
+ let governedTiles = 0;
3267
+ const blocks = collectBlockCandidates(projectRoot);
3268
+ for (const dashboard of dashboards) {
3269
+ for (const item of dashboard.layout.items) {
3270
+ if (item.block) {
3271
+ governedTiles += 1;
3272
+ const blockRef = item.block;
3273
+ const block = blocks.find((candidate) => ('blockId' in blockRef
3274
+ ? candidate.id === blockRef.blockId || candidate.name === blockRef.blockId
3275
+ : candidate.path === blockRef.ref));
3276
+ if (!block || block.status !== 'certified') {
3277
+ blockers.push({
3278
+ dashboardId: dashboard.id,
3279
+ tileId: item.i,
3280
+ code: 'block_not_certified',
3281
+ message: `${dashboard.id}/${item.i} no longer resolves to a certified block.`,
3282
+ });
3283
+ }
3284
+ else if (item.review?.sourceFingerprint?.startsWith('sha256:') && item.review.sourceFingerprint !== block.fingerprint) {
3285
+ blockers.push({
3286
+ dashboardId: dashboard.id,
3287
+ tileId: item.i,
3288
+ code: 'block_source_changed',
3289
+ message: `${dashboard.id}/${item.i} changed after it was selected; review the current certified block before publication.`,
3290
+ });
3291
+ }
3292
+ }
3293
+ if (item.draftAnalysis) {
3294
+ blockers.push({
3295
+ dashboardId: dashboard.id,
3296
+ tileId: item.i,
3297
+ code: 'exploratory_source',
3298
+ message: `${dashboard.id}/${item.i} references app-scoped exploratory DQL; promote it to governed semantic logic or a certified block first.`,
3299
+ });
3300
+ }
3301
+ if (item.semantic) {
3302
+ governedTiles += 1;
3303
+ if (item.review?.status !== 'approved') {
3304
+ blockers.push({
3305
+ dashboardId: dashboard.id,
3306
+ tileId: item.i,
3307
+ code: 'semantic_review',
3308
+ message: `${dashboard.id}/${item.i} semantic result has not been explicitly approved.`,
3309
+ });
3310
+ }
3311
+ if (!item.semantic.snapshotId || !item.review?.preflightReceiptId) {
3312
+ blockers.push({
3313
+ dashboardId: dashboard.id,
3314
+ tileId: item.i,
3315
+ code: 'semantic_preflight',
3316
+ message: `${dashboard.id}/${item.i} needs a snapshot-bound semantic preflight receipt.`,
3317
+ });
3318
+ }
3319
+ }
3320
+ if (dashboard.version === 2 && (item.block || item.semantic || item.draftAnalysis)) {
3321
+ for (const filter of dashboard.filters ?? []) {
3322
+ if (filter.scope?.tileIds && !filter.scope.tileIds.includes(item.i))
3323
+ continue;
3324
+ const binding = item.filterBindings?.find((candidate) => candidate.filter === filter.id);
3325
+ // An explicit unsupported binding is a governed exclusion, not a
3326
+ // missing binding. Studio shows the reason on the affected tile and
3327
+ // the filter deliberately leaves that tile unchanged.
3328
+ if (binding?.capability === 'unsupported' && binding.unsupportedReason?.trim())
3329
+ continue;
3330
+ const settled = options.settledPageIds?.has(dashboard.id) ?? false;
3331
+ if (!binding || (!binding.binding && binding.mode !== 'parameter') || (binding.capability === 'preflight_required' && !settled)) {
3332
+ blockers.push({
3333
+ dashboardId: dashboard.id,
3334
+ tileId: item.i,
3335
+ code: 'filter_not_preflighted',
3336
+ message: `${dashboard.id}/${item.i} does not have a preflighted binding for filter ${filter.label ?? filter.id}.`,
3337
+ });
3338
+ }
3339
+ }
3340
+ }
3341
+ }
3342
+ }
3343
+ return { ready: blockers.length === 0, governedTiles, blockers };
3344
+ }
3345
+ function appAiBuildSessionDir(projectRoot) {
3346
+ return join(projectRoot, '.dql', 'local', 'app-ai-builds');
3347
+ }
3348
+ function appBuildDraftDir(projectRoot) {
3349
+ // Read-only migration location for AppBuildDraft v1 JSON files. New drafts
3350
+ // are persisted transactionally in .dql/local/apps.sqlite.
3351
+ return join(projectRoot, '.dql', 'local', 'app-builds');
3352
+ }
3353
+ export function createStoredAppBuildDraft(projectRoot, input) {
3354
+ const baseAppId = cleanString(input.baseAppId);
3355
+ const base = baseAppId ? loadAppById(projectRoot, baseAppId) : null;
3356
+ if (baseAppId && !base)
3357
+ throw new Error(`App "${baseAppId}" not found.`);
3358
+ const name = cleanString(input.name) || base?.app.name || cleanString(input.goal) || 'Untitled App';
3359
+ const appId = base?.app.id || cleanString(input.appId) || suggestAppId(name);
3360
+ const id = `build_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
3361
+ const pageId = 'overview';
3362
+ const emptyItems = [];
3363
+ const blankPage = {
3364
+ version: 2,
3365
+ id: pageId,
3366
+ metadata: {
3367
+ title: 'Overview',
3368
+ description: cleanString(input.goal) || `Local draft canvas for ${name}`,
3369
+ domain: cleanString(input.domain) || base?.app.domain || 'general',
3370
+ audience: cleanString(input.audience) || base?.app.audience || 'stakeholders',
3371
+ visibility: 'private',
3372
+ lifecycle: 'draft',
3373
+ },
3374
+ layout: {
3375
+ kind: 'grid', cols: 12, rowHeight: 80, items: emptyItems,
3376
+ responsive: {
3377
+ wide: { kind: 'grid', cols: 12, rowHeight: 80, items: emptyItems },
3378
+ medium: { kind: 'grid', cols: 6, rowHeight: 80, items: emptyItems },
3379
+ narrow: { kind: 'grid', cols: 1, rowHeight: 80, items: emptyItems },
3380
+ },
3381
+ },
3382
+ };
3383
+ const basePages = base
3384
+ ? findDashboardsForApp(base.appDir)
3385
+ .map((path) => loadDashboardDocument(path).document)
3386
+ .filter((page) => Boolean(page))
3387
+ : [];
3388
+ const template = input.template ?? 'blank';
3389
+ const pages = basePages.length ? basePages : [applyAppStudioTemplate(blankPage, template, name)];
3390
+ const draft = createAppBuildDraft({
3391
+ id,
3392
+ appId,
3393
+ name,
3394
+ ...(base ? { baseApp: { appId: base.app.id, fingerprint: fingerprintAppDirectory(base.appDir) } } : {}),
3395
+ authoringMode: input.authoringMode === 'manual' ? 'manual' : 'ai',
3396
+ template,
3397
+ sourcePolicy: input.sourcePolicy,
3398
+ frame: {
3399
+ goal: cleanString(input.goal) || `Create ${name}`,
3400
+ audience: cleanString(input.audience) || base?.app.audience || 'stakeholders',
3401
+ metrics: [], dimensions: [], filters: [], desiredOutput: 'One responsive analytical page',
3402
+ clarificationQuestions: input.authoringMode === 'manual' || base || input.entrypoint === 'ask' ? [] : [{
3403
+ id: 'metric',
3404
+ question: 'Which governed business metric should lead this App?',
3405
+ choices: [
3406
+ { id: 'certified', label: 'Choose a certified block' },
3407
+ { id: 'semantic', label: 'Choose a semantic metric' },
3408
+ { id: 'gap', label: 'Keep a visible gap' },
3409
+ ],
3410
+ required: true,
3411
+ }],
3412
+ },
3413
+ pages,
3414
+ });
3415
+ writeStoredAppBuildDraft(projectRoot, draft);
3416
+ return draft;
3417
+ }
3418
+ /**
3419
+ * Adds an Ask result to the same local AppBuildDraft used by manual and AI
3420
+ * authoring. Certified answers retain their block identity. Exploratory DQL is
3421
+ * materialized as an app-scoped local artifact and remains review-required;
3422
+ * SQL is never embedded in the dashboard JSON or written to Project source.
3423
+ */
3424
+ export function addAskResultToAppBuildDraft(projectRoot, draftId, input) {
3425
+ const draft = loadStoredAppBuildDraft(projectRoot, draftId);
3426
+ if (!draft)
3427
+ throw new Error('App Build Draft not found.');
3428
+ if (input.expectedRevision !== draft.revision || input.expectedProposalHash !== draft.proposalHash) {
3429
+ throw new Error('APP_BUILD_REVISION_CONFLICT: refresh the local draft before adding this Ask result.');
3430
+ }
3431
+ const page = draft.pages.find((candidate) => candidate.id === cleanString(input.pageId)) ?? draft.pages[0];
3432
+ if (!page)
3433
+ throw new Error('The local App draft needs a page before adding an Ask result.');
3434
+ const question = cleanString(input.question).slice(0, 500);
3435
+ const answer = cleanString(input.answer).slice(0, 40_000);
3436
+ const title = (cleanString(input.title) || titleForGapQuestion(question || 'Ask result')).slice(0, 120);
3437
+ const requestedBlockId = cleanString(input.certifiedBlockId);
3438
+ const dqlSource = cleanString(input.dqlSource);
3439
+ const sql = cleanString(input.sql);
3440
+ if (dqlSource.length > 400_000 || sql.length > 200_000)
3441
+ throw new Error('Ask result source exceeds the local App artifact limit.');
3442
+ const sourceIdentity = requestedBlockId || dqlSource || sql || `${question}\n${answer}`;
3443
+ if (!sourceIdentity)
3444
+ throw new Error('The Ask result did not include reusable evidence.');
3445
+ const identity = createHash('sha256').update(sourceIdentity).digest('hex').slice(0, 16);
3446
+ const tileId = `ask-${identity}`;
3447
+ if (draft.pages.some((candidate) => candidate.layout.items.some((tile) => tile.i === tileId))) {
3448
+ return { ok: true, draft, pageId: page.id, tileId, deduped: true };
3449
+ }
3450
+ const nextY = page.layout.items.reduce((max, tile) => Math.max(max, tile.y + tile.h), 0);
3451
+ const viz = normalizeGeneratedViz(input.visualization);
3452
+ const operations = [];
3453
+ let artifactPath;
3454
+ let artifactWasCreated = false;
3455
+ let tile;
3456
+ if (requestedBlockId) {
3457
+ const block = collectBlockCandidates(projectRoot).find((candidate) => candidate.status === 'certified'
3458
+ && (candidate.id === requestedBlockId || candidate.name === requestedBlockId || candidate.path === requestedBlockId));
3459
+ if (!block)
3460
+ throw new Error(`Certified block "${requestedBlockId}" is no longer available.`);
3461
+ const sourceId = `block:${block.id}`;
3462
+ operations.push({
3463
+ type: 'upsert_source',
3464
+ source: {
3465
+ id: sourceId,
3466
+ kind: 'certified_block',
3467
+ sourceRef: block.id,
3468
+ sourceFingerprint: block.fingerprint,
3469
+ trustState: 'certified',
3470
+ reviewStatus: 'not_required',
3471
+ },
3472
+ });
3473
+ tile = {
3474
+ i: tileId, x: 0, y: nextY, w: 6, h: 4,
3475
+ block: { blockId: block.id },
3476
+ viz: { type: viz === 'table' ? normalizeVizType(block.chartType) : viz },
3477
+ title,
3478
+ sourceClass: 'certified_block',
3479
+ filterBindings: block.filterIds.map((filter) => ({ filter, binding: filter, mode: 'predicate', capability: 'preflight_required' })),
3480
+ sourceEvidence: [{ source: block.id, reason: question ? `Ask answer for: ${question}` : 'Added from a certified Ask answer.', kind: 'certified_block', trustState: 'certified' }],
3481
+ review: { status: 'not_required', sourceFingerprint: block.fingerprint },
3482
+ trustState: 'certified',
3483
+ reviewStatus: 'certified',
3484
+ };
3485
+ }
3486
+ else if (dqlSource || sql) {
3487
+ const sourceId = `review-dql:ask-${identity}`;
3488
+ const sourceRef = `analysis/ask-${identity}.dql`;
3489
+ const source = dqlSource || [
3490
+ `block "ask_${identity}" {`,
3491
+ ` domain = "${escapeDqlString(page.metadata.domain ?? 'general')}"`,
3492
+ ' type = "custom"',
3493
+ ' status = "review"',
3494
+ ' owner = "local-ask"',
3495
+ ` description = "${escapeDqlString(answer || question || title)}"`,
3496
+ question ? ` llmContext = "${escapeDqlString(`Ask result: ${question}`)}"` : '',
3497
+ ' caveats = ["Ask-generated local analysis. Validate joins, grain, filters, and interpretation before promotion."]',
3498
+ ' tags = ["app-scoped", "ask-generated", "needs-review"]',
3499
+ '',
3500
+ ' query = """',
3501
+ sql,
3502
+ ' """',
3503
+ '',
3504
+ ' visualization {',
3505
+ ` chart = "${escapeDqlString(normalizeGeneratedViz(input.visualization))}"`,
3506
+ ' }',
3507
+ '}',
3508
+ '',
3509
+ ].filter(Boolean).join('\n');
3510
+ const fingerprint = `sha256:${createHash('sha256').update(source).digest('hex')}`;
3511
+ artifactPath = join(projectRoot, '.dql', 'local', 'app-builds', draft.id, sourceRef);
3512
+ operations.push(...(draft.sourcePolicy === 'include_review_required'
3513
+ ? []
3514
+ : [{ type: 'set_source_policy', sourcePolicy: 'include_review_required' }]), {
3515
+ type: 'upsert_source',
3516
+ source: {
3517
+ id: sourceId,
3518
+ kind: 'review_dql',
3519
+ sourceRef,
3520
+ qualifiedIdentity: title,
3521
+ sourceFingerprint: fingerprint,
3522
+ trustState: 'review_required',
3523
+ reviewStatus: 'required',
3524
+ },
3525
+ }, {
3526
+ type: 'set_review_task',
3527
+ task: {
3528
+ id: `review-ask-${identity}`,
3529
+ message: `Validate the joins, grain, filters, and business interpretation for ${title}.`,
3530
+ status: 'open',
3531
+ sourceId,
3532
+ pageId: page.id,
3533
+ tileId,
3534
+ },
3535
+ });
3536
+ tile = {
3537
+ i: tileId, x: 0, y: nextY, w: 6, h: 4,
3538
+ draftAnalysis: { ref: sourceRef, artifactFingerprint: fingerprint },
3539
+ viz: { type: viz },
3540
+ title,
3541
+ sourceClass: 'exploratory_analysis',
3542
+ sourceEvidence: [{ source: sourceRef, reason: question ? `Ask generated this private analysis for: ${question}` : 'Ask generated this private analysis.', kind: 'review_dql', trustState: 'review_required' }],
3543
+ review: { status: 'required', sourceFingerprint: fingerprint },
3544
+ trustState: 'review_required',
3545
+ reviewStatus: 'review_required',
3546
+ };
3547
+ if (!existsSync(artifactPath)) {
3548
+ mkdirSync(dirname(artifactPath), { recursive: true });
3549
+ writeFileSync(artifactPath, source, 'utf-8');
3550
+ artifactWasCreated = true;
3551
+ }
3552
+ }
3553
+ else {
3554
+ const sourceId = `text:ask-${identity}`;
3555
+ operations.push(...(draft.sourcePolicy === 'include_review_required'
3556
+ ? []
3557
+ : [{ type: 'set_source_policy', sourcePolicy: 'include_review_required' }]), { type: 'upsert_source', source: { id: sourceId, kind: 'text', sourceRef: `ask:${identity}`, trustState: 'review_required', reviewStatus: 'required' } }, { type: 'set_review_task', task: { id: `review-ask-${identity}`, message: `Review the Ask narrative ${title} before publication.`, status: 'open', sourceId, pageId: page.id, tileId } });
3558
+ tile = {
3559
+ i: tileId, x: 0, y: nextY, w: 12, h: 2,
3560
+ text: { markdown: answer || question },
3561
+ viz: { type: 'text' },
3562
+ title,
3563
+ sourceClass: 'narrative',
3564
+ sourceEvidence: [{ source: `ask:${identity}`, reason: 'Saved from Ask as an editable review-required narrative.', kind: 'text', trustState: 'review_required' }],
3565
+ review: { status: 'required' },
3566
+ trustState: 'review_required',
3567
+ reviewStatus: 'review_required',
3568
+ };
3569
+ }
3570
+ operations.push({ type: 'add_tile', pageId: page.id, tile });
3571
+ try {
3572
+ const next = applyAppBuildDraftOperations(draft, draft.revision, operations);
3573
+ writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: draft.revision, operations });
3574
+ return { ok: true, draft: next, pageId: page.id, tileId, deduped: false };
3575
+ }
3576
+ catch (error) {
3577
+ if (artifactWasCreated && artifactPath)
3578
+ rmSync(artifactPath, { force: true });
3579
+ throw error;
3580
+ }
3581
+ }
3582
+ function applyAppStudioTemplate(page, template, appName) {
3583
+ if (template === 'blank')
3584
+ return page;
3585
+ const intro = template === 'executive_brief'
3586
+ ? `# ${appName}\n\nState the decision this brief supports, then add governed evidence below.`
3587
+ : template === 'operational_dashboard'
3588
+ ? `# ${appName}\n\nMonitor current performance, changes, and the details that require action.`
3589
+ : `# ${appName}\n\nDocument the question, findings, caveats, and evidence for this investigation.`;
3590
+ const items = [{
3591
+ i: 'template-introduction', x: 0, y: 0, w: 12, h: 2,
3592
+ title: template === 'investigation' ? 'Investigation question' : 'Executive context',
3593
+ sectionId: 'exec_summary',
3594
+ text: { markdown: intro },
3595
+ viz: { type: 'text' },
3596
+ sourceClass: 'narrative',
3597
+ trustState: 'draft_ready',
3598
+ reviewStatus: 'draft_ready',
3599
+ }];
3600
+ const sections = template === 'investigation'
3601
+ ? [
3602
+ { id: 'exec_summary', title: 'Question', kind: 'exec_summary', order: 0 },
3603
+ { id: 'insight', title: 'Findings and comparisons', kind: 'insight', order: 1 },
3604
+ { id: 'appendix', title: 'Caveats and evidence', kind: 'appendix', order: 2 },
3605
+ ]
3606
+ : [
3607
+ { id: 'exec_summary', title: 'Executive summary', kind: 'exec_summary', order: 0 },
3608
+ { id: 'kpi_band', title: 'Key metrics', kind: 'kpi_band', order: 1 },
3609
+ { id: 'insight', title: template === 'executive_brief' ? 'Decision evidence' : 'Trends and drivers', kind: 'insight', order: 2 },
3610
+ { id: 'appendix', title: 'Detail and evidence', kind: 'appendix', order: 3 },
3611
+ ];
3612
+ return {
3613
+ ...page,
3614
+ sections,
3615
+ layout: {
3616
+ ...page.layout,
3617
+ items,
3618
+ responsive: responsiveLayoutsForDashboardItems(items),
3619
+ },
3620
+ };
3621
+ }
3622
+ function loadStoredAppBuildDraft(projectRoot, id) {
3623
+ const clean = cleanString(id);
3624
+ if (!clean || !/^[a-z0-9_:-]+$/i.test(clean))
3625
+ return null;
3626
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
3627
+ try {
3628
+ const stored = storage.getAppBuildDraft(clean);
3629
+ if (stored)
3630
+ return stored;
3631
+ const legacyPath = join(appBuildDraftDir(projectRoot), `${clean}.json`);
3632
+ if (!existsSync(legacyPath))
3633
+ return null;
3634
+ const migrated = migrateLegacyAppBuildDraft(JSON.parse(readFileSync(legacyPath, 'utf-8')));
3635
+ if (!migrated)
3636
+ return null;
3637
+ storage.saveAppBuildDraft(migrated);
3638
+ return migrated;
3639
+ }
3640
+ catch {
3641
+ return null;
3642
+ }
3643
+ finally {
3644
+ storage.close();
3645
+ }
3646
+ }
3647
+ export function writeStoredAppBuildDraft(projectRoot, draft, input = {}) {
3648
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
3649
+ try {
3650
+ storage.saveAppBuildDraft(draft, input);
3651
+ }
3652
+ finally {
3653
+ storage.close();
3654
+ }
3655
+ }
3656
+ function migrateLegacyAppBuildDraft(value) {
3657
+ if (!value || typeof value !== 'object' || Array.isArray(value))
3658
+ return null;
3659
+ const legacy = value;
3660
+ if (legacy.version === 2)
3661
+ return legacy;
3662
+ if (legacy.version !== 1 || typeof legacy.id !== 'string' || typeof legacy.appId !== 'string')
3663
+ return null;
3664
+ const frame = (legacy.frame && typeof legacy.frame === 'object' ? legacy.frame : {});
3665
+ const clarificationQuestions = Array.isArray(frame.clarificationQuestions)
3666
+ ? frame.clarificationQuestions.flatMap((raw) => {
3667
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
3668
+ return [];
3669
+ const question = raw;
3670
+ if (typeof question.id !== 'string' || typeof question.question !== 'string')
3671
+ return [];
3672
+ const choices = Array.isArray(question.choices)
3673
+ ? question.choices.map((choice, index) => typeof choice === 'string'
3674
+ ? { id: slugify(choice) || `choice-${index + 1}`, label: choice }
3675
+ : choice).filter((choice) => Boolean(choice && typeof choice === 'object' && 'id' in choice && 'label' in choice))
3676
+ : [];
3677
+ return [{ id: question.id, question: question.question, choices, required: question.required !== false }];
3678
+ })
3679
+ : [];
3680
+ const migrated = createAppBuildDraft({
3681
+ id: legacy.id,
3682
+ appId: legacy.appId,
3683
+ name: typeof legacy.name === 'string' ? legacy.name : (typeof frame.goal === 'string' ? frame.goal : legacy.appId),
3684
+ authoringMode: legacy.authoringMode === 'manual' ? 'manual' : 'ai',
3685
+ sourcePolicy: legacy.sourcePolicy === 'include_review_required' ? 'include_review_required' : 'governed_only',
3686
+ frame: {
3687
+ goal: typeof frame.goal === 'string' ? frame.goal : `Create ${legacy.appId}`,
3688
+ decision: typeof frame.decision === 'string' ? frame.decision : undefined,
3689
+ audience: typeof frame.audience === 'string' ? frame.audience : undefined,
3690
+ metrics: Array.isArray(frame.metrics) ? frame.metrics.filter((item) => typeof item === 'string') : [],
3691
+ dimensions: Array.isArray(frame.dimensions) ? frame.dimensions.filter((item) => typeof item === 'string') : [],
3692
+ filters: Array.isArray(frame.filters) ? frame.filters.filter((item) => typeof item === 'string') : [],
3693
+ desiredOutput: typeof frame.desiredOutput === 'string' ? frame.desiredOutput : undefined,
3694
+ clarificationQuestions,
3695
+ },
3696
+ sources: Array.isArray(legacy.sources) ? legacy.sources : [],
3697
+ pages: Array.isArray(legacy.pages) ? legacy.pages : [],
3698
+ reviewTasks: Array.isArray(legacy.reviewTasks) ? legacy.reviewTasks : [],
3699
+ now: typeof legacy.createdAt === 'string' ? legacy.createdAt : undefined,
3700
+ });
3701
+ return {
3702
+ ...migrated,
3703
+ revision: typeof legacy.revision === 'number' ? legacy.revision : migrated.revision,
3704
+ updatedAt: typeof legacy.updatedAt === 'string' ? legacy.updatedAt : migrated.updatedAt,
3705
+ };
3706
+ }
3707
+ export function deleteStoredAppBuildDraft(projectRoot, draft) {
3708
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
3709
+ let deleted;
3710
+ try {
3711
+ deleted = storage.deleteAppBuildDraft(draft.id);
3712
+ }
3713
+ finally {
3714
+ storage.close();
3715
+ }
3716
+ if (!deleted)
3717
+ throw new Error(`App Build Draft not found: ${draft.id}`);
3718
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3719
+ const recoveryId = `draft-${stamp}-${draft.id}`;
3720
+ const recoveryDir = join(projectRoot, '.dql', 'local', 'trash', 'app-drafts', recoveryId);
3721
+ const localArtifactsDir = join(projectRoot, '.dql', 'local', 'app-builds', draft.id);
3722
+ const recoveryArtifactsDir = join(recoveryDir, 'artifacts');
3723
+ let artifactsMoved = false;
3724
+ try {
3725
+ mkdirSync(recoveryDir, { recursive: true });
3726
+ if (existsSync(localArtifactsDir)) {
3727
+ renameSync(localArtifactsDir, recoveryArtifactsDir);
3728
+ artifactsMoved = true;
3729
+ }
3730
+ writeFileSync(join(recoveryDir, 'recovery.json'), JSON.stringify({
3731
+ version: 1,
3732
+ kind: 'app_build_draft',
3733
+ recoveryId,
3734
+ deletedAt: new Date().toISOString(),
3735
+ draft: deleted.draft,
3736
+ operations: deleted.operations,
3737
+ }, null, 2) + '\n', 'utf-8');
3738
+ }
3739
+ catch (error) {
3740
+ if (artifactsMoved && existsSync(recoveryArtifactsDir)) {
3741
+ mkdirSync(dirname(localArtifactsDir), { recursive: true });
3742
+ renameSync(recoveryArtifactsDir, localArtifactsDir);
3743
+ }
3744
+ const restore = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
3745
+ try {
3746
+ restore.restoreAppBuildDraft(deleted.draft, deleted.operations);
3747
+ }
3748
+ finally {
3749
+ restore.close();
3750
+ }
3751
+ rmSync(recoveryDir, { recursive: true, force: true });
3752
+ throw error;
3753
+ }
3754
+ return { ok: true, id: draft.id, recoveryId, recoverableUntilRestart: false };
3755
+ }
3756
+ function restoreStoredAppBuildDraft(projectRoot, recoveryId) {
3757
+ const recoveryDir = join(projectRoot, '.dql', 'local', 'trash', 'app-drafts', recoveryId);
3758
+ const recoveryPath = join(recoveryDir, 'recovery.json');
3759
+ if (!existsSync(recoveryPath))
3760
+ return { ok: false, status: 404, error: 'App recovery bundle not found.' };
3761
+ let payload;
3762
+ try {
3763
+ payload = JSON.parse(readFileSync(recoveryPath, 'utf-8'));
3764
+ }
3765
+ catch {
3766
+ return { ok: false, status: 400, error: 'App draft recovery bundle is invalid.' };
3767
+ }
3768
+ if (payload.kind !== 'app_build_draft' || !payload.draft)
3769
+ return { ok: false, status: 400, error: 'App draft recovery bundle is invalid.' };
3770
+ const recoveredArtifactsDir = join(recoveryDir, 'artifacts');
3771
+ const localArtifactsDir = join(projectRoot, '.dql', 'local', 'app-builds', payload.draft.id);
3772
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
3773
+ let artifactsRestored = false;
3774
+ try {
3775
+ if (storage.getAppBuildDraft(payload.draft.id))
3776
+ return { ok: false, status: 409, error: `App Build Draft already exists: ${payload.draft.id}` };
3777
+ if (existsSync(localArtifactsDir))
3778
+ return { ok: false, status: 409, error: `App Build Draft artifacts already exist: ${payload.draft.id}` };
3779
+ if (existsSync(recoveredArtifactsDir)) {
3780
+ mkdirSync(dirname(localArtifactsDir), { recursive: true });
3781
+ renameSync(recoveredArtifactsDir, localArtifactsDir);
3782
+ artifactsRestored = true;
3783
+ }
3784
+ storage.restoreAppBuildDraft(payload.draft, payload.operations ?? []);
2462
3785
  }
2463
3786
  catch (error) {
2464
- if (existsSync(backupAppDir) && !existsSync(loaded.appDir))
2465
- renameSync(backupAppDir, loaded.appDir);
3787
+ if (artifactsRestored && existsSync(localArtifactsDir))
3788
+ renameSync(localArtifactsDir, recoveredArtifactsDir);
2466
3789
  throw error;
2467
3790
  }
2468
- const paths = [relative(projectRoot, appPath), ...dashboards.map((dashboard) => relative(projectRoot, dashboard.sourcePath))];
2469
- return { ok: true, app: parsedApp.document, paths, removedLocalTiles, readiness };
3791
+ finally {
3792
+ storage.close();
3793
+ }
3794
+ rmSync(recoveryDir, { recursive: true, force: true });
3795
+ return { ok: true, id: payload.draft.id, restoredPath: '.dql/local/apps.sqlite', draft: payload.draft };
2470
3796
  }
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.' };
3797
+ export function preflightStoredAppBuildDraft(projectRoot, draft) {
3798
+ const errors = [];
3799
+ const blocks = collectBlockCandidates(projectRoot);
3800
+ if (draft.state === 'clarification_required')
3801
+ errors.push('Resolve required Build Frame clarifications before publication.');
3802
+ if (draft.pages.length === 0)
3803
+ errors.push('App Build Draft needs at least one page.');
3804
+ for (const requirement of draft.requirements.filter((item) => appBuildRequirementBlocksPublication(draft, item))) {
3805
+ const coverage = draft.coverage.find((item) => item.requirementId === requirement.id);
3806
+ if (!coverage || coverage.status !== 'covered')
3807
+ errors.push(`Required App question is unresolved: ${requirement.question}`);
3808
+ }
3809
+ for (const task of draft.reviewTasks.filter((item) => appBuildReviewTaskBlocksPublication(draft, item))) {
3810
+ errors.push(`Review task is still open: ${task.message}`);
3811
+ }
3812
+ if (draft.baseApp) {
3813
+ const base = loadAppById(projectRoot, draft.baseApp.appId);
3814
+ if (!base || fingerprintAppDirectory(base.appDir) !== draft.baseApp.fingerprint) {
3815
+ errors.push('The published App changed after this local draft was created. Refresh the draft before publishing.');
3816
+ }
3817
+ }
3818
+ for (const page of draft.pages) {
3819
+ const parsed = parseDashboardDocument(JSON.stringify(page), `<app-build:${draft.id}/${page.id}>`);
3820
+ if (!parsed.document)
3821
+ errors.push(...parsed.errors.map((error) => error.message));
3822
+ }
3823
+ const analysisPages = draft.pages.filter((page) => page.layout.items.some((tile) => !tile.text && !tile.aiPin));
3824
+ const receipts = draft.previewReceipts ?? (draft.previewReceipt ? [draft.previewReceipt] : []);
3825
+ for (const page of analysisPages) {
3826
+ const receipt = receipts.find((candidate) => candidate.pageId === page.id);
3827
+ if (!receipt || receipt.revision !== draft.revision)
3828
+ errors.push(`Run a current settled preview for page ${page.metadata.title || page.id} before Project publication.`);
3829
+ }
3830
+ for (const source of draft.sources) {
3831
+ if (source.kind === 'certified_block') {
3832
+ const block = blocks.find((candidate) => candidate.id === source.sourceRef || candidate.name === source.sourceRef || candidate.path === source.sourceRef);
3833
+ if (!block || block.status !== 'certified')
3834
+ errors.push(`Source ${source.id} does not resolve to a current certified block.`);
3835
+ if (block && source.sourceFingerprint && source.sourceFingerprint !== block.fingerprint)
3836
+ errors.push(`Source ${source.id} changed after selection.`);
3837
+ }
3838
+ if (source.kind === 'governed_semantic' || source.kind === 'semantic_query') {
3839
+ if (source.reviewStatus !== 'approved' || !source.snapshotId || !source.receiptId) {
3840
+ errors.push(`Source ${source.id} requires snapshot-bound semantic approval before Project publication.`);
3841
+ }
3842
+ }
3843
+ if (source.kind === 'review_block' || source.kind === 'review_dql' || source.kind === 'exploratory_sql') {
3844
+ errors.push(`Source ${source.id} is review-required and cannot be published to the Project.`);
3845
+ }
3846
+ if ((source.kind === 'review_block' || source.kind === 'review_dql' || source.kind === 'exploratory_sql') && draft.sourcePolicy !== 'include_review_required') {
3847
+ errors.push(`Source ${source.id} requires include_review_required policy.`);
3848
+ }
2478
3849
  }
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
- }),
3850
+ const settledPageIds = new Set(receipts.filter((receipt) => receipt.revision === draft.revision).map((receipt) => receipt.pageId));
3851
+ const readiness = appPublicationReadiness(projectRoot, draft.pages, { settledPageIds });
3852
+ errors.push(...readiness.blockers.map((blocker) => blocker.message));
3853
+ return Array.from(new Set(errors));
3854
+ }
3855
+ function preflightAppBuildRequestErrors(projectRoot, draft, verifyPreview) {
3856
+ const errors = preflightStoredAppBuildDraft(projectRoot, draft);
3857
+ const analysisPages = draft.pages
3858
+ .map((page) => ({
3859
+ pageId: page.id,
3860
+ tileIds: page.layout.items.filter((tile) => !tile.text && !tile.aiPin).map((tile) => tile.i),
3861
+ }))
3862
+ .filter((page) => page.tileIds.length > 0);
3863
+ const receipts = draft.previewReceipts ?? (draft.previewReceipt ? [draft.previewReceipt] : []);
3864
+ if (analysisPages.length > 0 && !verifyPreview) {
3865
+ errors.push('The runtime cannot verify the settled App preview receipts.');
3866
+ }
3867
+ for (const page of analysisPages) {
3868
+ const receipt = receipts.find((candidate) => candidate.pageId === page.pageId);
3869
+ if (!receipt || !verifyPreview)
3870
+ continue;
3871
+ const verified = verifyPreview({
3872
+ runId: receipt.id,
3873
+ draftId: draft.id,
3874
+ dashboardId: page.pageId,
3875
+ tileIds: page.tileIds,
3876
+ });
3877
+ if (!verified.ok
3878
+ || verified.snapshotId !== receipt.snapshotId
3879
+ || verified.resultFingerprint !== receipt.resultFingerprint) {
3880
+ errors.push(verified.ok
3881
+ ? `The saved preview receipt for ${page.pageId} no longer matches the settled run.`
3882
+ : verified.error);
3883
+ }
3884
+ }
3885
+ return Array.from(new Set(errors));
3886
+ }
3887
+ function appBuildRequirementBlocksPublication(draft, requirement) {
3888
+ if (!requirement.required)
3889
+ return false;
3890
+ if (draft.authoringMode === 'manual' && draft.frame.metrics.length === 0 && requirement.measures.length === 0)
3891
+ return false;
3892
+ return true;
3893
+ }
3894
+ function appBuildReviewTaskBlocksPublication(draft, task) {
3895
+ if (task.status !== 'open')
3896
+ return false;
3897
+ if (task.sourceId && draft.sources.some((source) => source.id === task.sourceId))
3898
+ return true;
3899
+ if (task.tileId && draft.pages.some((page) => page.layout.items.some((tile) => tile.i === task.tileId)))
3900
+ return true;
3901
+ if (task.pageId && draft.pages.some((page) => page.id === task.pageId))
3902
+ return true;
3903
+ // v2 AI plans previously persisted general guidance as open tasks without an
3904
+ // attached source, page, or tile. Keep those reminders readable, but do not
3905
+ // turn them into an unexplained hard publication gate.
3906
+ return false;
3907
+ }
3908
+ function hasCurrentAppBuildPreflightReceipt(draft) {
3909
+ return Boolean(draft.preflightReceipt
3910
+ && draft.preflightReceipt.revision === draft.revision
3911
+ && draft.preflightReceipt.proposalHash === draft.proposalHash
3912
+ && draft.preflightReceipt.sourceFingerprint === appBuildSourceFingerprint(draft));
3913
+ }
3914
+ export function markStoredAppBuildDraftPreflighted(projectRoot, draft) {
3915
+ const errors = preflightStoredAppBuildDraft(projectRoot, draft);
3916
+ if (errors.length)
3917
+ throw new Error(`App Build preflight failed: ${errors.join(' ')}`);
3918
+ const now = new Date().toISOString();
3919
+ const next = {
3920
+ ...draft,
3921
+ state: 'preflight_ready',
3922
+ preflightReceipt: {
3923
+ id: `app_preflight_${createHash('sha256').update(`${draft.id}:${draft.revision}:${draft.proposalHash}`).digest('hex').slice(0, 16)}`,
3924
+ revision: draft.revision,
3925
+ proposalHash: draft.proposalHash,
3926
+ sourceFingerprint: appBuildSourceFingerprint(draft),
3927
+ createdAt: now,
2506
3928
  },
3929
+ updatedAt: now,
2507
3930
  };
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) };
3931
+ writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: draft.revision });
3932
+ return next;
2513
3933
  }
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
- });
3934
+ function appBuildSourceFingerprint(draft) {
3935
+ const sources = [...draft.sources]
3936
+ .sort((left, right) => left.id.localeCompare(right.id))
3937
+ .map((source) => ({
3938
+ id: source.id,
3939
+ kind: source.kind,
3940
+ sourceRef: source.sourceRef,
3941
+ snapshotId: source.snapshotId,
3942
+ sourceFingerprint: source.sourceFingerprint,
3943
+ receiptId: source.receiptId,
3944
+ reviewStatus: source.reviewStatus,
3945
+ }));
3946
+ return `sha256:${createHash('sha256').update(JSON.stringify(sources)).digest('hex')}`;
3947
+ }
3948
+ export function publishStoredAppBuildDraft(projectRoot, draft) {
3949
+ const errors = preflightStoredAppBuildDraft(projectRoot, draft);
3950
+ if (errors.length)
3951
+ throw new Error(`App Build preflight failed: ${errors.join(' ')}`);
3952
+ if (!hasCurrentAppBuildPreflightReceipt(draft)) {
3953
+ throw new Error('App Build Draft needs a current preflight receipt before publication.');
3954
+ }
3955
+ const destination = resolveAppPackageDir(projectRoot, draft.appId);
3956
+ if (!draft.baseApp && existsSync(destination))
3957
+ throw new Error(`App already exists: ${draft.appId}`);
3958
+ if (draft.baseApp && (!existsSync(destination) || draft.baseApp.appId !== draft.appId)) {
3959
+ throw new Error('Published App base no longer exists at the expected path.');
3960
+ }
3961
+ const stage = join(projectRoot, '.dql', 'local', 'app-build-staging', `${draft.id}-${Date.now()}`);
3962
+ const dashboardDir = join(stage, 'dashboards');
3963
+ const owner = `${process.env.USER ?? 'owner'}@local`;
3964
+ const domain = draft.pages[0]?.metadata.domain || 'general';
3965
+ const name = draft.name.trim() || draft.appId;
3966
+ const containsSemantic = draft.sources.some((source) => source.kind === 'governed_semantic' || source.kind === 'semantic_query');
3967
+ const hasCertifiedAnalysis = draft.sources.some((source) => source.kind === 'certified_block');
3968
+ const lifecycle = hasCertifiedAnalysis && !containsSemantic ? 'certified' : 'review';
3969
+ const app = {
3970
+ version: 1,
3971
+ id: draft.appId,
3972
+ name,
3973
+ description: draft.frame.decision || `Governed analytical App for ${name}`,
3974
+ visibility: 'shared',
3975
+ publicationIntent: 'shared_project',
3976
+ ownerDomain: domain,
3977
+ usesDomains: [domain],
3978
+ requiredExports: [],
3979
+ domain,
3980
+ audience: draft.frame.audience,
3981
+ lifecycle,
3982
+ owners: [owner],
3983
+ members: [{ userId: owner, displayName: owner, roles: ['owner', 'analyst'] }],
3984
+ roles: [
3985
+ { id: 'owner', displayName: 'Owner' },
3986
+ { id: 'analyst', displayName: 'Analyst' },
3987
+ { id: 'viewer', displayName: 'Viewer' },
3988
+ ],
3989
+ policies: [
3990
+ { id: 'viewers-read', domain, minClassification: 'internal', allowedRoles: ['viewer', 'analyst', 'owner'], accessLevel: 'read', enabled: true },
3991
+ { id: 'analyst-execute', domain, minClassification: 'internal', allowedRoles: ['analyst', 'owner'], accessLevel: 'execute', enabled: true },
3992
+ { id: 'owner-admin', domain, minClassification: 'restricted', allowedRoles: ['owner'], accessLevel: 'admin', enabled: true },
3993
+ ],
3994
+ rlsBindings: [],
3995
+ schedules: [],
3996
+ homepage: { type: 'dashboard', id: draft.pages[0].id },
3997
+ };
3998
+ const committedPages = draft.pages.map((page) => ({
3999
+ ...page,
4000
+ version: 2,
4001
+ metadata: { ...page.metadata, visibility: 'shared', lifecycle },
4002
+ }));
4003
+ mkdirSync(dashboardDir, { recursive: true });
4004
+ mkdirSync(join(stage, 'notebooks'), { recursive: true });
4005
+ mkdirSync(join(stage, 'drafts'), { recursive: true });
4006
+ try {
4007
+ writeFileSync(join(stage, 'dql.app.json'), JSON.stringify(app, null, 2) + '\n', 'utf-8');
4008
+ for (const page of committedPages) {
4009
+ writeFileSync(join(dashboardDir, `${page.id}.dqld`), JSON.stringify(page, null, 2) + '\n', 'utf-8');
4010
+ }
4011
+ writeFileSync(join(stage, 'README.md'), appReadme(app, draft.frame.audience ?? '', [], committedPages[0].id), 'utf-8');
4012
+ mkdirSync(dirname(destination), { recursive: true });
4013
+ if (draft.baseApp) {
4014
+ const backup = join(projectRoot, '.dql', 'local', 'app-build-staging', `${draft.id}-backup-${Date.now()}`);
4015
+ renameSync(destination, backup);
4016
+ try {
4017
+ renameSync(stage, destination);
4018
+ rmSync(backup, { recursive: true, force: true });
2528
4019
  }
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
- }
4020
+ catch (error) {
4021
+ if (!existsSync(destination) && existsSync(backup))
4022
+ renameSync(backup, destination);
4023
+ throw error;
2547
4024
  }
2548
4025
  }
4026
+ else {
4027
+ renameSync(stage, destination);
4028
+ }
2549
4029
  }
2550
- return { ready: blockers.length === 0, governedTiles, blockers };
4030
+ catch (error) {
4031
+ rmSync(stage, { recursive: true, force: true });
4032
+ throw error;
4033
+ }
4034
+ const next = {
4035
+ ...draft,
4036
+ revision: draft.revision + 1,
4037
+ state: 'project_published',
4038
+ publishedFingerprint: fingerprintAppDirectory(destination),
4039
+ updatedAt: new Date().toISOString(),
4040
+ proposalHash: draft.proposalHash,
4041
+ };
4042
+ writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: draft.revision });
4043
+ const created = collectAppsList(projectRoot).find((entry) => entry.id === draft.appId);
4044
+ if (!created)
4045
+ throw new Error(`App was committed but could not be reloaded: ${draft.appId}`);
4046
+ return {
4047
+ ok: true,
4048
+ app: created,
4049
+ draft: next,
4050
+ paths: ['dql.app.json', 'README.md', ...committedPages.map((page) => `dashboards/${page.id}.dqld`)].map((path) => `apps/${draft.appId}/${path}`),
4051
+ };
2551
4052
  }
2552
- function appAiBuildSessionDir(projectRoot) {
2553
- return join(projectRoot, '.dql', 'local', 'app-ai-builds');
4053
+ /** @deprecated Compatibility adapter for callers that still use the old name. */
4054
+ export function commitStoredAppBuildDraft(projectRoot, draft) {
4055
+ return publishStoredAppBuildDraft(projectRoot, draft);
2554
4056
  }
2555
4057
  function writeAppAiBuildSession(projectRoot, session) {
2556
4058
  const dir = appAiBuildSessionDir(projectRoot);
@@ -2646,7 +4148,7 @@ function appAskTileContext(tile) {
2646
4148
  sourceEvidence: tile.sourceEvidence,
2647
4149
  };
2648
4150
  }
2649
- function buildAppContextEnvelope(app, appDir, focus) {
4151
+ function buildAppContextEnvelope(projectRoot, app, appDir, focus) {
2650
4152
  const dashboards = findDashboardsForApp(appDir)
2651
4153
  .map((path) => loadDashboardDocument(path).document)
2652
4154
  .filter((document) => Boolean(document))
@@ -2671,6 +4173,7 @@ function buildAppContextEnvelope(app, appDir, focus) {
2671
4173
  caveats: app.caveats ?? [],
2672
4174
  },
2673
4175
  dashboards,
4176
+ drafts: loadAppDraftContext(projectRoot, appDir),
2674
4177
  focus,
2675
4178
  };
2676
4179
  }
@@ -2683,6 +4186,9 @@ function filterBindingsForBlockSource(projectRoot, block) {
2683
4186
  const parameterSection = sectionBody(source, 'parameterPolicy');
2684
4187
  const bindings = Array.from(filterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"([^"]+)"/gm))
2685
4188
  .map((match) => ({ filter: match[1], binding: match[2], mode: 'predicate' }));
4189
+ const allowedFilterBindings = matchArray(source, /allowedFilters\s*=\s*\[([^\]]*)\]/)
4190
+ .filter((filter) => !bindings.some((binding) => binding.filter === filter))
4191
+ .map((filter) => ({ filter, binding: filter, mode: 'predicate' }));
2686
4192
  const parameterNames = Array.from(parameterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"dynamic"/gm))
2687
4193
  .map((match) => match[1]);
2688
4194
  const parameterBindings = parameterNames.map((param) => ({
@@ -2691,7 +4197,7 @@ function filterBindingsForBlockSource(projectRoot, block) {
2691
4197
  mode: 'parameter',
2692
4198
  paramNames: [param],
2693
4199
  }));
2694
- return uniqueFilterBindings([...bindings, ...parameterBindings]);
4200
+ return uniqueFilterBindings([...bindings, ...allowedFilterBindings, ...parameterBindings]);
2695
4201
  }
2696
4202
  function sectionBody(source, sectionName) {
2697
4203
  const match = new RegExp(`${sectionName}\\s*\\{([\\s\\S]*?)\\n\\s*\\}`, 'm').exec(source);
@@ -2857,7 +4363,7 @@ function promoteSharedDashboardItem(item) {
2857
4363
  reviewStatus,
2858
4364
  };
2859
4365
  }
2860
- export function deleteAppPackage(projectRoot, id) {
4366
+ export function deleteAppPackage(projectRoot, id, input = {}) {
2861
4367
  const cleanId = cleanString(id);
2862
4368
  if (!cleanId || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(cleanId)) {
2863
4369
  return { ok: false, status: 400, error: 'A valid App id is required.' };
@@ -2865,27 +4371,130 @@ export function deleteAppPackage(projectRoot, id) {
2865
4371
  const loaded = loadAppById(projectRoot, cleanId);
2866
4372
  if (!loaded)
2867
4373
  return { ok: false, status: 404, error: `App "${cleanId}" not found.` };
4374
+ const currentFingerprint = fingerprintAppDirectory(loaded.appDir);
4375
+ if (!input.expectedFingerprint) {
4376
+ return { ok: false, status: 409, error: 'App deletion requires the fingerprint shown during confirmation.' };
4377
+ }
4378
+ if (input.expectedFingerprint !== currentFingerprint) {
4379
+ return { ok: false, status: 409, error: 'This App changed since deletion was requested. Refresh and review it again.' };
4380
+ }
2868
4381
  const relativeAppDir = relative(projectRoot, loaded.appDir).replaceAll('\\', '/');
2869
4382
  const isProjectApp = /^apps\/[^/]+$/.test(relativeAppDir);
2870
4383
  const isDomainApp = /^domains\/(?:[^/]+\/)*apps\/[^/]+$/.test(relativeAppDir);
2871
4384
  if ((!isProjectApp && !isDomainApp) || relativeAppDir.startsWith('../')) {
2872
4385
  return { ok: false, status: 400, error: 'DQL refused to delete an App outside this project.' };
2873
4386
  }
2874
- const trashRoot = join(projectRoot, '.dql', 'trash', 'apps');
4387
+ const trashRoot = join(projectRoot, '.dql', 'local', 'trash', 'apps');
2875
4388
  mkdirSync(trashRoot, { recursive: true });
2876
4389
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
2877
- let trashDir = join(trashRoot, `${stamp}-${cleanId}`);
4390
+ let recoveryId = `${stamp}-${cleanId}`;
4391
+ let trashDir = join(trashRoot, recoveryId);
2878
4392
  let suffix = 2;
2879
- while (existsSync(trashDir))
2880
- trashDir = join(trashRoot, `${stamp}-${cleanId}-${suffix++}`);
2881
- renameSync(loaded.appDir, trashDir);
4393
+ while (existsSync(trashDir)) {
4394
+ recoveryId = `${stamp}-${cleanId}-${suffix++}`;
4395
+ trashDir = join(trashRoot, recoveryId);
4396
+ }
4397
+ mkdirSync(trashDir, { recursive: true });
4398
+ const packageDir = join(trashDir, 'package');
4399
+ renameSync(loaded.appDir, packageDir);
4400
+ let localArchive;
4401
+ try {
4402
+ const dbPath = defaultLocalAppsDbPath(projectRoot);
4403
+ if (existsSync(dbPath)) {
4404
+ const storage = new LocalAppStorage(dbPath);
4405
+ try {
4406
+ localArchive = storage.archiveAndDeleteAppState(cleanId);
4407
+ }
4408
+ finally {
4409
+ storage.close();
4410
+ }
4411
+ writeFileSync(join(trashDir, 'local-state.json'), JSON.stringify(localArchive, null, 2) + '\n', 'utf-8');
4412
+ }
4413
+ writeFileSync(join(trashDir, 'recovery.json'), JSON.stringify({
4414
+ version: 1,
4415
+ recoveryId,
4416
+ appId: cleanId,
4417
+ originalPath: relativeAppDir,
4418
+ appFingerprint: currentFingerprint,
4419
+ deletedAt: new Date().toISOString(),
4420
+ }, null, 2) + '\n', 'utf-8');
4421
+ }
4422
+ catch (error) {
4423
+ if (localArchive) {
4424
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
4425
+ try {
4426
+ storage.restoreAppState(localArchive);
4427
+ }
4428
+ finally {
4429
+ storage.close();
4430
+ }
4431
+ }
4432
+ if (!existsSync(loaded.appDir) && existsSync(packageDir))
4433
+ renameSync(packageDir, loaded.appDir);
4434
+ rmSync(trashDir, { recursive: true, force: true });
4435
+ throw error;
4436
+ }
4437
+ const localRowsArchived = localArchive
4438
+ ? Object.values(localArchive.rows).reduce((count, rows) => count + rows.length, 0)
4439
+ : 0;
2882
4440
  return {
2883
4441
  ok: true,
2884
4442
  id: cleanId,
2885
4443
  deletedPath: relativeAppDir,
2886
4444
  trashPath: relative(projectRoot, trashDir).replaceAll('\\', '/'),
4445
+ recoveryId,
4446
+ localRowsArchived,
4447
+ sourceControlImpact: 'delete',
2887
4448
  };
2888
4449
  }
4450
+ export function restoreAppPackage(projectRoot, recoveryId) {
4451
+ const cleanRecoveryId = cleanString(recoveryId);
4452
+ if (!cleanRecoveryId || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(cleanRecoveryId)) {
4453
+ return { ok: false, status: 400, error: 'A valid recovery id is required.' };
4454
+ }
4455
+ const recoveryDir = join(projectRoot, '.dql', 'local', 'trash', 'apps', cleanRecoveryId);
4456
+ const manifestPath = join(recoveryDir, 'recovery.json');
4457
+ const packageDir = join(recoveryDir, 'package');
4458
+ if (!existsSync(manifestPath) || !existsSync(packageDir))
4459
+ return { ok: false, status: 404, error: 'App recovery bundle not found.' };
4460
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
4461
+ if (!manifest.appId || !manifest.originalPath || manifest.originalPath.startsWith('../')) {
4462
+ return { ok: false, status: 400, error: 'App recovery bundle is invalid.' };
4463
+ }
4464
+ const destination = join(projectRoot, manifest.originalPath);
4465
+ if (existsSync(destination))
4466
+ return { ok: false, status: 409, error: `Cannot restore because ${manifest.originalPath} already exists.` };
4467
+ mkdirSync(dirname(destination), { recursive: true });
4468
+ renameSync(packageDir, destination);
4469
+ try {
4470
+ const localStatePath = join(recoveryDir, 'local-state.json');
4471
+ if (existsSync(localStatePath)) {
4472
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
4473
+ try {
4474
+ storage.restoreAppState(JSON.parse(readFileSync(localStatePath, 'utf-8')));
4475
+ }
4476
+ finally {
4477
+ storage.close();
4478
+ }
4479
+ }
4480
+ }
4481
+ catch (error) {
4482
+ renameSync(destination, packageDir);
4483
+ throw error;
4484
+ }
4485
+ rmSync(recoveryDir, { recursive: true, force: true });
4486
+ return { ok: true, id: manifest.appId, restoredPath: manifest.originalPath };
4487
+ }
4488
+ export function restoreAppRecovery(projectRoot, recoveryId) {
4489
+ const cleanRecoveryId = cleanString(recoveryId);
4490
+ if (!cleanRecoveryId || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(cleanRecoveryId)) {
4491
+ return { ok: false, status: 400, error: 'A valid recovery id is required.' };
4492
+ }
4493
+ const draftRecovery = join(projectRoot, '.dql', 'local', 'trash', 'app-drafts', cleanRecoveryId, 'recovery.json');
4494
+ return existsSync(draftRecovery)
4495
+ ? restoreStoredAppBuildDraft(projectRoot, cleanRecoveryId)
4496
+ : restoreAppPackage(projectRoot, cleanRecoveryId);
4497
+ }
2889
4498
  export function createAppPackage(projectRoot, input) {
2890
4499
  const name = cleanString(input.name);
2891
4500
  const domain = cleanString(input.ownerDomain) || cleanString(input.domain);
@@ -2903,16 +4512,23 @@ export function createAppPackage(projectRoot, input) {
2903
4512
  const audience = cleanString(input.audience);
2904
4513
  const subdomain = cleanString(input.subdomain);
2905
4514
  const groups = normalizeTags(input.groups ?? []);
2906
- const visibility = input.visibility === 'private' || input.visibility === 'template' ? input.visibility : 'shared';
2907
- const lifecycle = input.lifecycle === 'certified' || input.lifecycle === 'review' || input.lifecycle === 'deprecated'
2908
- ? input.lifecycle
2909
- : 'draft';
4515
+ // API-013: authoring always starts as a private draft. Project visibility is
4516
+ // available only through the publication endpoint and its live trust checks.
4517
+ const visibility = 'private';
4518
+ const lifecycle = 'draft';
2910
4519
  const tags = normalizeTags([...(input.tags ?? []), audience ? `audience:${slugify(audience)}` : '']);
2911
4520
  const selectedIds = Array.from(new Set((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean)));
2912
4521
  const blocks = collectBlockCandidates(projectRoot);
2913
4522
  const selectedBlocks = selectedIds
2914
4523
  .map((blockId) => blocks.find((block) => block.id === blockId || block.name === blockId))
2915
4524
  .filter((block) => Boolean(block));
4525
+ const missingBlocks = selectedIds.filter((blockId) => !selectedBlocks.some((block) => block.id === blockId || block.name === blockId));
4526
+ if (missingBlocks.length)
4527
+ return { ok: false, error: `Unknown block selection: ${missingBlocks.join(', ')}` };
4528
+ const uncertifiedBlocks = selectedBlocks.filter((block) => block.status !== 'certified');
4529
+ if (uncertifiedBlocks.length) {
4530
+ return { ok: false, error: `Manual App creation accepts certified blocks only: ${uncertifiedBlocks.map((block) => block.name).join(', ')}` };
4531
+ }
2916
4532
  const app = {
2917
4533
  version: 1,
2918
4534
  id,
@@ -2970,7 +4586,7 @@ export function createAppPackage(projectRoot, input) {
2970
4586
  homepage: { type: 'dashboard', id: dashboardId },
2971
4587
  };
2972
4588
  const dashboard = {
2973
- version: 1,
4589
+ version: 2,
2974
4590
  id: dashboardId,
2975
4591
  metadata: {
2976
4592
  title: dashboardTitle,
@@ -2983,11 +4599,13 @@ export function createAppPackage(projectRoot, input) {
2983
4599
  lifecycle,
2984
4600
  tags,
2985
4601
  },
4602
+ filters: dashboardFiltersForBlocks(selectedBlocks),
2986
4603
  layout: {
2987
4604
  kind: 'grid',
2988
4605
  cols: 12,
2989
4606
  rowHeight: 80,
2990
- items: buildDashboardItems(selectedBlocks),
4607
+ items: buildDashboardItems(projectRoot, selectedBlocks),
4608
+ responsive: responsiveLayoutsForDashboardItems(buildDashboardItems(projectRoot, selectedBlocks)),
2991
4609
  },
2992
4610
  };
2993
4611
  const paths = [
@@ -3013,7 +4631,81 @@ export function createAppPackage(projectRoot, input) {
3013
4631
  dashboardId,
3014
4632
  };
3015
4633
  }
3016
- function buildDashboardItems(blocks) {
4634
+ function createAppPackageViaBuildDraft(projectRoot, input) {
4635
+ const name = cleanString(input.name);
4636
+ const domain = cleanString(input.ownerDomain) || cleanString(input.domain);
4637
+ if (!name)
4638
+ return { ok: false, error: 'name is required' };
4639
+ if (!domain)
4640
+ return { ok: false, error: 'domain is required' };
4641
+ const appId = suggestAppId(name);
4642
+ if (existsSync(resolveAppPackageDir(projectRoot, appId)))
4643
+ return { ok: false, error: `App already exists: ${appId}` };
4644
+ const selectedIds = unique((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean));
4645
+ const blocks = collectBlockCandidates(projectRoot);
4646
+ const selectedBlocks = selectedIds
4647
+ .map((id) => blocks.find((block) => block.id === id || block.name === id))
4648
+ .filter((block) => Boolean(block));
4649
+ const missing = selectedIds.filter((id) => !selectedBlocks.some((block) => block.id === id || block.name === id));
4650
+ if (missing.length)
4651
+ return { ok: false, error: `Unknown block selection: ${missing.join(', ')}` };
4652
+ const uncertified = selectedBlocks.filter((block) => block.status !== 'certified');
4653
+ if (uncertified.length)
4654
+ return { ok: false, error: `Manual App creation accepts certified blocks only: ${uncertified.map((block) => block.name).join(', ')}` };
4655
+ const draft = createStoredAppBuildDraft(projectRoot, {
4656
+ appId,
4657
+ name,
4658
+ goal: name,
4659
+ audience: cleanString(input.audience),
4660
+ domain,
4661
+ authoringMode: 'manual',
4662
+ sourcePolicy: 'governed_only',
4663
+ });
4664
+ const items = buildDashboardItems(projectRoot, selectedBlocks);
4665
+ const page = {
4666
+ ...draft.pages[0],
4667
+ id: slugify(cleanString(input.dashboardTitle) || 'Overview') || 'overview',
4668
+ metadata: {
4669
+ ...draft.pages[0].metadata,
4670
+ title: cleanString(input.dashboardTitle) || 'Overview',
4671
+ description: cleanString(input.purpose) || `Starter dashboard for ${name}`,
4672
+ domain,
4673
+ audience: cleanString(input.audience) || undefined,
4674
+ },
4675
+ filters: dashboardFiltersForBlocks(selectedBlocks),
4676
+ layout: {
4677
+ ...draft.pages[0].layout,
4678
+ items,
4679
+ responsive: responsiveLayoutsForDashboardItems(items),
4680
+ },
4681
+ };
4682
+ const operations = [
4683
+ ...(page.id === draft.pages[0].id ? [] : [{ type: 'remove_page', pageId: draft.pages[0].id }]),
4684
+ { type: 'upsert_page', page },
4685
+ ...selectedBlocks.map((block) => ({
4686
+ type: 'upsert_source',
4687
+ source: {
4688
+ id: `block:${block.id}`,
4689
+ kind: 'certified_block',
4690
+ sourceRef: block.id,
4691
+ sourceFingerprint: block.fingerprint,
4692
+ trustState: 'certified',
4693
+ reviewStatus: 'not_required',
4694
+ },
4695
+ })),
4696
+ ];
4697
+ const revised = applyAppBuildDraftOperations(draft, draft.revision, operations);
4698
+ writeStoredAppBuildDraft(projectRoot, revised, { expectedRevision: draft.revision, operations });
4699
+ try {
4700
+ const preflighted = markStoredAppBuildDraftPreflighted(projectRoot, revised);
4701
+ const committed = publishStoredAppBuildDraft(projectRoot, preflighted);
4702
+ return { ok: true, app: committed.app, paths: committed.paths, dashboardId: page.id };
4703
+ }
4704
+ catch (error) {
4705
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
4706
+ }
4707
+ }
4708
+ function buildDashboardItems(projectRoot, blocks) {
3017
4709
  let x = 0;
3018
4710
  let y = 0;
3019
4711
  let rowH = 0;
@@ -3027,13 +4719,51 @@ function buildDashboardItems(blocks) {
3027
4719
  y += rowH || size.h;
3028
4720
  rowH = 0;
3029
4721
  }
3030
- const item = dashboardItemForBlock(block, chartType, x, y, size, index);
4722
+ const item = dashboardItemForBlock(block, chartType, x, y, size, index, filterBindingsForBlockSource(projectRoot, block));
3031
4723
  x += size.w;
3032
4724
  rowH = Math.max(rowH, size.h);
3033
4725
  return item;
3034
4726
  });
3035
4727
  }
3036
- function dashboardItemForBlock(block, chartType, x, y, size, index) {
4728
+ function dashboardFiltersForBlocks(blocks) {
4729
+ return unique(blocks.flatMap((block) => block.filterIds)).map((id) => ({
4730
+ id,
4731
+ label: id.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase()),
4732
+ type: /(?:^|_)(?:top_n|limit|count|amount|number|minimum|maximum)(?:_|$)/i.test(id)
4733
+ ? 'number'
4734
+ : /(?:date|time|period|month|quarter|year)/i.test(id)
4735
+ ? 'date'
4736
+ : /(?:^|_)(?:name|id|search|email)(?:_|$)/i.test(id)
4737
+ ? 'search'
4738
+ : 'select',
4739
+ bindsTo: id,
4740
+ }));
4741
+ }
4742
+ function responsiveLayoutsForDashboardItems(items) {
4743
+ const project = (cols) => {
4744
+ let x = 0;
4745
+ let y = 0;
4746
+ let rowHeight = 0;
4747
+ return [...items].sort((left, right) => left.y - right.y || left.x - right.x).map((item) => {
4748
+ const width = cols === 1 ? 1 : Math.min(cols, Math.max(2, Math.ceil(item.w / 2)));
4749
+ if (x + width > cols) {
4750
+ x = 0;
4751
+ y += rowHeight || item.h;
4752
+ rowHeight = 0;
4753
+ }
4754
+ const next = { ...item, x, y, w: width };
4755
+ x += width;
4756
+ rowHeight = Math.max(rowHeight, item.h);
4757
+ return next;
4758
+ });
4759
+ };
4760
+ return {
4761
+ wide: { kind: 'grid', cols: 12, rowHeight: 80, items },
4762
+ medium: { kind: 'grid', cols: 6, rowHeight: 80, items: project(6) },
4763
+ narrow: { kind: 'grid', cols: 1, rowHeight: 80, items: project(1) },
4764
+ };
4765
+ }
4766
+ function dashboardItemForBlock(block, chartType, x, y, size, index, filterBindings = []) {
3037
4767
  return {
3038
4768
  i: slugify(block.name) || `tile-${index + 1}`,
3039
4769
  x,
@@ -3043,7 +4773,12 @@ function dashboardItemForBlock(block, chartType, x, y, size, index) {
3043
4773
  block: { blockId: block.name },
3044
4774
  viz: { type: chartType },
3045
4775
  display: displayForBlockHint(block, chartType),
4776
+ sourceClass: 'certified_block',
4777
+ review: { status: 'not_required', sourceFingerprint: block.fingerprint },
4778
+ trustState: 'certified',
4779
+ reviewStatus: 'certified',
3046
4780
  title: block.name,
4781
+ ...(filterBindings.length ? { filterBindings } : {}),
3047
4782
  };
3048
4783
  }
3049
4784
  function vizRank(chartType) {
@@ -3209,8 +4944,13 @@ function visualizationFitsResult(viz, columns, rows) {
3209
4944
  const hasTime = columns.some((column) => /\b(date|time|week|month|quarter|year|season|period)\b/i.test(column.name));
3210
4945
  if (viz === 'table' || viz === 'pivot' || viz === 'text' || viz === 'heading')
3211
4946
  return true;
4947
+ // An empty row sample means "cannot judge", not "does not fit". Returning
4948
+ // false here silently rewrote every requested visualization to `table`
4949
+ // whenever no rows were supplied — which is the normal case when a tile is
4950
+ // configured before its first run, so authored and AI-chosen charts were
4951
+ // routinely discarded. Keep the request; the renderer copes with the shape.
3212
4952
  if (rows.length === 0)
3213
- return false;
4953
+ return true;
3214
4954
  if (viz === 'single_value' || viz === 'kpi' || viz === 'gauge')
3215
4955
  return rows.length === 1 && measures.length >= 1;
3216
4956
  if (viz === 'line' || viz === 'area')
@@ -3415,7 +5155,12 @@ function collectBlockCandidates(projectRoot) {
3415
5155
  seen.add(filePath);
3416
5156
  const source = readFileSync(filePath, 'utf-8');
3417
5157
  const stat = statSyncSafe(filePath);
3418
- const name = matchString(source, /block\s+"([^"]+)"/) ?? entry.name.replace(/\.dql$/, '');
5158
+ const name = matchString(source, /block\s+"([^"]+)"/);
5159
+ // The App source picker is an executable-block catalog. Domain terms,
5160
+ // skills, and other .dql context remain available to planning, but they
5161
+ // must not appear as addable dashboard data sources.
5162
+ if (!name)
5163
+ continue;
3419
5164
  const tags = matchArray(source, /tags\s*=\s*\[([^\]]*)\]/);
3420
5165
  blocks.push({
3421
5166
  id: name,
@@ -3425,10 +5170,13 @@ function collectBlockCandidates(projectRoot) {
3425
5170
  owner: matchString(source, /owner\s*=\s*"([^"]+)"/),
3426
5171
  tags,
3427
5172
  path: relative(projectRoot, filePath).replaceAll('\\', '/'),
5173
+ fingerprint: `sha256:${createHash('sha256').update(source).digest('hex')}`,
3428
5174
  lastModified: stat?.mtime.toISOString() ?? new Date(0).toISOString(),
3429
5175
  description: matchString(source, /description\s*=\s*"((?:[^"\\]|\\.)*)"/) ?? '',
3430
5176
  llmContext: matchString(source, /llmContext\s*=\s*"((?:[^"\\]|\\.)*)"/),
3431
5177
  chartType: matchString(source, /chart\s*=\s*"([^"]+)"/) ?? matchString(source, /chart\.(\w+)\s*\(/) ?? undefined,
5178
+ filterIds: declaredAppFilterIds(source),
5179
+ dimensionIds: declaredAppDimensionIds(source),
3432
5180
  score: 0,
3433
5181
  reasons: [],
3434
5182
  });
@@ -3441,7 +5189,31 @@ function collectBlockCandidates(projectRoot) {
3441
5189
  };
3442
5190
  scanDir(join(projectRoot, 'blocks'));
3443
5191
  scanDir(join(projectRoot, 'domains'));
3444
- return blocks;
5192
+ const byId = new Map();
5193
+ for (const block of blocks) {
5194
+ const current = byId.get(block.id);
5195
+ if (!current) {
5196
+ byId.set(block.id, block);
5197
+ continue;
5198
+ }
5199
+ const blockRank = (block.status === 'certified' ? 4 : 0) + (block.path.startsWith('domains/') ? 2 : 0);
5200
+ const currentRank = (current.status === 'certified' ? 4 : 0) + (current.path.startsWith('domains/') ? 2 : 0);
5201
+ if (blockRank > currentRank || (blockRank === currentRank && block.lastModified > current.lastModified))
5202
+ byId.set(block.id, block);
5203
+ }
5204
+ return Array.from(byId.values());
5205
+ }
5206
+ function declaredAppFilterIds(source) {
5207
+ const filterSection = sectionBody(source, 'filterBindings');
5208
+ const parameterSection = sectionBody(source, 'parameterPolicy');
5209
+ return unique([
5210
+ ...matchArray(source, /allowedFilters\s*=\s*\[([^\]]*)\]/),
5211
+ ...Array.from(filterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"[^"]+"/gm)).map((match) => match[1]),
5212
+ ...Array.from(parameterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"(?:dynamic|optional|business)"/gm)).map((match) => match[1]),
5213
+ ]);
5214
+ }
5215
+ function declaredAppDimensionIds(source) {
5216
+ return unique(matchArray(source, /dimensions\s*=\s*\[([^\]]*)\]/));
3445
5217
  }
3446
5218
  function readdirSyncSafe(dir) {
3447
5219
  try {
@@ -4111,10 +5883,13 @@ function resolveSelectedBlock(projectRoot, selected, sourceBlockId) {
4111
5883
  owner: matchString(source, /owner\s*=\s*"([^"]+)"/),
4112
5884
  tags: matchArray(source, /tags\s*=\s*\[([^\]]*)\]/),
4113
5885
  path: normalizedPath,
5886
+ fingerprint: `sha256:${createHash('sha256').update(source).digest('hex')}`,
4114
5887
  lastModified: statSyncSafe(join(projectRoot, normalizedPath))?.mtime.toISOString() ?? new Date(0).toISOString(),
4115
5888
  description: matchString(source, /description\s*=\s*"((?:[^"\\]|\\.)*)"/) ?? '',
4116
5889
  llmContext: matchString(source, /llmContext\s*=\s*"((?:[^"\\]|\\.)*)"/),
4117
5890
  chartType: matchString(source, /chart\s*=\s*"([^"]+)"/) ?? undefined,
5891
+ filterIds: declaredAppFilterIds(source),
5892
+ dimensionIds: declaredAppDimensionIds(source),
4118
5893
  score: 0,
4119
5894
  reasons: [],
4120
5895
  };
@@ -5051,14 +6826,29 @@ function uniqueAppPageId(base, existingIds) {
5051
6826
  * deliberately stricter than the metadata snapshot: page, notebook, draft, and
5052
6827
  * App-document edits all invalidate approval before the atomic directory swap.
5053
6828
  */
6829
+ /**
6830
+ * Fingerprint the parts of an App package a write can actually conflict with:
6831
+ * the manifest and the dashboard documents.
6832
+ *
6833
+ * Hashing every byte under the App directory made the guard far too sensitive —
6834
+ * a notebook run output, a README edit, or an attached notebook would
6835
+ * invalidate a page proposal that could not possibly conflict with them, and
6836
+ * the commit failed with an unexplained 409.
6837
+ */
5054
6838
  function fingerprintAppDirectory(appDir) {
5055
6839
  const files = [];
5056
6840
  const visit = (directory) => {
5057
6841
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
5058
6842
  const absolutePath = join(directory, entry.name);
5059
- if (entry.isDirectory())
6843
+ if (entry.isDirectory()) {
6844
+ // Only `dashboards/` participates; notebooks, drafts and generated prose do not.
6845
+ if (directory === appDir && entry.name !== 'dashboards')
6846
+ continue;
5060
6847
  visit(absolutePath);
6848
+ }
5061
6849
  else if (entry.isFile() || entry.isSymbolicLink()) {
6850
+ if (directory === appDir && entry.name !== 'dql.app.json')
6851
+ continue;
5062
6852
  files.push({ path: absolutePath, symbolicLink: entry.isSymbolicLink() });
5063
6853
  }
5064
6854
  }
@@ -5679,6 +7469,32 @@ function readNotebookRunSnapshot(absNotebookPath) {
5679
7469
  function normalizeNotebookRole(value) {
5680
7470
  return value === 'source' || value === 'analysis' ? value : 'supporting';
5681
7471
  }
7472
+ /** Longest draft SQL carried into a prompt; drafts are context, not the answer. */
7473
+ const APP_DRAFT_SQL_PROMPT_LIMIT = 1200;
7474
+ /**
7475
+ * The app's drafts, with enough of each to be usable as context.
7476
+ *
7477
+ * `listAppDrafts` reads the same files for the Drafts panel but keeps only a
7478
+ * name and review status, which is why the drafts were visible in the UI and
7479
+ * invisible to the copilot even once the envelope carried them.
7480
+ */
7481
+ function loadAppDraftContext(projectRoot, appDir) {
7482
+ return scanFiles(join(appDir, 'drafts'), '.dql').map((file) => {
7483
+ const source = readFileSync(file, 'utf-8');
7484
+ const path = relative(projectRoot, file).replaceAll('\\', '/');
7485
+ const query = matchString(source, /query\s*=\s*"""([\s\S]*?)"""/)?.trim();
7486
+ return {
7487
+ name: matchString(source, /block\s+"([^"]+)"/) ?? titleFromPath(path),
7488
+ path,
7489
+ status: matchString(source, /status\s*=\s*"([^"]+)"/) ?? 'review',
7490
+ description: matchString(source, /description\s*=\s*"([^"]*)"/) ?? undefined,
7491
+ question: matchString(source, /question\s*=\s*"([^"]*)"/) ?? undefined,
7492
+ sql: query
7493
+ ? query.length > APP_DRAFT_SQL_PROMPT_LIMIT ? `${query.slice(0, APP_DRAFT_SQL_PROMPT_LIMIT)}\n-- …truncated` : query
7494
+ : undefined,
7495
+ };
7496
+ }).sort((a, b) => a.name.localeCompare(b.name));
7497
+ }
5682
7498
  function listAppDrafts(projectRoot, appDir) {
5683
7499
  return scanFiles(join(appDir, 'drafts'), '.dql').map((file) => {
5684
7500
  const source = readFileSync(file, 'utf-8');
@@ -5788,6 +7604,7 @@ function loadDashboardForApp(projectRoot, appId, dashboardId) {
5788
7604
  }
5789
7605
  return null;
5790
7606
  }
7607
+ export { writeDashboard as writeDashboardForTest };
5791
7608
  function writeDashboard(projectRoot, appId, dashboardId, payload) {
5792
7609
  // Validate against the dashboard schema before touching disk.
5793
7610
  const { document, errors } = parseDashboardDocument(JSON.stringify(payload), '<incoming>');
@@ -5803,6 +7620,22 @@ function writeDashboard(projectRoot, appId, dashboardId, payload) {
5803
7620
  return { ok: false, error: `App "${appId}" not found` };
5804
7621
  const appDir = loaded.appDir;
5805
7622
  const dashboardPath = join(appDir, 'dashboards', `${dashboardId}.dqld`);
7623
+ // Dashboard ids are unique only within an App — `overview` is a common name —
7624
+ // so a caller holding one App's document and another App's id would silently
7625
+ // overwrite the wrong file. Only accept a title change to an existing page,
7626
+ // never a wholesale replacement of a different App's page of the same name.
7627
+ if (existsSync(dashboardPath)) {
7628
+ const existing = loadDashboardDocument(dashboardPath).document;
7629
+ const incomingBlocks = new Set(document.layout.items.map((item) => item.i));
7630
+ const existingBlocks = new Set((existing?.layout.items ?? []).map((item) => item.i));
7631
+ const overlap = [...incomingBlocks].filter((tileId) => existingBlocks.has(tileId)).length;
7632
+ if (existingBlocks.size > 0 && incomingBlocks.size > 0 && overlap === 0) {
7633
+ return {
7634
+ ok: false,
7635
+ error: `Refusing to overwrite "${appId}/${dashboardId}": the incoming page shares no tiles with the one on disk, so it is very likely a different App's page of the same name.`,
7636
+ };
7637
+ }
7638
+ }
5806
7639
  mkdirSync(dirname(dashboardPath), { recursive: true });
5807
7640
  writeFileSync(dashboardPath, JSON.stringify(document, null, 2) + '\n', 'utf-8');
5808
7641
  return { ok: true, path: dashboardPath };