@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.
- package/dist/apps-api.d.ts +225 -5
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +1950 -117
- package/dist/apps-api.js.map +1 -1
- package/dist/assets/dql-notebook/assets/{AgentLogPage-D6fgEAEy.js → AgentLogPage-BlUE3qsX.js} +1 -1
- package/dist/assets/dql-notebook/assets/{AiBuildDialog-8miXFPse.js → AiBuildDialog-O88iK6UW.js} +1 -1
- package/dist/assets/dql-notebook/assets/{AiBuildResult-DPJ_SI0R.js → AiBuildResult-D-6G4Sh3.js} +8 -8
- package/dist/assets/dql-notebook/assets/{AiSidePanel-NVA6lZnn.js → AiSidePanel-MRSGtQDV.js} +1 -1
- package/dist/assets/dql-notebook/assets/AnalyticsHome-CcRVO6zN.js +6 -0
- package/dist/assets/dql-notebook/assets/AppsView-BTkGC-wl.js +5588 -0
- package/dist/assets/dql-notebook/assets/{BlockStudio--jNw9XUI.js → BlockStudio-BrTNaDpb.js} +10 -15
- package/dist/assets/dql-notebook/assets/{BusinessArtifactView-DtRMiaF4.js → BusinessArtifactView-itEIUrYy.js} +1 -1
- package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-CbAqOpNc.js +22 -0
- package/dist/assets/dql-notebook/assets/{GitPage-CazC-eTR.js → GitPage-BxOqRYu2.js} +1 -1
- package/dist/assets/dql-notebook/assets/GlobalAiRail-Tfkx76CI.js +1 -0
- package/dist/assets/dql-notebook/assets/GovernedContextPage-CeaDbepP.js +7 -0
- package/dist/assets/dql-notebook/assets/{HelpDocsPage-DMHPsVqP.js → HelpDocsPage-D1lAryYv.js} +1 -1
- package/dist/assets/dql-notebook/assets/{HomePage-DcSI4dnU.js → HomePage-GB6se6xW.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDAG-BJ6xz06u.js → LineageDAG-zCyN-hdg.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDetailView-8WrQm-MF.js → LineageDetailView-Cg0TgG2Y.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDrawer-hnqCW_t-.js → LineageDrawer-DwMcenDf.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-B4LZTUsl.js → LineagePathBreadcrumb-DUZx3ksU.js} +1 -1
- package/dist/assets/dql-notebook/assets/{MiniLineageGraph-CotnRuxa.js → MiniLineageGraph-DT_ycl6s.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NewBlockModal-DHn-CEEW.js → NewBlockModal-qdhzeJPF.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NewNotebookModal-BPGp-8oq.js → NewNotebookModal-BnaDzs1A.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NotebookEditor-B0OTHM6W.js → NotebookEditor-CouPFzdo.js} +5 -5
- package/dist/assets/dql-notebook/assets/{ReadinessPage-CoOIpxWq.js → ReadinessPage-DB_KbH9Y.js} +1 -1
- package/dist/assets/dql-notebook/assets/{SetupOnboarding-DxK_mFzf.js → SetupOnboarding-CamQq_N2.js} +1 -1
- package/dist/assets/dql-notebook/assets/SkillsPage--mYkOAK2.js +10 -0
- package/dist/assets/dql-notebook/assets/{TrustBadge-ZuD5N5Ww.js → TrustBadge-ClEYiYeA.js} +1 -1
- package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-DBfsmXes.js +88 -0
- package/dist/assets/dql-notebook/assets/{answer-to-notebook-WAVAYjBv.js → answer-to-notebook-Cg3UyGwh.js} +1 -1
- package/dist/assets/dql-notebook/assets/{arrow-left-L8odLbyN.js → arrow-left-DnYPGqlr.js} +1 -1
- package/dist/assets/dql-notebook/assets/{arrow-right-WIF1V9a9.js → arrow-right-Ct_YGrmU.js} +1 -1
- package/dist/assets/dql-notebook/assets/{book-open-text-BpqWMa4m.js → book-open-text-DULlwbj6.js} +1 -1
- package/dist/assets/dql-notebook/assets/{circle-x-CQBOM0b0.js → circle-x-ByYxoiS9.js} +1 -1
- package/dist/assets/dql-notebook/assets/{dagre.esm-DlZi7NR3.js → dagre.esm-BjYSO53F.js} +1 -1
- package/dist/assets/dql-notebook/assets/{external-link-DYqdQIGp.js → external-link-DprWh9Yf.js} +1 -1
- package/dist/assets/dql-notebook/assets/{grip-vertical-HmUijbwN.js → grip-vertical-CrEEYkXN.js} +1 -1
- package/dist/assets/dql-notebook/assets/{index-Cpx47jCH.js → index-9w93YPF9.js} +127 -126
- package/dist/assets/dql-notebook/assets/index-B3shyZsg.css +1 -0
- package/dist/assets/dql-notebook/assets/link-2-BkW-rkEC.js +6 -0
- package/dist/assets/dql-notebook/assets/{list-tree-DGKqCxmz.js → list-tree-BipBLmIE.js} +1 -1
- package/dist/assets/dql-notebook/assets/{minimize-2-C8dvqCxX.js → minimize-2-BhXsglrO.js} +1 -1
- package/dist/assets/dql-notebook/assets/{panel-right-open-yuVkIo5n.js → panel-right-open-BoCSilYB.js} +1 -1
- package/dist/assets/dql-notebook/assets/{bot-CLR1V6dO.js → play-mdoZawPu.js} +7 -2
- package/dist/assets/dql-notebook/assets/{rotate-ccw-Di0kRnrM.js → rotate-ccw-ELiDRUmj.js} +1 -1
- package/dist/assets/dql-notebook/assets/{semantic-fields-BMQV0li8.js → semantic-fields-DVNRLfeU.js} +1 -1
- package/dist/assets/dql-notebook/assets/{sliders-horizontal-alebptlJ.js → sliders-horizontal-GeUzpmKz.js} +1 -1
- package/dist/assets/dql-notebook/assets/{star-CgCQbnXc.js → star-D63O81Wp.js} +1 -1
- package/dist/assets/dql-notebook/assets/{triangle-alert-SyuSZ4ht.js → triangle-alert-BWrCxQVE.js} +1 -1
- package/dist/assets/dql-notebook/assets/upload-CI-ldXhC.js +6 -0
- package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Cv7Yq2hK.js +32 -0
- package/dist/assets/dql-notebook/assets/{user-round-J4PQHxo9.js → user-round-Do21aFGo.js} +1 -1
- package/dist/assets/dql-notebook/assets/{wand-sparkles-c4WDXTCk.js → wand-sparkles-DT7u7F_x.js} +1 -1
- package/dist/assets/dql-notebook/assets/{workflow-NtRLaq7n.js → workflow-DHh2mMbn.js} +1 -1
- package/dist/assets/dql-notebook/assets/{wrench-CS1f1Zv4.js → wrench-7tYr_GKb.js} +1 -1
- package/dist/assets/dql-notebook/assets/{x-CbKEfe5m.js → x-CcYceFMz.js} +1 -1
- package/dist/assets/dql-notebook/index.html +2 -2
- package/dist/commands/app.d.ts.map +1 -1
- package/dist/commands/app.js +65 -133
- package/dist/commands/app.js.map +1 -1
- package/dist/commands/model.js +21 -2
- package/dist/commands/model.js.map +1 -1
- package/dist/llm/providers/dql-agent-provider.d.ts +16 -0
- package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
- package/dist/llm/providers/dql-agent-provider.js +65 -2
- package/dist/llm/providers/dql-agent-provider.js.map +1 -1
- package/dist/local-runtime.d.ts +65 -0
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +1008 -125
- package/dist/local-runtime.js.map +1 -1
- package/dist/package.json +10 -10
- package/dist/sql-result-filter.d.ts +9 -0
- package/dist/sql-result-filter.d.ts.map +1 -1
- package/dist/sql-result-filter.js +25 -0
- package/dist/sql-result-filter.js.map +1 -1
- package/package.json +10 -10
- package/dist/assets/dql-notebook/assets/AgentAnswerCard-c8QUo6NH.js +0 -5
- package/dist/assets/dql-notebook/assets/AnalyticsHome-DgwnRW71.js +0 -6
- package/dist/assets/dql-notebook/assets/AppsView-B6LYUpgm.js +0 -4578
- package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-DhBsLCYr.js +0 -22
- package/dist/assets/dql-notebook/assets/GlobalAiRail-1Tgweuu2.js +0 -1
- package/dist/assets/dql-notebook/assets/GovernedContextPage-C7qGrH3d.js +0 -16
- package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-duFXXNV_.js +0 -88
- package/dist/assets/dql-notebook/assets/index-DKu4CZt3.css +0 -1
- package/dist/assets/dql-notebook/assets/upload-BPhfea_E.js +0 -11
- 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
|
-
|
|
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
|
|
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
|
|
2291
|
+
return filteredTile;
|
|
1697
2292
|
const viz = override.viz;
|
|
1698
2293
|
return {
|
|
1699
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 = [
|
|
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:
|
|
2198
|
-
? [
|
|
2199
|
-
:
|
|
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 (
|
|
2465
|
-
renameSync(
|
|
3787
|
+
if (artifactsRestored && existsSync(localArtifactsDir))
|
|
3788
|
+
renameSync(localArtifactsDir, recoveredArtifactsDir);
|
|
2466
3789
|
throw error;
|
|
2467
3790
|
}
|
|
2468
|
-
|
|
2469
|
-
|
|
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
|
|
2472
|
-
const
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
if (
|
|
2477
|
-
|
|
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
|
|
2480
|
-
const
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
const
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
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
|
-
|
|
2509
|
-
|
|
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
|
|
2515
|
-
const
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
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
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2553
|
-
|
|
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
|
|
4390
|
+
let recoveryId = `${stamp}-${cleanId}`;
|
|
4391
|
+
let trashDir = join(trashRoot, recoveryId);
|
|
2878
4392
|
let suffix = 2;
|
|
2879
|
-
while (existsSync(trashDir))
|
|
2880
|
-
|
|
2881
|
-
|
|
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
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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+"([^"]+)"/)
|
|
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
|
-
|
|
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 };
|