@duckcodeailabs/dql-cli 1.13.1 → 1.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apps-api.d.ts +249 -7
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +2855 -160
- package/dist/apps-api.js.map +1 -1
- package/dist/assets/dql-notebook/assets/{AgentLogPage-D6fgEAEy.js → AgentLogPage-B3ugEb3H.js} +1 -1
- package/dist/assets/dql-notebook/assets/{AiBuildDialog-8miXFPse.js → AiBuildDialog-BK_TDNcM.js} +1 -1
- package/dist/assets/dql-notebook/assets/{AiBuildResult-DPJ_SI0R.js → AiBuildResult-Cob_Lj05.js} +8 -8
- package/dist/assets/dql-notebook/assets/{AiSidePanel-NVA6lZnn.js → AiSidePanel-CzIgxmP9.js} +1 -1
- package/dist/assets/dql-notebook/assets/AnalyticsHome-C2vJ2fxY.js +6 -0
- package/dist/assets/dql-notebook/assets/AppsView-DEWQ0sHT.js +5588 -0
- package/dist/assets/dql-notebook/assets/BlockStudio-CaDNTKMM.js +55 -0
- package/dist/assets/dql-notebook/assets/{BusinessArtifactView-DtRMiaF4.js → BusinessArtifactView-zwUOtddi.js} +1 -1
- package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-BlnhTMu3.js +22 -0
- package/dist/assets/dql-notebook/assets/{GitPage-CazC-eTR.js → GitPage-Coevr9NJ.js} +3 -3
- package/dist/assets/dql-notebook/assets/GlobalAiRail-BTZS11x5.js +1 -0
- package/dist/assets/dql-notebook/assets/GovernedContextPage-CjyyM_ne.js +7 -0
- package/dist/assets/dql-notebook/assets/{HelpDocsPage-DMHPsVqP.js → HelpDocsPage-C1UeP85v.js} +1 -1
- package/dist/assets/dql-notebook/assets/{HomePage-DcSI4dnU.js → HomePage-zGPFIvOY.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDAG-BJ6xz06u.js → LineageDAG-DbXnQWu3.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDetailView-8WrQm-MF.js → LineageDetailView-Ch1Db01I.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDrawer-hnqCW_t-.js → LineageDrawer-DLxqJO6D.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-B4LZTUsl.js → LineagePathBreadcrumb-B2tNdkr_.js} +3 -3
- package/dist/assets/dql-notebook/assets/{MiniLineageGraph-CotnRuxa.js → MiniLineageGraph-0gY-ZwCy.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NewBlockModal-DHn-CEEW.js → NewBlockModal-DKVkXD20.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NewNotebookModal-BPGp-8oq.js → NewNotebookModal-BUIByvYT.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NotebookEditor-B0OTHM6W.js → NotebookEditor-D2fIUxjE.js} +5 -5
- package/dist/assets/dql-notebook/assets/{ReadinessPage-CoOIpxWq.js → ReadinessPage-Ci4LyNiQ.js} +1 -1
- package/dist/assets/dql-notebook/assets/{SetupOnboarding-DxK_mFzf.js → SetupOnboarding-BF7ai_0t.js} +1 -1
- package/dist/assets/dql-notebook/assets/SkillsPage-BgYrgirJ.js +10 -0
- package/dist/assets/dql-notebook/assets/{TrustBadge-ZuD5N5Ww.js → TrustBadge-Bob1Laiq.js} +1 -1
- package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-IxDUKSG8.js +88 -0
- package/dist/assets/dql-notebook/assets/{answer-to-notebook-WAVAYjBv.js → answer-to-notebook-Dk42N0pU.js} +1 -1
- package/dist/assets/dql-notebook/assets/{arrow-left-L8odLbyN.js → arrow-left-DKuuTw9l.js} +1 -1
- package/dist/assets/dql-notebook/assets/{arrow-right-WIF1V9a9.js → arrow-right-TH8_ufKG.js} +1 -1
- package/dist/assets/dql-notebook/assets/{book-open-text-BpqWMa4m.js → book-open-text-B-dcHHn0.js} +1 -1
- package/dist/assets/dql-notebook/assets/{circle-x-CQBOM0b0.js → circle-x-BOc2KCZQ.js} +1 -1
- package/dist/assets/dql-notebook/assets/{dagre.esm-DlZi7NR3.js → dagre.esm-CPT_vn5p.js} +1 -1
- package/dist/assets/dql-notebook/assets/{external-link-DYqdQIGp.js → external-link-DtOfpYc6.js} +1 -1
- package/dist/assets/dql-notebook/assets/{grip-vertical-HmUijbwN.js → grip-vertical-B8joDDH9.js} +1 -1
- package/dist/assets/dql-notebook/assets/index-B3shyZsg.css +1 -0
- package/dist/assets/dql-notebook/assets/index-DJRMz64S.js +635 -0
- package/dist/assets/dql-notebook/assets/link-2-DMZPHawg.js +6 -0
- package/dist/assets/dql-notebook/assets/{list-tree-DGKqCxmz.js → list-tree-Dw0qlxPK.js} +1 -1
- package/dist/assets/dql-notebook/assets/{minimize-2-C8dvqCxX.js → minimize-2-DUiSrPS3.js} +1 -1
- package/dist/assets/dql-notebook/assets/{panel-right-open-yuVkIo5n.js → panel-right-open-BrrFrRZQ.js} +1 -1
- package/dist/assets/dql-notebook/assets/{bot-CLR1V6dO.js → play-Ced0HT9g.js} +7 -2
- package/dist/assets/dql-notebook/assets/{rotate-ccw-Di0kRnrM.js → rotate-ccw-D_9U9o7X.js} +1 -1
- package/dist/assets/dql-notebook/assets/{semantic-fields-BMQV0li8.js → semantic-fields-BlDOaonV.js} +1 -1
- package/dist/assets/dql-notebook/assets/{sliders-horizontal-alebptlJ.js → sliders-horizontal-BDt6KRGR.js} +1 -1
- package/dist/assets/dql-notebook/assets/{star-CgCQbnXc.js → star-CxUbF_BW.js} +1 -1
- package/dist/assets/dql-notebook/assets/{triangle-alert-SyuSZ4ht.js → triangle-alert-COWnsSdh.js} +1 -1
- package/dist/assets/dql-notebook/assets/upload-DSFC2kJX.js +6 -0
- package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-DYdx896E.js +32 -0
- package/dist/assets/dql-notebook/assets/{user-round-J4PQHxo9.js → user-round-Bfu7fosn.js} +1 -1
- package/dist/assets/dql-notebook/assets/{wand-sparkles-c4WDXTCk.js → wand-sparkles-DSpG0c0j.js} +1 -1
- package/dist/assets/dql-notebook/assets/{workflow-NtRLaq7n.js → workflow-B1_pj9gq.js} +1 -1
- package/dist/assets/dql-notebook/assets/{wrench-CS1f1Zv4.js → wrench-CvfM03KZ.js} +1 -1
- package/dist/assets/dql-notebook/assets/{x-CbKEfe5m.js → x-B4coLiOl.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 +112 -19
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +2026 -694
- 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/BlockStudio--jNw9XUI.js +0 -60
- 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-Cpx47jCH.js +0 -634
- 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, appBuildDraftHash, 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,342 @@ 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
|
+
const drafts = storage.listAppBuildDrafts().flatMap((stored) => {
|
|
44
|
+
const migrated = migrateLegacyAppBuildDraft(projectRoot, stored);
|
|
45
|
+
if (!migrated)
|
|
46
|
+
return [];
|
|
47
|
+
if (stored.version !== migrated.version)
|
|
48
|
+
storage.saveAppBuildDraft(migrated);
|
|
49
|
+
return [migrated];
|
|
50
|
+
});
|
|
51
|
+
sendJson(res, 200, { ok: true, drafts });
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
storage.close();
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
const sourceCandidatesMatch = path.match(/^\/api\/app-builds\/([^/]+)\/source-candidates$/);
|
|
59
|
+
if (sourceCandidatesMatch && req.method === 'GET') {
|
|
60
|
+
try {
|
|
61
|
+
const id = decodeURIComponent(sourceCandidatesMatch[1]);
|
|
62
|
+
const current = loadStoredAppBuildDraft(projectRoot, id);
|
|
63
|
+
if (!current) {
|
|
64
|
+
sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
const { ensureAgentProjectReady, queryAppSourceCatalog } = await import('@duckcodeailabs/dql-agent');
|
|
68
|
+
await ensureAgentProjectReady(projectRoot);
|
|
69
|
+
const lifecycles = splitQueryValues(ctx.url.searchParams.getAll('lifecycle'));
|
|
70
|
+
const domains = splitQueryValues(ctx.url.searchParams.getAll('domain'));
|
|
71
|
+
const page = queryAppSourceCatalog(projectRoot, {
|
|
72
|
+
query: ctx.url.searchParams.get('q') ?? undefined,
|
|
73
|
+
cursor: ctx.url.searchParams.get('cursor') ?? undefined,
|
|
74
|
+
limit: Number(ctx.url.searchParams.get('limit')) || 50,
|
|
75
|
+
lifecycles: lifecycles.length ? lifecycles : undefined,
|
|
76
|
+
domains: domains.length ? domains : undefined,
|
|
77
|
+
sourcePolicy: current.sourcePolicy,
|
|
78
|
+
includeDeprecated: ctx.url.searchParams.get('includeDeprecated') === 'true',
|
|
79
|
+
});
|
|
80
|
+
sendJson(res, 200, { ok: true, ...page });
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
const error = err;
|
|
84
|
+
sendJson(res, error.code?.includes('CURSOR') ? 409 : 400, { ok: false, code: error.code, error: error.message });
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (sourceCandidatesMatch && req.method === 'POST') {
|
|
89
|
+
try {
|
|
90
|
+
const id = decodeURIComponent(sourceCandidatesMatch[1]);
|
|
91
|
+
const current = loadStoredAppBuildDraft(projectRoot, id);
|
|
92
|
+
if (!current) {
|
|
93
|
+
sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
const body = await readJson(req);
|
|
97
|
+
const { ensureAgentProjectReady, resolveAppSourceCatalogRecords } = await import('@duckcodeailabs/dql-agent');
|
|
98
|
+
await ensureAgentProjectReady(projectRoot);
|
|
99
|
+
const result = resolveAppSourceCatalogRecords(projectRoot, body.sourceIds ?? [], current.sourcePolicy);
|
|
100
|
+
sendJson(res, 200, { ok: true, ...result });
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
const error = err;
|
|
104
|
+
sendJson(res, 400, { ok: false, code: error.code, error: error.message });
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
let buildMatch = path.match(/^\/api\/app-builds\/([^/]+)$/);
|
|
109
|
+
if (buildMatch && req.method === 'GET') {
|
|
110
|
+
const draft = loadStoredAppBuildDraft(projectRoot, decodeURIComponent(buildMatch[1]));
|
|
111
|
+
sendJson(res, draft ? 200 : 404, draft ? { ok: true, draft } : { ok: false, error: 'App Build Draft not found.' });
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
if (buildMatch && req.method === 'PATCH') {
|
|
115
|
+
try {
|
|
116
|
+
const id = decodeURIComponent(buildMatch[1]);
|
|
117
|
+
const current = loadStoredAppBuildDraft(projectRoot, id);
|
|
118
|
+
if (!current) {
|
|
119
|
+
sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
const body = await readJson(req);
|
|
123
|
+
if (body.expectedProposalHash && body.expectedProposalHash !== current.proposalHash) {
|
|
124
|
+
sendJson(res, 409, { ok: false, error: 'APP_BUILD_PROPOSAL_CONFLICT: refresh the local draft before applying changes.' });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
const next = applyAppBuildDraftOperations(current, body.expectedRevision ?? -1, body.operations ?? []);
|
|
128
|
+
writeStoredAppBuildDraft(projectRoot, next, {
|
|
129
|
+
expectedRevision: current.revision,
|
|
130
|
+
operations: body.operations ?? [],
|
|
131
|
+
});
|
|
132
|
+
sendJson(res, 200, { ok: true, draft: next });
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
136
|
+
sendJson(res, message.includes('APP_BUILD_REVISION_CONFLICT') ? 409 : 400, { ok: false, error: message });
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (buildMatch && req.method === 'DELETE') {
|
|
141
|
+
try {
|
|
142
|
+
const id = decodeURIComponent(buildMatch[1]);
|
|
143
|
+
const current = loadStoredAppBuildDraft(projectRoot, id);
|
|
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 !== current.revision || body.proposalHash !== current.proposalHash) {
|
|
150
|
+
sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh before deleting this local draft.' });
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
sendJson(res, 200, deleteStoredAppBuildDraft(projectRoot, current));
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
const askResultMatch = path.match(/^\/api\/app-builds\/([^/]+)\/ask-results$/);
|
|
161
|
+
if (askResultMatch && req.method === 'POST') {
|
|
162
|
+
try {
|
|
163
|
+
const id = decodeURIComponent(askResultMatch[1]);
|
|
164
|
+
const body = await readJson(req);
|
|
165
|
+
const result = addAskResultToAppBuildDraft(projectRoot, id, body);
|
|
166
|
+
sendJson(res, result.deduped ? 200 : 201, result);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
170
|
+
sendJson(res, message.includes('APP_BUILD_') ? 409 : 400, { ok: false, error: message });
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const draftAiProposalMatch = path.match(/^\/api\/app-builds\/([^/]+)\/ai-proposals$/);
|
|
175
|
+
if (draftAiProposalMatch && req.method === 'POST') {
|
|
176
|
+
try {
|
|
177
|
+
const id = decodeURIComponent(draftAiProposalMatch[1]);
|
|
178
|
+
const current = loadStoredAppBuildDraft(projectRoot, id);
|
|
179
|
+
if (!current) {
|
|
180
|
+
sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
const body = await readJson(req);
|
|
184
|
+
if (body.expectedRevision !== current.revision || body.proposalHash !== current.proposalHash) {
|
|
185
|
+
sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh before asking AI to change this draft.' });
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
const proposal = await proposeAppBuildDraftOperations(projectRoot, current, {
|
|
189
|
+
prompt: cleanString(body.prompt) || current.frame.goal,
|
|
190
|
+
selectedBlockIds: body.selectedBlockIds,
|
|
191
|
+
}, { generateGovernedAnswer: ctx.generateGovernedAnswer, planAppBuild: ctx.planAppBuild });
|
|
192
|
+
writeAppBuildDraftAiProposal(projectRoot, proposal);
|
|
193
|
+
sendJson(res, 201, { ok: true, proposal });
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
const composeBuildMatch = path.match(/^\/api\/app-builds\/([^/]+)\/compose$/);
|
|
201
|
+
if (composeBuildMatch && req.method === 'POST') {
|
|
202
|
+
try {
|
|
203
|
+
const id = decodeURIComponent(composeBuildMatch[1]);
|
|
204
|
+
const current = loadStoredAppBuildDraft(projectRoot, id);
|
|
205
|
+
if (!current) {
|
|
206
|
+
sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
const body = await readJson(req);
|
|
210
|
+
if (body.expectedRevision !== current.revision || body.expectedProposalHash !== current.proposalHash) {
|
|
211
|
+
sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh before composing this App.' });
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
const result = await composeStoredAppBuildDraft(projectRoot, current, body);
|
|
215
|
+
sendJson(res, 200, { ok: true, ...result });
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
219
|
+
sendJson(res, message.includes('APP_BUILD_') ? 409 : 400, { ok: false, error: message });
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
const reviseProposalMatch = path.match(/^\/api\/app-builds\/([^/]+)\/ai-proposals\/([^/]+)\/revisions$/);
|
|
224
|
+
if (reviseProposalMatch && req.method === 'POST') {
|
|
225
|
+
try {
|
|
226
|
+
const id = decodeURIComponent(reviseProposalMatch[1]);
|
|
227
|
+
const proposalId = decodeURIComponent(reviseProposalMatch[2]);
|
|
228
|
+
const current = loadStoredAppBuildDraft(projectRoot, id);
|
|
229
|
+
const previous = current ? loadAppBuildDraftAiProposal(projectRoot, id, proposalId) : null;
|
|
230
|
+
if (!current || !previous) {
|
|
231
|
+
sendJson(res, 404, { ok: false, error: 'App Build proposal not found.' });
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
const body = await readJson(req);
|
|
235
|
+
if (body.expectedRevision !== current.revision || body.expectedProposalHash !== current.proposalHash
|
|
236
|
+
|| previous.baseRevision !== current.revision || previous.baseProposalHash !== current.proposalHash) {
|
|
237
|
+
sendJson(res, 409, { ok: false, error: 'APP_BUILD_PROPOSAL_CONFLICT: refresh before revising this proposal.' });
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
const answers = body.answers ?? {};
|
|
241
|
+
const answerText = previous.clarifications.flatMap((question) => {
|
|
242
|
+
const answerId = answers[question.id];
|
|
243
|
+
const choice = question.choices.find((candidate) => candidate.id === answerId);
|
|
244
|
+
return choice ? [`${question.question}: ${choice.label}`] : [];
|
|
245
|
+
});
|
|
246
|
+
const selectedSourceIds = unique([
|
|
247
|
+
...(body.selectedSourceIds ?? previous.defaultSelectedSourceIds ?? []),
|
|
248
|
+
...(body.additionalSourceIds ?? []),
|
|
249
|
+
].map(cleanString).filter(Boolean));
|
|
250
|
+
const proposal = await proposeAppBuildDraftOperations(projectRoot, current, {
|
|
251
|
+
prompt: [current.frame.goal, answerText.length ? `Clarification decisions: ${answerText.join('; ')}` : ''].filter(Boolean).join('\n'),
|
|
252
|
+
selectedBlockIds: selectedSourceIds,
|
|
253
|
+
}, { generateGovernedAnswer: ctx.generateGovernedAnswer, planAppBuild: ctx.planAppBuild });
|
|
254
|
+
proposal.defaultSelectedSourceIds = selectedSourceIds.filter((sourceId) => proposal.operations.some((operation) => (operation.type === 'upsert_source' && operation.source.id === sourceId)));
|
|
255
|
+
writeAppBuildDraftAiProposal(projectRoot, proposal);
|
|
256
|
+
sendJson(res, 201, { ok: true, proposal });
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
260
|
+
}
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
const generateGapMatch = path.match(/^\/api\/app-builds\/([^/]+)\/ai-proposals\/([^/]+)\/gaps$/);
|
|
264
|
+
if (generateGapMatch && req.method === 'POST') {
|
|
265
|
+
try {
|
|
266
|
+
const id = decodeURIComponent(generateGapMatch[1]);
|
|
267
|
+
const proposalId = decodeURIComponent(generateGapMatch[2]);
|
|
268
|
+
const current = loadStoredAppBuildDraft(projectRoot, id);
|
|
269
|
+
const previous = current ? loadAppBuildDraftAiProposal(projectRoot, id, proposalId) : null;
|
|
270
|
+
if (!current || !previous) {
|
|
271
|
+
sendJson(res, 404, { ok: false, error: 'App Build proposal not found.' });
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
const body = await readJson(req);
|
|
275
|
+
if (body.expectedRevision !== current.revision || body.expectedProposalHash !== current.proposalHash
|
|
276
|
+
|| previous.baseRevision !== current.revision || previous.baseProposalHash !== current.proposalHash) {
|
|
277
|
+
sendJson(res, 409, { ok: false, error: 'APP_BUILD_PROPOSAL_CONFLICT: refresh before generating a gap.' });
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
if (current.sourcePolicy !== 'include_review_required') {
|
|
281
|
+
sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVIEW_POLICY_REQUIRED: enable review-required sources before generating a gap.' });
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
if (!ctx.generateGovernedAnswer)
|
|
285
|
+
throw new Error('The configured provider cannot generate an App gap in this runtime.');
|
|
286
|
+
const proposal = await generateExplicitAppBuildGap(projectRoot, current, previous, cleanString(body.requirementId), ctx.generateGovernedAnswer);
|
|
287
|
+
writeAppBuildDraftAiProposal(projectRoot, proposal);
|
|
288
|
+
sendJson(res, 201, { ok: true, proposal });
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
buildMatch = path.match(/^\/api\/app-builds\/([^/]+)\/preflight$/);
|
|
296
|
+
if (buildMatch && req.method === 'POST') {
|
|
297
|
+
const current = loadStoredAppBuildDraft(projectRoot, decodeURIComponent(buildMatch[1]));
|
|
298
|
+
if (!current) {
|
|
299
|
+
sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
const body = await readJson(req);
|
|
303
|
+
if ((body.expectedRevision !== undefined && body.expectedRevision !== current.revision)
|
|
304
|
+
|| (body.proposalHash && body.proposalHash !== current.proposalHash)) {
|
|
305
|
+
sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh before preflight.' });
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
const errors = preflightAppBuildRequestErrors(projectRoot, current, ctx.verifyAppBuildPreview);
|
|
309
|
+
if (errors.length) {
|
|
310
|
+
sendJson(res, 400, {
|
|
311
|
+
ok: false,
|
|
312
|
+
code: 'APP_BUILD_NOT_READY',
|
|
313
|
+
message: 'This App is not ready to publish yet.',
|
|
314
|
+
draft: current,
|
|
315
|
+
errors,
|
|
316
|
+
details: { draft: current, errors },
|
|
317
|
+
});
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
const next = markStoredAppBuildDraftPreflighted(projectRoot, current);
|
|
321
|
+
sendJson(res, 200, { ok: true, draft: next, errors: [] });
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
buildMatch = path.match(/^\/api\/app-builds\/([^/]+)\/(publish-to-project|commit)$/);
|
|
325
|
+
if (buildMatch && req.method === 'POST') {
|
|
326
|
+
try {
|
|
327
|
+
const current = loadStoredAppBuildDraft(projectRoot, decodeURIComponent(buildMatch[1]));
|
|
328
|
+
if (!current) {
|
|
329
|
+
sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
const body = await readJson(req);
|
|
333
|
+
if (body.expectedRevision !== current.revision || body.proposalHash !== current.proposalHash) {
|
|
334
|
+
sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh the Build Frame before committing.' });
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
const errors = preflightAppBuildRequestErrors(projectRoot, current, ctx.verifyAppBuildPreview);
|
|
338
|
+
if (errors.length) {
|
|
339
|
+
sendJson(res, 400, {
|
|
340
|
+
ok: false,
|
|
341
|
+
code: 'APP_BUILD_NOT_READY',
|
|
342
|
+
message: 'This App is not ready to publish yet.',
|
|
343
|
+
draft: current,
|
|
344
|
+
errors,
|
|
345
|
+
details: { draft: current, errors },
|
|
346
|
+
});
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
// The final confirmation consumes the exact receipt the user reviewed.
|
|
350
|
+
// Compatibility callers that arrive without one are preflighted once in
|
|
351
|
+
// this same request so they cannot enter a client-side recheck loop.
|
|
352
|
+
const prepared = hasCurrentAppBuildPreflightReceipt(current)
|
|
353
|
+
? current
|
|
354
|
+
: markStoredAppBuildDraftPreflighted(projectRoot, current);
|
|
355
|
+
const result = publishStoredAppBuildDraft(projectRoot, prepared);
|
|
356
|
+
await refreshGeneratedMetadata(projectRoot);
|
|
357
|
+
sendJson(res, 201, { ...result, compatibilityRoute: buildMatch[2] === 'commit' });
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
361
|
+
sendJson(res, message.includes('already exists') ? 409 : 400, { ok: false, error: message });
|
|
362
|
+
}
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
29
365
|
if (req.method === 'GET' && path === '/api/apps') {
|
|
30
366
|
const apps = collectAppsList(projectRoot);
|
|
31
367
|
sendJson(res, 200, { apps });
|
|
@@ -34,7 +370,16 @@ export async function handleAppsApi(ctx) {
|
|
|
34
370
|
if (req.method === 'POST' && path === '/api/apps/recommend-blocks') {
|
|
35
371
|
try {
|
|
36
372
|
const body = await readJson(req);
|
|
37
|
-
|
|
373
|
+
const { ensureAgentProjectReady, queryAppSourceCatalog } = await import('@duckcodeailabs/dql-agent');
|
|
374
|
+
await ensureAgentProjectReady(projectRoot);
|
|
375
|
+
const page = queryAppSourceCatalog(projectRoot, {
|
|
376
|
+
query: [body.purpose, body.audience, ...(body.tags ?? [])].filter(Boolean).join(' '),
|
|
377
|
+
domains: body.domain && body.domain !== 'general' ? [body.domain] : undefined,
|
|
378
|
+
lifecycles: body.certifiedOnly ? ['certified'] : undefined,
|
|
379
|
+
sourcePolicy: body.certifiedOnly ? 'governed_only' : 'include_review_required',
|
|
380
|
+
limit: 50,
|
|
381
|
+
});
|
|
382
|
+
sendJson(res, 200, { blocks: page.items.map(appSourceCatalogRecordToLegacyRecommendation) });
|
|
38
383
|
}
|
|
39
384
|
catch (err) {
|
|
40
385
|
sendJson(res, 500, { error: err.message });
|
|
@@ -156,7 +501,9 @@ export async function handleAppsApi(ctx) {
|
|
|
156
501
|
if (req.method === 'POST' && path === '/api/apps') {
|
|
157
502
|
try {
|
|
158
503
|
const body = await readJson(req);
|
|
159
|
-
|
|
504
|
+
// Compatibility adapter: the legacy manual endpoint now traverses the
|
|
505
|
+
// same versioned draft, preflight, and private commit service as Studio.
|
|
506
|
+
const result = createAppPackageViaBuildDraft(projectRoot, body);
|
|
160
507
|
if (!result.ok) {
|
|
161
508
|
sendJson(res, 400, { error: result.error });
|
|
162
509
|
return true;
|
|
@@ -204,6 +551,24 @@ export async function handleAppsApi(ctx) {
|
|
|
204
551
|
}
|
|
205
552
|
return true;
|
|
206
553
|
}
|
|
554
|
+
m = path.match(/^\/api\/apps\/([^/]+)\/publish-to-project$/);
|
|
555
|
+
if (m && req.method === 'POST') {
|
|
556
|
+
const appId = decodeURIComponent(m[1]);
|
|
557
|
+
try {
|
|
558
|
+
const body = await readJson(req);
|
|
559
|
+
const result = promoteAppForStakeholders(projectRoot, appId, body);
|
|
560
|
+
if (!result.ok) {
|
|
561
|
+
sendJson(res, 409, result);
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
await refreshGeneratedMetadata(projectRoot);
|
|
565
|
+
sendJson(res, 200, result);
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
569
|
+
}
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
207
572
|
m = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/approve-semantic$/);
|
|
208
573
|
if (m && req.method === 'POST') {
|
|
209
574
|
const appId = decodeURIComponent(m[1]);
|
|
@@ -714,7 +1079,8 @@ export async function handleAppsApi(ctx) {
|
|
|
714
1079
|
if (m && req.method === 'DELETE') {
|
|
715
1080
|
const id = decodeURIComponent(m[1]);
|
|
716
1081
|
try {
|
|
717
|
-
const
|
|
1082
|
+
const body = await readJson(req);
|
|
1083
|
+
const result = deleteAppPackage(projectRoot, id, body);
|
|
718
1084
|
if (!result.ok) {
|
|
719
1085
|
sendJson(res, result.status, { ok: false, error: result.error });
|
|
720
1086
|
return true;
|
|
@@ -727,8 +1093,25 @@ export async function handleAppsApi(ctx) {
|
|
|
727
1093
|
}
|
|
728
1094
|
return true;
|
|
729
1095
|
}
|
|
1096
|
+
if (m && req.method === 'PATCH') {
|
|
1097
|
+
const id = decodeURIComponent(m[1]);
|
|
1098
|
+
try {
|
|
1099
|
+
const body = await readJson(req);
|
|
1100
|
+
const result = renameApp(projectRoot, id, body);
|
|
1101
|
+
if (!result.ok) {
|
|
1102
|
+
sendJson(res, result.code === 'APP_NOT_FOUND' ? 404 : result.code === 'APP_CHANGED' ? 409 : 400, result);
|
|
1103
|
+
return true;
|
|
1104
|
+
}
|
|
1105
|
+
ctx.scheduleProjectRefresh?.('app-renamed');
|
|
1106
|
+
sendJson(res, 200, result);
|
|
1107
|
+
}
|
|
1108
|
+
catch (err) {
|
|
1109
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
1110
|
+
}
|
|
1111
|
+
return true;
|
|
1112
|
+
}
|
|
730
1113
|
if (m && req.method === 'GET') {
|
|
731
|
-
const id = m[1];
|
|
1114
|
+
const id = decodeURIComponent(m[1]);
|
|
732
1115
|
const result = loadAppById(projectRoot, id);
|
|
733
1116
|
if (!result) {
|
|
734
1117
|
sendJson(res, 404, { error: `App "${id}" not found` });
|
|
@@ -737,6 +1120,22 @@ export async function handleAppsApi(ctx) {
|
|
|
737
1120
|
sendJson(res, 200, result);
|
|
738
1121
|
return true;
|
|
739
1122
|
}
|
|
1123
|
+
m = path.match(/^\/api\/app-recoveries\/([^/]+)\/restore$/);
|
|
1124
|
+
if (m && req.method === 'POST') {
|
|
1125
|
+
try {
|
|
1126
|
+
const result = restoreAppRecovery(projectRoot, decodeURIComponent(m[1]));
|
|
1127
|
+
if (!result.ok) {
|
|
1128
|
+
sendJson(res, result.status, result);
|
|
1129
|
+
return true;
|
|
1130
|
+
}
|
|
1131
|
+
ctx.scheduleProjectRefresh?.('app-restored');
|
|
1132
|
+
sendJson(res, 200, result);
|
|
1133
|
+
}
|
|
1134
|
+
catch (err) {
|
|
1135
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
1136
|
+
}
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
740
1139
|
// /api/apps/:id/dashboards
|
|
741
1140
|
m = path.match(/^\/api\/apps\/([^/]+)\/dashboards$/);
|
|
742
1141
|
if (m && req.method === 'GET') {
|
|
@@ -830,6 +1229,7 @@ function collectAppsList(projectRoot) {
|
|
|
830
1229
|
out.push({
|
|
831
1230
|
id: document.id,
|
|
832
1231
|
name: document.name,
|
|
1232
|
+
fingerprint: fingerprintAppDirectory(appDir),
|
|
833
1233
|
filePath: relative(projectRoot, appDir),
|
|
834
1234
|
domain: document.domain,
|
|
835
1235
|
ownerDomain: document.ownerDomain,
|
|
@@ -1242,13 +1642,51 @@ function buildAppProposal(plan) {
|
|
|
1242
1642
|
seenGap.add(question.toLowerCase());
|
|
1243
1643
|
gaps.push({ id: `gap_${gaps.length + 1}`, question, reason: 'No certified block covers this question.' });
|
|
1244
1644
|
}
|
|
1645
|
+
const metrics = Array.from(new Set(plan.requirements.flatMap((requirement) => requirement.measures)));
|
|
1646
|
+
const dimensions = Array.from(new Set(plan.requirements.flatMap((requirement) => requirement.dimensions)));
|
|
1647
|
+
const filters = Array.from(new Set(plan.requirements.flatMap((requirement) => requirement.filters)));
|
|
1648
|
+
const clarifications = [];
|
|
1649
|
+
if (metrics.length === 0) {
|
|
1650
|
+
clarifications.push({
|
|
1651
|
+
id: 'metric',
|
|
1652
|
+
question: 'Which governed business metric should lead this App?',
|
|
1653
|
+
choices: ['Choose a certified block', 'Choose a semantic metric', 'Describe the calculation as review-required analysis'],
|
|
1654
|
+
required: true,
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
if (tiles.length === 0 || gaps.length > 0) {
|
|
1658
|
+
clarifications.push({
|
|
1659
|
+
id: 'coverage',
|
|
1660
|
+
question: 'How should DQL handle requirements without governed coverage?',
|
|
1661
|
+
choices: ['Refine metrics or dimensions', 'Use governed semantic coverage', 'Include review-required analysis', 'Keep visible gaps'],
|
|
1662
|
+
required: tiles.length === 0,
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
if (!plan.prompt.match(/\b(day|week|month|quarter|year|today|yesterday|current|previous|over time)\b/i)) {
|
|
1666
|
+
clarifications.push({
|
|
1667
|
+
id: 'time',
|
|
1668
|
+
question: 'What time window and comparison should the App use?',
|
|
1669
|
+
choices: ['Current period vs previous period', 'Trailing 30 days', 'Year to date', 'No time comparison'],
|
|
1670
|
+
required: false,
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1245
1673
|
return {
|
|
1246
1674
|
intent: {
|
|
1247
1675
|
target: plan.publicationIntent,
|
|
1248
1676
|
initialVisibility: 'private',
|
|
1249
1677
|
},
|
|
1678
|
+
buildFrame: {
|
|
1679
|
+
goal: plan.businessGoal,
|
|
1680
|
+
audience: plan.audience,
|
|
1681
|
+
metrics,
|
|
1682
|
+
dimensions,
|
|
1683
|
+
grain: plan.requirements.find((requirement) => requirement.grain)?.grain,
|
|
1684
|
+
filters,
|
|
1685
|
+
desiredOutput: plan.planning.displayStrategy,
|
|
1686
|
+
},
|
|
1250
1687
|
tiles,
|
|
1251
1688
|
gaps,
|
|
1689
|
+
clarifications,
|
|
1252
1690
|
followUps: gaps.map((gap) => gap.question).slice(0, 4),
|
|
1253
1691
|
coverage: {
|
|
1254
1692
|
certifiedTiles: tiles.filter((tile) => tile.certification === 'certified').length,
|
|
@@ -1343,6 +1781,7 @@ async function fillProposalGaps(proposal, hooks, maxGeneratedTiles) {
|
|
|
1343
1781
|
certification: 'ai_generated',
|
|
1344
1782
|
sourceClass: 'exploratory_analysis',
|
|
1345
1783
|
reviewStatus: 'required',
|
|
1784
|
+
requirementIds: gap.id.startsWith('gap_') ? [gap.id.slice('gap_'.length)] : undefined,
|
|
1346
1785
|
preflight: {
|
|
1347
1786
|
status: preview ? 'passed' : unresolvedExecutionError ? 'blocked' : 'review_required',
|
|
1348
1787
|
sourceFingerprint: `sha256:${createHash('sha256').update(sql).digest('hex')}`,
|
|
@@ -1383,6 +1822,7 @@ async function fillProposalGaps(proposal, hooks, maxGeneratedTiles) {
|
|
|
1383
1822
|
certification: 'ai_generated',
|
|
1384
1823
|
sourceClass: 'exploratory_analysis',
|
|
1385
1824
|
reviewStatus: 'required',
|
|
1825
|
+
requirementIds: gap.id.startsWith('gap_') ? [gap.id.slice('gap_'.length)] : undefined,
|
|
1386
1826
|
preflight: {
|
|
1387
1827
|
status: 'blocked',
|
|
1388
1828
|
message: 'Exploratory analysis generation failed.',
|
|
@@ -1530,52 +1970,452 @@ export async function proposeAppAiBuild(projectRoot, input, hooks) {
|
|
|
1530
1970
|
kg.close();
|
|
1531
1971
|
}
|
|
1532
1972
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1973
|
+
function appDraftSourceFromCatalog(source) {
|
|
1974
|
+
const certified = source.lifecycle === 'certified';
|
|
1975
|
+
return {
|
|
1976
|
+
id: source.sourceId,
|
|
1977
|
+
kind: 'block',
|
|
1978
|
+
sourceRef: source.executionRef,
|
|
1979
|
+
qualifiedIdentity: source.qualifiedIdentity,
|
|
1980
|
+
sourcePath: source.sourcePath,
|
|
1981
|
+
executionRef: source.executionRef,
|
|
1982
|
+
snapshotId: source.snapshotId,
|
|
1983
|
+
sourceRevision: source.sourceRevision,
|
|
1984
|
+
sourceFingerprint: source.sourceRevision,
|
|
1985
|
+
lifecycle: source.lifecycle,
|
|
1986
|
+
capabilities: source.capabilities,
|
|
1987
|
+
trustState: certified ? 'certified' : 'review_required',
|
|
1988
|
+
reviewStatus: certified ? 'not_required' : 'required',
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
function appSourceCatalogRecordToLegacyRecommendation(source) {
|
|
1992
|
+
return {
|
|
1993
|
+
id: source.sourceId,
|
|
1994
|
+
name: source.name,
|
|
1995
|
+
domain: source.domain ?? 'uncategorized',
|
|
1996
|
+
status: source.lifecycle,
|
|
1997
|
+
owner: source.owner ?? null,
|
|
1998
|
+
tags: source.tags,
|
|
1999
|
+
path: source.executionRef,
|
|
2000
|
+
fingerprint: source.sourceRevision,
|
|
2001
|
+
lastModified: new Date(0).toISOString(),
|
|
2002
|
+
description: source.description ?? '',
|
|
2003
|
+
llmContext: null,
|
|
2004
|
+
chartType: source.capabilities.chartType,
|
|
2005
|
+
filterIds: source.capabilities.filters,
|
|
2006
|
+
dimensionIds: source.capabilities.dimensions,
|
|
2007
|
+
score: source.score ?? 0,
|
|
2008
|
+
reasons: source.reasons,
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
function uniqueAppSourceRecords(sources) {
|
|
2012
|
+
const seen = new Set();
|
|
2013
|
+
return sources.filter((source) => {
|
|
2014
|
+
if (seen.has(source.sourceId))
|
|
2015
|
+
return false;
|
|
2016
|
+
seen.add(source.sourceId);
|
|
2017
|
+
return true;
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
function uniqueTileId(existing, preferred) {
|
|
2021
|
+
const base = slugify(preferred) || 'component';
|
|
2022
|
+
const ids = new Set(existing.map((item) => item.i));
|
|
2023
|
+
if (!ids.has(base))
|
|
2024
|
+
return base;
|
|
2025
|
+
for (let index = 2; index < 10_000; index += 1) {
|
|
2026
|
+
const candidate = `${base}-${index}`;
|
|
2027
|
+
if (!ids.has(candidate))
|
|
2028
|
+
return candidate;
|
|
1558
2029
|
}
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
for
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
2030
|
+
return `${base}-${Date.now()}`;
|
|
2031
|
+
}
|
|
2032
|
+
const APP_REQUIREMENT_STOP_WORDS = new Set([
|
|
2033
|
+
'a', 'an', 'and', 'app', 'application', 'build', 'by', 'create', 'dashboard',
|
|
2034
|
+
'for', 'from', 'in', 'last', 'me', 'of', 'on', 'report', 'show', 'showing',
|
|
2035
|
+
'the', 'to', 'using', 'view', 'with',
|
|
2036
|
+
]);
|
|
2037
|
+
function appRequirementTokens(value) {
|
|
2038
|
+
return new Set(value.toLowerCase().split(/[^a-z0-9]+/)
|
|
2039
|
+
.map((token) => token.length > 3 && token.endsWith('s') ? token.slice(0, -1) : token)
|
|
2040
|
+
.filter((token) => token.length > 1 && !APP_REQUIREMENT_STOP_WORDS.has(token)));
|
|
2041
|
+
}
|
|
2042
|
+
function catalogSourceSatisfiesRequirement(source, requirement) {
|
|
2043
|
+
const normalized = (values) => new Set(values.flatMap((value) => Array.from(appRequirementTokens(value))));
|
|
2044
|
+
const sourceMeasures = normalized(source.capabilities.measures);
|
|
2045
|
+
const sourceDimensions = normalized(source.capabilities.dimensions);
|
|
2046
|
+
const sourceFilters = normalized(source.capabilities.filters);
|
|
2047
|
+
const requires = [
|
|
2048
|
+
[normalized(requirement.measures), sourceMeasures],
|
|
2049
|
+
[normalized(requirement.dimensions), sourceDimensions],
|
|
2050
|
+
[normalized(requirement.filters), sourceFilters],
|
|
2051
|
+
];
|
|
2052
|
+
const declaredCompatible = requires.every(([required, available]) => (required.size === 0 || (available.size > 0 && Array.from(required).every((field) => available.has(field)))));
|
|
2053
|
+
const hasDeclaredRequirement = requires.some(([required]) => required.size > 0);
|
|
2054
|
+
if (declaredCompatible && hasDeclaredRequirement)
|
|
2055
|
+
return true;
|
|
2056
|
+
const requirementTerms = appRequirementTokens(requirement.question);
|
|
2057
|
+
if (requirementTerms.size === 0)
|
|
2058
|
+
return false;
|
|
2059
|
+
const sourceTerms = appRequirementTokens([
|
|
2060
|
+
source.title,
|
|
2061
|
+
source.name,
|
|
2062
|
+
source.description ?? '',
|
|
2063
|
+
source.qualifiedIdentity,
|
|
2064
|
+
...source.tags,
|
|
2065
|
+
...source.capabilities.measures,
|
|
2066
|
+
...source.capabilities.dimensions,
|
|
2067
|
+
].join(' '));
|
|
2068
|
+
const overlap = Array.from(requirementTerms).filter((term) => sourceTerms.has(term)).length;
|
|
2069
|
+
return overlap >= Math.min(2, requirementTerms.size);
|
|
2070
|
+
}
|
|
2071
|
+
function splitQueryValues(values) {
|
|
2072
|
+
return unique(values.flatMap((value) => value.split(',')).map((value) => value.trim()).filter(Boolean));
|
|
2073
|
+
}
|
|
2074
|
+
function materializeAppBuildProposalReviewAnalyses(projectRoot, draft, plan, proposalId, candidates, inputDashboards) {
|
|
2075
|
+
const eligible = candidates.filter((candidate) => (candidate.source === 'ai_generated'
|
|
2076
|
+
&& Boolean(candidate.sql)
|
|
2077
|
+
&& !candidate.error
|
|
2078
|
+
&& candidate.preflight.status === 'passed'));
|
|
2079
|
+
if (eligible.length === 0 || inputDashboards.length === 0) {
|
|
2080
|
+
return { dashboards: inputDashboards, sourceOperations: [], reviewTasks: [], coverageByRequirement: new Map() };
|
|
2081
|
+
}
|
|
2082
|
+
const proposalKey = slugify(proposalId) || `proposal-${Date.now()}`;
|
|
2083
|
+
const relativeDraftDir = `proposals/${proposalKey}/drafts`;
|
|
2084
|
+
const absoluteDraftDir = join(projectRoot, '.dql', 'local', 'app-builds', draft.id, relativeDraftDir);
|
|
2085
|
+
mkdirSync(absoluteDraftDir, { recursive: true });
|
|
2086
|
+
const sourceOperations = [];
|
|
2087
|
+
const reviewTasks = [];
|
|
2088
|
+
const coverageByRequirement = new Map();
|
|
2089
|
+
const generatedItems = [];
|
|
2090
|
+
const normalizeQuestion = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
2091
|
+
for (const [index, candidate] of eligible.entries()) {
|
|
2092
|
+
const sql = candidate.sql;
|
|
2093
|
+
if (sql.includes('"""'))
|
|
2094
|
+
throw new Error(`Exploratory SQL for "${candidate.title}" contains an unsupported triple-quote delimiter.`);
|
|
2095
|
+
const candidateKey = slugify(candidate.id) || `analysis-${index + 1}`;
|
|
2096
|
+
const blockName = `app_${slugify(draft.appId)}_${candidateKey}`;
|
|
2097
|
+
const sourceRef = `${relativeDraftDir}/${candidateKey}.dql`;
|
|
1570
2098
|
const source = [
|
|
1571
|
-
`block "${escapeDqlString(
|
|
2099
|
+
`block "${escapeDqlString(blockName)}" {`,
|
|
1572
2100
|
` domain = "${escapeDqlString(plan.domain)}"`,
|
|
1573
2101
|
' type = "custom"',
|
|
1574
2102
|
' status = "review"',
|
|
1575
2103
|
` owner = "${escapeDqlString(plan.owner)}"`,
|
|
1576
|
-
` description = "${escapeDqlString((
|
|
1577
|
-
|
|
1578
|
-
|
|
2104
|
+
` description = "${escapeDqlString((candidate.answer ?? candidate.question ?? candidate.title).slice(0, 240))}"`,
|
|
2105
|
+
candidate.question ? ` llmContext = "${escapeDqlString(`App-scoped exploratory analysis. Question: ${candidate.question}`)}"` : '',
|
|
2106
|
+
' caveats = ["AI-generated App analysis. Validate joins, filters, grain, and business interpretation before promotion."]',
|
|
2107
|
+
' tags = ["app-scoped", "ai-generated", "needs-review"]',
|
|
2108
|
+
'',
|
|
2109
|
+
' query = """',
|
|
2110
|
+
sql,
|
|
2111
|
+
' """',
|
|
2112
|
+
'',
|
|
2113
|
+
' visualization {',
|
|
2114
|
+
` chart = "${escapeDqlString(normalizeGeneratedViz(candidate.viz))}"`,
|
|
2115
|
+
' }',
|
|
2116
|
+
'}',
|
|
2117
|
+
'',
|
|
2118
|
+
].filter(Boolean).join('\n');
|
|
2119
|
+
const fingerprint = `sha256:${createHash('sha256').update(source).digest('hex')}`;
|
|
2120
|
+
writeFileSync(join(absoluteDraftDir, `${candidateKey}.dql`), source, 'utf-8');
|
|
2121
|
+
const sourceId = `review-dql:${candidate.id}`;
|
|
2122
|
+
const componentId = `review_${candidateKey}`;
|
|
2123
|
+
sourceOperations.push({
|
|
2124
|
+
type: 'upsert_source',
|
|
2125
|
+
source: {
|
|
2126
|
+
id: sourceId,
|
|
2127
|
+
kind: 'review_dql',
|
|
2128
|
+
sourceRef,
|
|
2129
|
+
qualifiedIdentity: candidate.title,
|
|
2130
|
+
snapshotId: candidate.preflight.snapshotId ?? plan.snapshotId,
|
|
2131
|
+
sourceFingerprint: fingerprint,
|
|
2132
|
+
receiptId: candidate.preflight.receiptId,
|
|
2133
|
+
trustState: 'review_required',
|
|
2134
|
+
reviewStatus: 'required',
|
|
2135
|
+
},
|
|
2136
|
+
});
|
|
2137
|
+
reviewTasks.push({
|
|
2138
|
+
type: 'set_review_task',
|
|
2139
|
+
task: {
|
|
2140
|
+
id: `review-generated-${candidateKey}`,
|
|
2141
|
+
message: `Validate the joins, grain, filters, and business interpretation for ${candidate.title}.`,
|
|
2142
|
+
status: 'open',
|
|
2143
|
+
sourceId,
|
|
2144
|
+
pageId: inputDashboards[0].id,
|
|
2145
|
+
tileId: componentId,
|
|
2146
|
+
},
|
|
2147
|
+
});
|
|
2148
|
+
const requirementIds = candidate.requirementIds?.length
|
|
2149
|
+
? candidate.requirementIds
|
|
2150
|
+
: plan.requirements.filter((requirement) => normalizeQuestion(requirement.question) === normalizeQuestion(candidate.question ?? '')).map((requirement) => requirement.id);
|
|
2151
|
+
for (const requirementId of requirementIds) {
|
|
2152
|
+
const current = coverageByRequirement.get(requirementId) ?? { sourceIds: [], componentIds: [] };
|
|
2153
|
+
current.sourceIds.push(sourceId);
|
|
2154
|
+
current.componentIds.push(componentId);
|
|
2155
|
+
coverageByRequirement.set(requirementId, current);
|
|
2156
|
+
}
|
|
2157
|
+
generatedItems.push({
|
|
2158
|
+
i: componentId,
|
|
2159
|
+
x: (index % 2) * 6,
|
|
2160
|
+
y: Math.floor(index / 2) * 4,
|
|
2161
|
+
w: 6,
|
|
2162
|
+
h: 4,
|
|
2163
|
+
draftAnalysis: {
|
|
2164
|
+
ref: sourceRef,
|
|
2165
|
+
artifactFingerprint: fingerprint,
|
|
2166
|
+
...(candidate.preflight.snapshotId ?? plan.snapshotId ? { snapshotId: candidate.preflight.snapshotId ?? plan.snapshotId } : {}),
|
|
2167
|
+
...(candidate.preflight.receiptId ? { executionReceiptId: candidate.preflight.receiptId } : {}),
|
|
2168
|
+
},
|
|
2169
|
+
viz: { type: normalizeGeneratedViz(candidate.viz) },
|
|
2170
|
+
title: candidate.title,
|
|
2171
|
+
sourceClass: 'exploratory_analysis',
|
|
2172
|
+
sourceEvidence: [
|
|
2173
|
+
...(candidate.sourceEvidence ?? []),
|
|
2174
|
+
{
|
|
2175
|
+
source: sourceRef,
|
|
2176
|
+
reason: candidate.question
|
|
2177
|
+
? `AI generated this private analysis for: ${candidate.question}`
|
|
2178
|
+
: 'AI generated this private analysis for a requirement without governed coverage.',
|
|
2179
|
+
kind: 'review_dql',
|
|
2180
|
+
trustState: 'review_required',
|
|
2181
|
+
},
|
|
2182
|
+
],
|
|
2183
|
+
review: { status: 'required', sourceFingerprint: fingerprint, ...(candidate.preflight.receiptId ? { preflightReceiptId: candidate.preflight.receiptId } : {}) },
|
|
2184
|
+
trustState: 'review_required',
|
|
2185
|
+
reviewStatus: 'review_required',
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
const first = inputDashboards[0];
|
|
2189
|
+
const baseY = first.layout.items.reduce((max, item) => Math.max(max, item.y + item.h), 0);
|
|
2190
|
+
const items = [...first.layout.items, ...generatedItems.map((item) => ({ ...item, y: item.y + baseY }))];
|
|
2191
|
+
const sections = first.sections
|
|
2192
|
+
? first.sections.some((section) => section.kind === 'appendix')
|
|
2193
|
+
? first.sections
|
|
2194
|
+
: [...first.sections, { id: 'review-appendix', title: 'Review-required analysis', kind: 'appendix', order: first.sections.length }]
|
|
2195
|
+
: undefined;
|
|
2196
|
+
const appendixId = sections?.find((section) => section.kind === 'appendix')?.id;
|
|
2197
|
+
const finalItems = appendixId
|
|
2198
|
+
? items.map((item) => generatedItems.some((generated) => generated.i === item.i) ? { ...item, sectionId: appendixId } : item)
|
|
2199
|
+
: items;
|
|
2200
|
+
const page = {
|
|
2201
|
+
...first,
|
|
2202
|
+
...(sections ? { sections } : {}),
|
|
2203
|
+
layout: {
|
|
2204
|
+
...first.layout,
|
|
2205
|
+
items: finalItems,
|
|
2206
|
+
responsive: responsiveLayoutsForDashboardItems(finalItems),
|
|
2207
|
+
},
|
|
2208
|
+
};
|
|
2209
|
+
return {
|
|
2210
|
+
dashboards: [page, ...inputDashboards.slice(1)],
|
|
2211
|
+
sourceOperations,
|
|
2212
|
+
reviewTasks,
|
|
2213
|
+
coverageByRequirement,
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* AI is a coauthor of the same local AppBuildDraft used by manual authoring.
|
|
2218
|
+
* This adapter reuses the governed planner, compiles its result outside the
|
|
2219
|
+
* project, and returns typed operations. It does not mutate the draft or write
|
|
2220
|
+
* any canonical App source.
|
|
2221
|
+
*/
|
|
2222
|
+
export async function proposeAppBuildDraftOperations(projectRoot, draft, input, hooks) {
|
|
2223
|
+
const prompt = cleanString(input.prompt) || draft.frame.goal;
|
|
2224
|
+
const { ensureAgentProjectReady, planAppBuildBrief, resolveAppSourceCatalogRecords, shortlistAppSources, } = await import('@duckcodeailabs/dql-agent');
|
|
2225
|
+
await ensureAgentProjectReady(projectRoot);
|
|
2226
|
+
let candidates = shortlistAppSources(projectRoot, prompt, {
|
|
2227
|
+
sourcePolicy: draft.sourcePolicy,
|
|
2228
|
+
domains: draft.pages[0]?.metadata.domain && draft.pages[0].metadata.domain !== 'general'
|
|
2229
|
+
? [draft.pages[0].metadata.domain]
|
|
2230
|
+
: undefined,
|
|
2231
|
+
}).items;
|
|
2232
|
+
const requestedIds = unique((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean));
|
|
2233
|
+
if (requestedIds.length) {
|
|
2234
|
+
const exactIds = requestedIds.filter((id) => id.startsWith('app:block:'));
|
|
2235
|
+
const exact = exactIds.length
|
|
2236
|
+
? resolveAppSourceCatalogRecords(projectRoot, exactIds, draft.sourcePolicy).items
|
|
2237
|
+
: [];
|
|
2238
|
+
const requested = new Set(requestedIds);
|
|
2239
|
+
candidates = uniqueAppSourceRecords([
|
|
2240
|
+
...exact,
|
|
2241
|
+
...candidates.filter((candidate) => requested.has(candidate.sourceId)
|
|
2242
|
+
|| requested.has(candidate.name)
|
|
2243
|
+
|| requested.has(candidate.sourcePath)
|
|
2244
|
+
|| requested.has(candidate.qualifiedIdentity)),
|
|
2245
|
+
...candidates,
|
|
2246
|
+
]).slice(0, 12);
|
|
2247
|
+
}
|
|
2248
|
+
const brief = await planAppBuildBrief({
|
|
2249
|
+
prompt,
|
|
2250
|
+
candidates,
|
|
2251
|
+
sourcePolicy: draft.sourcePolicy,
|
|
2252
|
+
domain: draft.pages[0]?.metadata.domain,
|
|
2253
|
+
audience: draft.frame.audience,
|
|
2254
|
+
complete: hooks?.planAppBuild,
|
|
2255
|
+
});
|
|
2256
|
+
const byId = new Map(candidates.map((candidate) => [candidate.sourceId, candidate]));
|
|
2257
|
+
const plannedSources = brief.selectedSourceIds.flatMap((sourceId) => {
|
|
2258
|
+
const source = byId.get(sourceId);
|
|
2259
|
+
return source ? [source] : [];
|
|
2260
|
+
});
|
|
2261
|
+
const sourceOperations = plannedSources.map((source) => ({
|
|
2262
|
+
type: 'upsert_source',
|
|
2263
|
+
source: appDraftSourceFromCatalog(source),
|
|
2264
|
+
}));
|
|
2265
|
+
const page = draft.pages[0];
|
|
2266
|
+
if (!page)
|
|
2267
|
+
throw new Error('The App draft needs a page before AI can compose it.');
|
|
2268
|
+
const structuralItems = page.layout.items.filter((item) => Boolean(item.text));
|
|
2269
|
+
const baseY = structuralItems.reduce((max, item) => Math.max(max, item.y + item.h), 0);
|
|
2270
|
+
const reservedItems = [...structuralItems];
|
|
2271
|
+
const componentTileIdById = new Map();
|
|
2272
|
+
const plannedItems = brief.components.flatMap((component, index) => {
|
|
2273
|
+
const source = byId.get(component.sourceId);
|
|
2274
|
+
if (!source)
|
|
2275
|
+
return [];
|
|
2276
|
+
const certified = source.lifecycle === 'certified';
|
|
2277
|
+
const tileId = uniqueTileId(reservedItems, component.id || `component-${index + 1}`);
|
|
2278
|
+
componentTileIdById.set(component.id, tileId);
|
|
2279
|
+
const tile = {
|
|
2280
|
+
i: tileId,
|
|
2281
|
+
x: (index % 2) * 6,
|
|
2282
|
+
y: baseY + Math.floor(index / 2) * 4,
|
|
2283
|
+
w: component.view === 'kpi' ? 3 : 6,
|
|
2284
|
+
h: component.view === 'kpi' ? 2 : 4,
|
|
2285
|
+
sourceId: source.sourceId,
|
|
2286
|
+
sourceRevision: source.sourceRevision,
|
|
2287
|
+
block: { ref: source.executionRef },
|
|
2288
|
+
viz: { type: component.view },
|
|
2289
|
+
title: component.title,
|
|
2290
|
+
sourceClass: certified ? 'certified_block' : 'exploratory_analysis',
|
|
2291
|
+
filterBindings: source.capabilities.filters.map((filter) => ({
|
|
2292
|
+
filter,
|
|
2293
|
+
binding: filter,
|
|
2294
|
+
mode: 'predicate',
|
|
2295
|
+
capability: 'preflight_required',
|
|
2296
|
+
})),
|
|
2297
|
+
review: { status: certified ? 'not_required' : 'required', sourceFingerprint: source.sourceRevision },
|
|
2298
|
+
trustState: certified ? 'certified' : 'review_required',
|
|
2299
|
+
reviewStatus: certified ? 'certified' : 'review_required',
|
|
2300
|
+
};
|
|
2301
|
+
reservedItems.push(tile);
|
|
2302
|
+
return [tile];
|
|
2303
|
+
});
|
|
2304
|
+
const nextPage = {
|
|
2305
|
+
...page,
|
|
2306
|
+
metadata: { ...page.metadata, description: brief.frame.decision || brief.frame.goal },
|
|
2307
|
+
layout: {
|
|
2308
|
+
...page.layout,
|
|
2309
|
+
items: [...structuralItems, ...plannedItems],
|
|
2310
|
+
responsive: responsiveLayoutsForDashboardItems([...structuralItems, ...plannedItems]),
|
|
2311
|
+
},
|
|
2312
|
+
};
|
|
2313
|
+
const coverage = brief.requirements.map((requirement) => {
|
|
2314
|
+
const components = brief.components.filter((component) => component.requirementIds.includes(requirement.id));
|
|
2315
|
+
return {
|
|
2316
|
+
requirementId: requirement.id,
|
|
2317
|
+
status: components.length ? 'covered' : 'gap',
|
|
2318
|
+
sourceIds: Array.from(new Set(components.map((component) => component.sourceId))),
|
|
2319
|
+
componentIds: components.map((component) => componentTileIdById.get(component.id) ?? component.id),
|
|
2320
|
+
reasons: components.length
|
|
2321
|
+
? ['Covered by a source-bound App component from the validated Build Brief.']
|
|
2322
|
+
: ['No supplied source candidate covered this requirement.'],
|
|
2323
|
+
};
|
|
2324
|
+
});
|
|
2325
|
+
const clarifications = brief.frame.clarificationQuestions ?? [];
|
|
2326
|
+
const reviewTasks = plannedSources
|
|
2327
|
+
.filter((source) => source.lifecycle !== 'certified')
|
|
2328
|
+
.map((source) => ({
|
|
2329
|
+
type: 'set_review_task',
|
|
2330
|
+
task: {
|
|
2331
|
+
id: `review-source-${createHash('sha256').update(source.sourceId).digest('hex').slice(0, 12)}`,
|
|
2332
|
+
message: `${source.title} is ${source.lifecycle} and must be certified, replaced, or removed before Project publication.`,
|
|
2333
|
+
status: 'open',
|
|
2334
|
+
sourceId: source.sourceId,
|
|
2335
|
+
pageId: page.id,
|
|
2336
|
+
},
|
|
2337
|
+
}));
|
|
2338
|
+
const operations = [
|
|
2339
|
+
{
|
|
2340
|
+
type: 'set_frame',
|
|
2341
|
+
frame: brief.frame,
|
|
2342
|
+
},
|
|
2343
|
+
{
|
|
2344
|
+
type: 'set_requirements',
|
|
2345
|
+
requirements: brief.requirements,
|
|
2346
|
+
coverage,
|
|
2347
|
+
},
|
|
2348
|
+
{ type: 'upsert_page', page: nextPage },
|
|
2349
|
+
...sourceOperations,
|
|
2350
|
+
...reviewTasks,
|
|
2351
|
+
];
|
|
2352
|
+
return {
|
|
2353
|
+
id: `proposal_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
2354
|
+
draftId: draft.id,
|
|
2355
|
+
baseRevision: draft.revision,
|
|
2356
|
+
baseProposalHash: draft.proposalHash,
|
|
2357
|
+
operations,
|
|
2358
|
+
defaultSelectedSourceIds: plannedSources
|
|
2359
|
+
.filter((source) => source.eligibility.localPreview)
|
|
2360
|
+
.map((source) => source.sourceId),
|
|
2361
|
+
clarifications,
|
|
2362
|
+
candidateSourceIds: brief.candidateSourceIds,
|
|
2363
|
+
warnings: brief.warnings,
|
|
2364
|
+
summary: {
|
|
2365
|
+
requirements: brief.requirements.length,
|
|
2366
|
+
covered: coverage.filter((item) => item.status === 'covered').length,
|
|
2367
|
+
gaps: coverage.filter((item) => item.status === 'gap').length,
|
|
2368
|
+
certifiedSources: plannedSources.filter((source) => source.lifecycle === 'certified').length,
|
|
2369
|
+
semanticSources: 0,
|
|
2370
|
+
},
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
/**
|
|
2374
|
+
* Materialize explicitly selected exploratory candidates as Git-owned, app-scoped
|
|
2375
|
+
* review DQL. They are never promoted into the shared block library and the
|
|
2376
|
+
* dashboard stores a fingerprinted reference instead of inline SQL.
|
|
2377
|
+
*/
|
|
2378
|
+
function attachGeneratedDraftAnalyses(projectRoot, plan, generatedPaths, candidates) {
|
|
2379
|
+
const dashboardId = plan.pages[0]?.id ?? 'overview';
|
|
2380
|
+
const dashboardPath = generatedPaths.find((path) => path.endsWith(`${dashboardId}.dqld`))
|
|
2381
|
+
?? generatedPaths.find((path) => path.endsWith('.dqld'));
|
|
2382
|
+
if (!dashboardPath)
|
|
2383
|
+
return;
|
|
2384
|
+
const absolutePath = join(projectRoot, dashboardPath);
|
|
2385
|
+
if (!existsSync(absolutePath))
|
|
2386
|
+
return;
|
|
2387
|
+
const doc = JSON.parse(readFileSync(absolutePath, 'utf-8'));
|
|
2388
|
+
// Story layout: exploratory tiles live in the review appendix. Classic grids
|
|
2389
|
+
// (no sections) just append at the bottom, untagged.
|
|
2390
|
+
const storyMode = Array.isArray(doc.sections) && doc.sections.length > 0;
|
|
2391
|
+
if (storyMode && !doc.sections.some((section) => section.kind === 'appendix')) {
|
|
2392
|
+
doc.sections.push({
|
|
2393
|
+
id: 'appendix',
|
|
2394
|
+
title: 'AI-generated analysis — needs review',
|
|
2395
|
+
kind: 'appendix',
|
|
2396
|
+
order: doc.sections.length,
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
let y = doc.layout.items.reduce((max, item) => Math.max(max, item.y + item.h), 0);
|
|
2400
|
+
let x = 0;
|
|
2401
|
+
const draftsDir = join(projectRoot, 'apps', plan.appId, 'drafts');
|
|
2402
|
+
mkdirSync(draftsDir, { recursive: true });
|
|
2403
|
+
for (const tile of candidates) {
|
|
2404
|
+
if (!tile.sql)
|
|
2405
|
+
continue;
|
|
2406
|
+
if (tile.sql.includes('"""'))
|
|
2407
|
+
throw new Error(`Exploratory SQL for "${tile.title}" contains an unsupported triple-quote delimiter.`);
|
|
2408
|
+
const draftId = slugify(tile.title) || slugify(tile.id) || `analysis-${Date.now()}`;
|
|
2409
|
+
const draftPath = join(draftsDir, `${draftId}.dql`);
|
|
2410
|
+
const source = [
|
|
2411
|
+
`block "${escapeDqlString(draftId)}" {`,
|
|
2412
|
+
` domain = "${escapeDqlString(plan.domain)}"`,
|
|
2413
|
+
' type = "custom"',
|
|
2414
|
+
' status = "review"',
|
|
2415
|
+
` owner = "${escapeDqlString(plan.owner)}"`,
|
|
2416
|
+
` description = "${escapeDqlString((tile.answer ?? tile.question ?? tile.title).slice(0, 240))}"`,
|
|
2417
|
+
tile.question ? ` llmContext = "${escapeDqlString(`App-scoped exploratory analysis. Question: ${tile.question}`)}"` : '',
|
|
2418
|
+
tile.question ? ` examples = [{ question = "${escapeDqlString(tile.question)}" }]` : '',
|
|
1579
2419
|
' caveats = ["AI-generated app draft. Validate joins, filters, grain, and business interpretation before promotion or publication."]',
|
|
1580
2420
|
' tags = ["app-scoped", "ai-generated", "needs-review"]',
|
|
1581
2421
|
'',
|
|
@@ -1677,10 +2517,20 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
|
|
|
1677
2517
|
if (selected.size === 0) {
|
|
1678
2518
|
return { ok: false, error: 'Select at least one tile to create the app.', status: 400 };
|
|
1679
2519
|
}
|
|
2520
|
+
const plannedFilterIds = new Set([
|
|
2521
|
+
...(plan.globalFilters ?? []).map((filter) => filter.id),
|
|
2522
|
+
...plan.pages.flatMap((page) => (page.filters ?? []).map((filter) => filter.id)),
|
|
2523
|
+
]);
|
|
2524
|
+
const selectedFilterIds = new Set((input.filterIds ?? Array.from(plannedFilterIds)).filter((filterId) => plannedFilterIds.has(filterId)));
|
|
1680
2525
|
// Filter the plan's certified tiles down to the confirmed selection; structural
|
|
1681
2526
|
// tiles (narrative, draft placeholders) stay — they document the story + gaps.
|
|
1682
2527
|
const committedPlan = {
|
|
1683
2528
|
...plan,
|
|
2529
|
+
globalFilters: (plan.globalFilters ?? []).filter((filter) => selectedFilterIds.has(filter.id)),
|
|
2530
|
+
// `+ Add page` must not rewrite the App it is adding to (`UI-017`), but the
|
|
2531
|
+
// fields were dropped with no error, so a name typed in that brief silently
|
|
2532
|
+
// vanished. Adding a page keeps the App's own name; use `PATCH /api/apps/:id`
|
|
2533
|
+
// to rename, and see `appliedNameWarnings` below for the reported diagnostic.
|
|
1684
2534
|
...(!isExistingPageBuild && cleanString(input.appName) ? { name: cleanString(input.appName).slice(0, 120) } : {}),
|
|
1685
2535
|
...(!isExistingPageBuild && cleanString(input.audience) ? { audience: cleanString(input.audience).slice(0, 120) } : {}),
|
|
1686
2536
|
pages: plan.pages.map((page, pageIndex) => ({
|
|
@@ -1688,15 +2538,20 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
|
|
|
1688
2538
|
...(isExistingPageBuild && pageIndex === 0 && cleanString(input.pageTitle)
|
|
1689
2539
|
? { title: cleanString(input.pageTitle).slice(0, 120) }
|
|
1690
2540
|
: {}),
|
|
2541
|
+
filters: (page.filters ?? []).filter((filter) => selectedFilterIds.has(filter.id)),
|
|
1691
2542
|
tiles: page.tiles
|
|
1692
2543
|
.filter((tile) => !['certified_block', 'semantic_query'].includes(tile.kind) || selected.has(tile.id))
|
|
1693
2544
|
.map((tile) => {
|
|
1694
2545
|
const override = tileOverrides[tile.id];
|
|
2546
|
+
const filteredTile = {
|
|
2547
|
+
...tile,
|
|
2548
|
+
filterBindings: (tile.filterBindings ?? []).filter((binding) => selectedFilterIds.has(binding.filter)),
|
|
2549
|
+
};
|
|
1695
2550
|
if (!override)
|
|
1696
|
-
return
|
|
2551
|
+
return filteredTile;
|
|
1697
2552
|
const viz = override.viz;
|
|
1698
2553
|
return {
|
|
1699
|
-
...
|
|
2554
|
+
...filteredTile,
|
|
1700
2555
|
...(override.title ? { title: override.title } : {}),
|
|
1701
2556
|
...(viz ? { viz } : {}),
|
|
1702
2557
|
...(viz && tile.display ? {
|
|
@@ -1721,7 +2576,15 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
|
|
|
1721
2576
|
const { KGStore, defaultKgPath, ensureMetadataCatalogFresh, generateAppFromPlan, reindexProject, validateAppPlan, } = await import('@duckcodeailabs/dql-agent');
|
|
1722
2577
|
const index = await reindexProject(projectRoot, { kgPath: defaultKgPath(projectRoot) });
|
|
1723
2578
|
if (!session.snapshotId || index.kgFingerprint !== session.snapshotId) {
|
|
1724
|
-
|
|
2579
|
+
// This compares a whole-project index, so an unrelated block or model edit
|
|
2580
|
+
// invalidates a pending proposal. Kept deliberately strict — the proposal's
|
|
2581
|
+
// preflight receipts are bound to that snapshot — but the message now says
|
|
2582
|
+
// what to do instead of only reporting a conflict.
|
|
2583
|
+
return {
|
|
2584
|
+
ok: false,
|
|
2585
|
+
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.',
|
|
2586
|
+
status: 409,
|
|
2587
|
+
};
|
|
1725
2588
|
}
|
|
1726
2589
|
const kg = new KGStore(defaultKgPath(projectRoot));
|
|
1727
2590
|
try {
|
|
@@ -1747,7 +2610,10 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
|
|
|
1747
2610
|
const stagedAppDir = isExistingPageBuild
|
|
1748
2611
|
? join(stagingRoot, 'candidate', 'apps', committedPlan.appId)
|
|
1749
2612
|
: generatedAppDir;
|
|
1750
|
-
|
|
2613
|
+
// Apps also resolve under `domains/**/apps/<id>`. Hardcoding the canonical
|
|
2614
|
+
// root meant a domain-hosted App always failed the existence check below
|
|
2615
|
+
// and could never have a page added.
|
|
2616
|
+
const targetAppDir = existingApp?.appDir ?? join(projectRoot, 'apps', committedPlan.appId);
|
|
1751
2617
|
const backupAppDir = join(projectRoot, '.dql', 'local', 'app-build-staging', `${session.id}-backup`);
|
|
1752
2618
|
rmSync(stagingRoot, { recursive: true, force: true });
|
|
1753
2619
|
try {
|
|
@@ -1841,6 +2707,15 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
|
|
|
1841
2707
|
rmSync(stagingRoot, { recursive: true, force: true });
|
|
1842
2708
|
}
|
|
1843
2709
|
const attachWarnings = [];
|
|
2710
|
+
// Adding a page deliberately cannot rename or re-audience the App it joins.
|
|
2711
|
+
// Say so instead of discarding the input silently — this array existed and
|
|
2712
|
+
// was never written to, so the drop had no surface at all.
|
|
2713
|
+
if (isExistingPageBuild && cleanString(input.appName)) {
|
|
2714
|
+
attachWarnings.push('The App name was not changed: adding a page cannot rename the App. Rename it from the App header.');
|
|
2715
|
+
}
|
|
2716
|
+
if (isExistingPageBuild && cleanString(input.audience)) {
|
|
2717
|
+
attachWarnings.push('The audience was not changed: adding a page keeps the App\'s existing audience.');
|
|
2718
|
+
}
|
|
1844
2719
|
await ensureMetadataCatalogFresh(projectRoot, { force: true });
|
|
1845
2720
|
const app = collectAppsList(projectRoot).find((entry) => entry.id === committedPlan.appId) ?? null;
|
|
1846
2721
|
const next = {
|
|
@@ -1858,6 +2733,7 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
|
|
|
1858
2733
|
? { pageTitle: committedPlan.pages[0]?.title }
|
|
1859
2734
|
: { appName: committedPlan.name }),
|
|
1860
2735
|
audience: committedPlan.audience,
|
|
2736
|
+
filterIds: Array.from(selectedFilterIds),
|
|
1861
2737
|
tileOverrides,
|
|
1862
2738
|
},
|
|
1863
2739
|
warnings: [...session.warnings, ...attachWarnings],
|
|
@@ -1920,7 +2796,7 @@ async function routeAppAskQuestion(ctx, appId, input) {
|
|
|
1920
2796
|
const tile = dashboard?.layout.items.find((item) => item.i === input.tileId ||
|
|
1921
2797
|
(input.blockId && item.block && 'blockId' in item.block && item.block.blockId === input.blockId));
|
|
1922
2798
|
const blockId = cleanString(input.blockId) || (tile?.block && 'blockId' in tile.block ? tile.block.blockId : undefined);
|
|
1923
|
-
const appContext = buildAppContextEnvelope(loaded.app, loaded.appDir, {
|
|
2799
|
+
const appContext = buildAppContextEnvelope(ctx.projectRoot, loaded.app, loaded.appDir, {
|
|
1924
2800
|
dashboardId,
|
|
1925
2801
|
tileId: cleanString(input.tileId) || undefined,
|
|
1926
2802
|
blockId,
|
|
@@ -2170,17 +3046,80 @@ function questionIsAboutFocusedTile(question) {
|
|
|
2170
3046
|
return true;
|
|
2171
3047
|
}
|
|
2172
3048
|
/** Map a governed AgentAnswer into the App Copilot's response shape. */
|
|
3049
|
+
/** Overlap needed before a draft is offered as covering the question. */
|
|
3050
|
+
const APP_DRAFT_MATCH_MIN_OVERLAP = 2;
|
|
3051
|
+
const APP_DRAFT_STOPWORDS = new Set([
|
|
3052
|
+
'the', 'a', 'an', 'and', 'or', 'for', 'with', 'what', 'which', 'who', 'how', 'why', 'is', 'are',
|
|
3053
|
+
'do', 'does', 'can', 'you', 'me', 'my', 'our', 'this', 'that', 'show', 'give', 'get', 'by', 'of',
|
|
3054
|
+
'in', 'on', 'to', 'from', 'all', 'complete', 'view',
|
|
3055
|
+
]);
|
|
3056
|
+
function appDraftTokens(value) {
|
|
3057
|
+
return new Set(value.toLowerCase().split(/[^a-z0-9]+/)
|
|
3058
|
+
.filter((word) => word.length > 2 && !APP_DRAFT_STOPWORDS.has(word)));
|
|
3059
|
+
}
|
|
3060
|
+
/**
|
|
3061
|
+
* The app's own draft that best covers a question the governed loop could not answer.
|
|
3062
|
+
*
|
|
3063
|
+
* Drafts are review-required exploratory analyses saved in the app. They are not
|
|
3064
|
+
* indexed into the manifest, so retrieval cannot find them — but when the loop
|
|
3065
|
+
* has just refused for want of a certified path, the app frequently already
|
|
3066
|
+
* contains a draft that does exactly the thing. Offering it is strictly better
|
|
3067
|
+
* than explaining the gap and stopping, and it never launders trust: the answer
|
|
3068
|
+
* stays review-required and says the source is an unreviewed draft.
|
|
3069
|
+
*/
|
|
3070
|
+
export function matchAppDraftForQuestion(drafts, question) {
|
|
3071
|
+
const asked = appDraftTokens(question);
|
|
3072
|
+
if (asked.size === 0)
|
|
3073
|
+
return undefined;
|
|
3074
|
+
let best;
|
|
3075
|
+
for (const draft of drafts ?? []) {
|
|
3076
|
+
const candidate = appDraftTokens([draft.name, draft.question ?? '', draft.description ?? ''].join(' '));
|
|
3077
|
+
let score = 0;
|
|
3078
|
+
for (const token of asked)
|
|
3079
|
+
if (candidate.has(token))
|
|
3080
|
+
score += 1;
|
|
3081
|
+
if (score >= APP_DRAFT_MATCH_MIN_OVERLAP && (!best || score > best.score))
|
|
3082
|
+
best = { draft, score };
|
|
3083
|
+
}
|
|
3084
|
+
return best?.draft;
|
|
3085
|
+
}
|
|
2173
3086
|
function mapGovernedToAppAsk(governed, input) {
|
|
2174
3087
|
const isCertified = governed.certification === 'certified' || governed.kind === 'certified';
|
|
2175
3088
|
const noAnswer = governed.kind === 'no_answer';
|
|
2176
|
-
const
|
|
3089
|
+
const governedText = cleanString(governed.answer) || cleanString(governed.text) || 'No governed answer was available for this question.';
|
|
3090
|
+
// The loop just declined for want of certified evidence. Before returning only
|
|
3091
|
+
// the gap, check whether this app already saved a draft that covers it.
|
|
3092
|
+
const draft = noAnswer ? matchAppDraftForQuestion(input.appContext?.drafts ?? [], input.question) : undefined;
|
|
3093
|
+
// App drafts are often generated FROM a refusal like this one, so their
|
|
3094
|
+
// description can be the same paragraph the loop just returned. Only add it
|
|
3095
|
+
// when it actually says something new.
|
|
3096
|
+
const normalize = (value) => value.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
3097
|
+
const draftAdds = draft?.description && !normalize(governedText).includes(normalize(draft.description).slice(0, 80))
|
|
3098
|
+
? draft.description
|
|
3099
|
+
: '';
|
|
3100
|
+
const answer = draft
|
|
3101
|
+
? [
|
|
3102
|
+
governedText,
|
|
3103
|
+
'',
|
|
3104
|
+
`This app already has a saved draft for it: "${draft.name}" (${draft.status}).`,
|
|
3105
|
+
draftAdds,
|
|
3106
|
+
'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.',
|
|
3107
|
+
].filter(Boolean).join('\n')
|
|
3108
|
+
: governedText;
|
|
2177
3109
|
const route = noAnswer ? 'metadata_answer' : isCertified ? 'certified_answer' : 'generated_answer';
|
|
2178
|
-
|
|
3110
|
+
// Offering a draft is actionable but unreviewed, so it is review_required
|
|
3111
|
+
// rather than the bare draft_ready used when there is nothing to offer.
|
|
3112
|
+
const trustState = draft ? 'review_required' : noAnswer ? 'draft_ready' : isCertified ? 'certified' : 'review_required';
|
|
2179
3113
|
const govCitations = Array.isArray(governed.citations)
|
|
2180
3114
|
? governed.citations.map((c) => ({ kind: String(c.kind ?? 'source'), name: String(c.name ?? '') })).filter((c) => c.name)
|
|
2181
3115
|
: [];
|
|
2182
3116
|
const seen = new Set();
|
|
2183
|
-
const citations = [
|
|
3117
|
+
const citations = [
|
|
3118
|
+
...input.citations,
|
|
3119
|
+
...govCitations,
|
|
3120
|
+
// Cite the draft so the user can open the exact file being offered.
|
|
3121
|
+
...(draft ? [{ kind: 'draft', name: draft.name, path: draft.path }] : []),
|
|
3122
|
+
].filter((c) => {
|
|
2184
3123
|
const key = `${c.kind}:${c.name}`;
|
|
2185
3124
|
if (seen.has(key))
|
|
2186
3125
|
return false;
|
|
@@ -2194,9 +3133,11 @@ function mapGovernedToAppAsk(governed, input) {
|
|
|
2194
3133
|
trustState,
|
|
2195
3134
|
reviewStatus: trustState,
|
|
2196
3135
|
citations,
|
|
2197
|
-
followUps:
|
|
2198
|
-
? [
|
|
2199
|
-
:
|
|
3136
|
+
followUps: draft
|
|
3137
|
+
? [`Open the "${draft.name}" draft`, 'Review its join and grain', 'Certify it to make it governed']
|
|
3138
|
+
: noAnswer
|
|
3139
|
+
? ['Name the metric and grain', 'Pick a certified tile', 'Start driver analysis']
|
|
3140
|
+
: ['Investigate drivers', 'Break this down further', 'Create a draft DQL block'],
|
|
2200
3141
|
decision: buildAppAskDecision(route, { question: input.question, blockId: input.blockId, appName: input.appName }),
|
|
2201
3142
|
};
|
|
2202
3143
|
}
|
|
@@ -2386,6 +3327,75 @@ export function recommendDashboardTile(projectRoot, appId, dashboardId, input) {
|
|
|
2386
3327
|
warnings: recommendation.warnings,
|
|
2387
3328
|
};
|
|
2388
3329
|
}
|
|
3330
|
+
/**
|
|
3331
|
+
* Rename an App, and optionally a page inside it.
|
|
3332
|
+
*
|
|
3333
|
+
* `dql.app.json` previously had exactly two writers — create and publish — so
|
|
3334
|
+
* an App could never be renamed after it was built. Only the display `name`
|
|
3335
|
+
* changes here: the App **id** is also its folder name and is referenced by
|
|
3336
|
+
* notebooks, product context, and deep links, so renaming must not move it.
|
|
3337
|
+
*
|
|
3338
|
+
* The write is staged and swapped like the publish path, and guarded by the
|
|
3339
|
+
* caller-supplied fingerprint so a concurrent edit cannot be clobbered.
|
|
3340
|
+
*/
|
|
3341
|
+
export function renameApp(projectRoot, appId, input) {
|
|
3342
|
+
const loaded = loadAppById(projectRoot, appId);
|
|
3343
|
+
if (!loaded)
|
|
3344
|
+
return { ok: false, error: `App "${appId}" not found`, code: 'APP_NOT_FOUND' };
|
|
3345
|
+
if (input.expectedFingerprint && fingerprintAppDirectory(loaded.appDir) !== input.expectedFingerprint) {
|
|
3346
|
+
return { ok: false, error: 'This App changed since it was loaded. Refresh and rename again.', code: 'APP_CHANGED' };
|
|
3347
|
+
}
|
|
3348
|
+
const name = cleanString(input.name);
|
|
3349
|
+
const pageTitle = cleanString(input.pageTitle);
|
|
3350
|
+
if (!name && !pageTitle)
|
|
3351
|
+
return { ok: false, error: 'Provide a name or a pageTitle.', code: 'INVALID_REQUEST' };
|
|
3352
|
+
const appPath = join(loaded.appDir, 'dql.app.json');
|
|
3353
|
+
const parsedApp = parseAppDocument(JSON.stringify({ ...loaded.app, ...(name ? { name } : {}) }), appPath);
|
|
3354
|
+
if (!parsedApp.document)
|
|
3355
|
+
return { ok: false, error: parsedApp.errors.map((err) => err.message).join('; ') };
|
|
3356
|
+
const renamedDashboards = [];
|
|
3357
|
+
if (pageTitle) {
|
|
3358
|
+
const homepage = loaded.app.homepage;
|
|
3359
|
+
const dashboardId = cleanString(input.dashboardId)
|
|
3360
|
+
|| (homepage?.type === 'dashboard' ? homepage.id : undefined);
|
|
3361
|
+
const source = findDashboardsForApp(loaded.appDir)
|
|
3362
|
+
.map((path) => ({ path, document: loadDashboardDocument(path).document }))
|
|
3363
|
+
.find((item) => item.document && (!dashboardId || item.document.id === dashboardId));
|
|
3364
|
+
if (!source?.document)
|
|
3365
|
+
return { ok: false, error: `Dashboard "${dashboardId ?? ''}" not found in app "${appId}"`, code: 'DASHBOARD_NOT_FOUND' };
|
|
3366
|
+
const next = { ...source.document, metadata: { ...source.document.metadata, title: pageTitle } };
|
|
3367
|
+
const parsed = parseDashboardDocument(JSON.stringify(next), source.path);
|
|
3368
|
+
if (!parsed.document)
|
|
3369
|
+
return { ok: false, error: parsed.errors.map((err) => err.message).join('; ') };
|
|
3370
|
+
renamedDashboards.push({ sourcePath: source.path, document: parsed.document });
|
|
3371
|
+
}
|
|
3372
|
+
const stagingBase = join(projectRoot, '.dql', 'local', 'app-rename-staging');
|
|
3373
|
+
const stagedAppDir = join(stagingBase, `${appId}-next`);
|
|
3374
|
+
const backupAppDir = join(stagingBase, `${appId}-backup`);
|
|
3375
|
+
rmSync(stagedAppDir, { recursive: true, force: true });
|
|
3376
|
+
rmSync(backupAppDir, { recursive: true, force: true });
|
|
3377
|
+
mkdirSync(stagingBase, { recursive: true });
|
|
3378
|
+
cpSync(loaded.appDir, stagedAppDir, { recursive: true });
|
|
3379
|
+
writeFileSync(join(stagedAppDir, 'dql.app.json'), JSON.stringify(parsedApp.document, null, 2) + '\n', 'utf-8');
|
|
3380
|
+
for (const dashboard of renamedDashboards) {
|
|
3381
|
+
writeFileSync(join(stagedAppDir, relative(loaded.appDir, dashboard.sourcePath)), JSON.stringify(dashboard.document, null, 2) + '\n', 'utf-8');
|
|
3382
|
+
}
|
|
3383
|
+
try {
|
|
3384
|
+
renameSync(loaded.appDir, backupAppDir);
|
|
3385
|
+
renameSync(stagedAppDir, loaded.appDir);
|
|
3386
|
+
rmSync(backupAppDir, { recursive: true, force: true });
|
|
3387
|
+
}
|
|
3388
|
+
catch (error) {
|
|
3389
|
+
if (existsSync(backupAppDir) && !existsSync(loaded.appDir))
|
|
3390
|
+
renameSync(backupAppDir, loaded.appDir);
|
|
3391
|
+
throw error;
|
|
3392
|
+
}
|
|
3393
|
+
return {
|
|
3394
|
+
ok: true,
|
|
3395
|
+
app: parsedApp.document,
|
|
3396
|
+
paths: [relative(projectRoot, appPath), ...renamedDashboards.map((item) => relative(projectRoot, item.sourcePath))],
|
|
3397
|
+
};
|
|
3398
|
+
}
|
|
2389
3399
|
export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
|
|
2390
3400
|
const loaded = loadAppById(projectRoot, appId);
|
|
2391
3401
|
if (!loaded)
|
|
@@ -2393,7 +3403,7 @@ export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
|
|
|
2393
3403
|
const dashboardSources = findDashboardsForApp(loaded.appDir)
|
|
2394
3404
|
.map((path) => ({ path, document: loadDashboardDocument(path).document }))
|
|
2395
3405
|
.filter((item) => Boolean(item.document));
|
|
2396
|
-
const readiness = appPublicationReadiness(dashboardSources.map((item) => item.document));
|
|
3406
|
+
const readiness = appPublicationReadiness(projectRoot, dashboardSources.map((item) => item.document));
|
|
2397
3407
|
if (!readiness.ready) {
|
|
2398
3408
|
return {
|
|
2399
3409
|
ok: false,
|
|
@@ -2456,101 +3466,1449 @@ export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
|
|
|
2456
3466
|
writeFileSync(stagedPath, JSON.stringify(dashboard.document, null, 2) + '\n', 'utf-8');
|
|
2457
3467
|
}
|
|
2458
3468
|
try {
|
|
2459
|
-
renameSync(loaded.appDir, backupAppDir);
|
|
2460
|
-
renameSync(stagedAppDir, loaded.appDir);
|
|
2461
|
-
rmSync(backupAppDir, { recursive: true, force: true });
|
|
3469
|
+
renameSync(loaded.appDir, backupAppDir);
|
|
3470
|
+
renameSync(stagedAppDir, loaded.appDir);
|
|
3471
|
+
rmSync(backupAppDir, { recursive: true, force: true });
|
|
3472
|
+
}
|
|
3473
|
+
catch (error) {
|
|
3474
|
+
if (existsSync(backupAppDir) && !existsSync(loaded.appDir))
|
|
3475
|
+
renameSync(backupAppDir, loaded.appDir);
|
|
3476
|
+
throw error;
|
|
3477
|
+
}
|
|
3478
|
+
const paths = [relative(projectRoot, appPath), ...dashboards.map((dashboard) => relative(projectRoot, dashboard.sourcePath))];
|
|
3479
|
+
return { ok: true, app: parsedApp.document, paths, removedLocalTiles, readiness };
|
|
3480
|
+
}
|
|
3481
|
+
export function approveAppSemanticTiles(projectRoot, appId, dashboardId, input) {
|
|
3482
|
+
const loaded = loadDashboardForApp(projectRoot, appId, dashboardId);
|
|
3483
|
+
if (!loaded)
|
|
3484
|
+
return { ok: false, error: `Dashboard "${dashboardId}" not found in App "${appId}".` };
|
|
3485
|
+
const currentFingerprint = `sha256:${createHash('sha256').update(JSON.stringify(loaded.dashboard)).digest('hex')}`;
|
|
3486
|
+
if (!input.expectedDashboardFingerprint || input.expectedDashboardFingerprint !== currentFingerprint) {
|
|
3487
|
+
return { ok: false, error: 'The dashboard changed after the reviewed run. Run it again before approval.' };
|
|
3488
|
+
}
|
|
3489
|
+
const selected = new Set(input.tileIds);
|
|
3490
|
+
const semanticIds = new Set(loaded.dashboard.layout.items.filter((item) => item.semantic).map((item) => item.i));
|
|
3491
|
+
const invalid = input.tileIds.filter((tileId) => !semanticIds.has(tileId));
|
|
3492
|
+
if (invalid.length > 0)
|
|
3493
|
+
return { ok: false, error: `Only semantic tiles can be approved here: ${invalid.join(', ')}` };
|
|
3494
|
+
const reviewedAt = new Date().toISOString();
|
|
3495
|
+
const dashboard = {
|
|
3496
|
+
...loaded.dashboard,
|
|
3497
|
+
layout: {
|
|
3498
|
+
...loaded.dashboard.layout,
|
|
3499
|
+
items: loaded.dashboard.layout.items.map((item) => {
|
|
3500
|
+
if (!selected.has(item.i) || !item.semantic)
|
|
3501
|
+
return item;
|
|
3502
|
+
return {
|
|
3503
|
+
...item,
|
|
3504
|
+
semantic: { ...item.semantic, snapshotId: input.snapshotId },
|
|
3505
|
+
review: {
|
|
3506
|
+
status: 'approved',
|
|
3507
|
+
sourceFingerprint: item.semantic.definitionFingerprint,
|
|
3508
|
+
preflightReceiptId: input.runId,
|
|
3509
|
+
reviewedAt,
|
|
3510
|
+
reviewedBy: input.reviewer,
|
|
3511
|
+
},
|
|
3512
|
+
trustState: 'review_required',
|
|
3513
|
+
reviewStatus: 'review_required',
|
|
3514
|
+
};
|
|
3515
|
+
}),
|
|
3516
|
+
},
|
|
3517
|
+
};
|
|
3518
|
+
const parsed = parseDashboardDocument(JSON.stringify(dashboard), loaded.path);
|
|
3519
|
+
if (!parsed.document)
|
|
3520
|
+
return { ok: false, error: parsed.errors.map((error) => error.message).join('; ') };
|
|
3521
|
+
writeFileSync(loaded.path, JSON.stringify(parsed.document, null, 2) + '\n', 'utf-8');
|
|
3522
|
+
return { ok: true, dashboard: parsed.document, path: relative(projectRoot, loaded.path) };
|
|
3523
|
+
}
|
|
3524
|
+
function appPublicationReadiness(projectRoot, dashboards, options = {}) {
|
|
3525
|
+
const blockers = [];
|
|
3526
|
+
let governedTiles = 0;
|
|
3527
|
+
const blocks = collectBlockCandidates(projectRoot, { preserveDuplicates: true });
|
|
3528
|
+
for (const dashboard of dashboards) {
|
|
3529
|
+
for (const item of dashboard.layout.items) {
|
|
3530
|
+
if (item.block) {
|
|
3531
|
+
governedTiles += 1;
|
|
3532
|
+
const blockRef = item.block;
|
|
3533
|
+
const boundRevision = item.sourceRevision ?? item.review?.sourceFingerprint;
|
|
3534
|
+
const block = blocks.find((candidate) => ('blockId' in blockRef
|
|
3535
|
+
? candidate.id === blockRef.blockId || candidate.name === blockRef.blockId
|
|
3536
|
+
: candidate.path === blockRef.ref));
|
|
3537
|
+
if (!block || block.status !== 'certified') {
|
|
3538
|
+
blockers.push({
|
|
3539
|
+
dashboardId: dashboard.id,
|
|
3540
|
+
tileId: item.i,
|
|
3541
|
+
code: 'block_not_certified',
|
|
3542
|
+
message: `${dashboard.id}/${item.i} no longer resolves to a certified block.`,
|
|
3543
|
+
});
|
|
3544
|
+
}
|
|
3545
|
+
else if (!boundRevision) {
|
|
3546
|
+
blockers.push({
|
|
3547
|
+
dashboardId: dashboard.id,
|
|
3548
|
+
tileId: item.i,
|
|
3549
|
+
code: 'block_source_unversioned',
|
|
3550
|
+
message: `${dashboard.id}/${item.i} has no bound source revision; refresh the certified block before publication.`,
|
|
3551
|
+
});
|
|
3552
|
+
}
|
|
3553
|
+
else if (boundRevision !== block.fingerprint) {
|
|
3554
|
+
blockers.push({
|
|
3555
|
+
dashboardId: dashboard.id,
|
|
3556
|
+
tileId: item.i,
|
|
3557
|
+
code: 'block_source_changed',
|
|
3558
|
+
message: `${dashboard.id}/${item.i} changed after it was selected; review the current certified block before publication.`,
|
|
3559
|
+
});
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
if (item.draftAnalysis) {
|
|
3563
|
+
blockers.push({
|
|
3564
|
+
dashboardId: dashboard.id,
|
|
3565
|
+
tileId: item.i,
|
|
3566
|
+
code: 'exploratory_source',
|
|
3567
|
+
message: `${dashboard.id}/${item.i} references app-scoped exploratory DQL; promote it to governed semantic logic or a certified block first.`,
|
|
3568
|
+
});
|
|
3569
|
+
}
|
|
3570
|
+
if (item.semantic) {
|
|
3571
|
+
governedTiles += 1;
|
|
3572
|
+
if (item.review?.status !== 'approved') {
|
|
3573
|
+
blockers.push({
|
|
3574
|
+
dashboardId: dashboard.id,
|
|
3575
|
+
tileId: item.i,
|
|
3576
|
+
code: 'semantic_review',
|
|
3577
|
+
message: `${dashboard.id}/${item.i} semantic result has not been explicitly approved.`,
|
|
3578
|
+
});
|
|
3579
|
+
}
|
|
3580
|
+
if (!item.semantic.snapshotId || !item.review?.preflightReceiptId) {
|
|
3581
|
+
blockers.push({
|
|
3582
|
+
dashboardId: dashboard.id,
|
|
3583
|
+
tileId: item.i,
|
|
3584
|
+
code: 'semantic_preflight',
|
|
3585
|
+
message: `${dashboard.id}/${item.i} needs a snapshot-bound semantic preflight receipt.`,
|
|
3586
|
+
});
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
if (dashboard.version === 2 && (item.block || item.semantic || item.draftAnalysis)) {
|
|
3590
|
+
for (const filter of dashboard.filters ?? []) {
|
|
3591
|
+
if (filter.scope?.tileIds && !filter.scope.tileIds.includes(item.i))
|
|
3592
|
+
continue;
|
|
3593
|
+
const binding = item.filterBindings?.find((candidate) => candidate.filter === filter.id);
|
|
3594
|
+
// An explicit unsupported binding is a governed exclusion, not a
|
|
3595
|
+
// missing binding. Studio shows the reason on the affected tile and
|
|
3596
|
+
// the filter deliberately leaves that tile unchanged.
|
|
3597
|
+
if (binding?.capability === 'unsupported' && binding.unsupportedReason?.trim())
|
|
3598
|
+
continue;
|
|
3599
|
+
const settled = options.settledPageIds?.has(dashboard.id) ?? false;
|
|
3600
|
+
if (!binding || (!binding.binding && binding.mode !== 'parameter') || (binding.capability === 'preflight_required' && !settled)) {
|
|
3601
|
+
blockers.push({
|
|
3602
|
+
dashboardId: dashboard.id,
|
|
3603
|
+
tileId: item.i,
|
|
3604
|
+
code: 'filter_not_preflighted',
|
|
3605
|
+
message: `${dashboard.id}/${item.i} does not have a preflighted binding for filter ${filter.label ?? filter.id}.`,
|
|
3606
|
+
});
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
return { ready: blockers.length === 0, governedTiles, blockers };
|
|
3613
|
+
}
|
|
3614
|
+
function appAiBuildSessionDir(projectRoot) {
|
|
3615
|
+
return join(projectRoot, '.dql', 'local', 'app-ai-builds');
|
|
3616
|
+
}
|
|
3617
|
+
function appBuildDraftDir(projectRoot) {
|
|
3618
|
+
// Read-only migration location for AppBuildDraft v1 JSON files. New drafts
|
|
3619
|
+
// are persisted transactionally in .dql/local/apps.sqlite.
|
|
3620
|
+
return join(projectRoot, '.dql', 'local', 'app-builds');
|
|
3621
|
+
}
|
|
3622
|
+
function appBuildProposalDir(projectRoot, draftId) {
|
|
3623
|
+
return join(appBuildDraftDir(projectRoot), draftId, 'proposals');
|
|
3624
|
+
}
|
|
3625
|
+
function writeAppBuildDraftAiProposal(projectRoot, proposal) {
|
|
3626
|
+
if (!/^[a-z0-9_:-]+$/i.test(proposal.draftId) || !/^[a-z0-9_:-]+$/i.test(proposal.id)) {
|
|
3627
|
+
throw new Error('APP_BUILD_PROPOSAL_INVALID: proposal identity is invalid.');
|
|
3628
|
+
}
|
|
3629
|
+
const dir = appBuildProposalDir(projectRoot, proposal.draftId);
|
|
3630
|
+
mkdirSync(dir, { recursive: true });
|
|
3631
|
+
const path = join(dir, `${proposal.id}.json`);
|
|
3632
|
+
const candidate = `${path}.candidate`;
|
|
3633
|
+
writeFileSync(candidate, `${JSON.stringify(proposal, null, 2)}\n`, 'utf8');
|
|
3634
|
+
renameSync(candidate, path);
|
|
3635
|
+
}
|
|
3636
|
+
function loadAppBuildDraftAiProposal(projectRoot, draftId, proposalId) {
|
|
3637
|
+
if (!/^[a-z0-9_:-]+$/i.test(draftId) || !/^[a-z0-9_:-]+$/i.test(proposalId))
|
|
3638
|
+
return null;
|
|
3639
|
+
const path = join(appBuildProposalDir(projectRoot, draftId), `${proposalId}.json`);
|
|
3640
|
+
if (!existsSync(path))
|
|
3641
|
+
return null;
|
|
3642
|
+
try {
|
|
3643
|
+
const proposal = JSON.parse(readFileSync(path, 'utf8'));
|
|
3644
|
+
return proposal.draftId === draftId && proposal.id === proposalId ? proposal : null;
|
|
3645
|
+
}
|
|
3646
|
+
catch {
|
|
3647
|
+
return null;
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
async function generateExplicitAppBuildGap(projectRoot, draft, previous, requirementId, generate) {
|
|
3651
|
+
const requirementOperation = previous.operations.find((operation) => operation.type === 'set_requirements');
|
|
3652
|
+
const requirement = requirementOperation?.type === 'set_requirements'
|
|
3653
|
+
? requirementOperation.requirements.find((candidate) => candidate.id === requirementId)
|
|
3654
|
+
: undefined;
|
|
3655
|
+
const coverage = requirementOperation?.type === 'set_requirements'
|
|
3656
|
+
? requirementOperation.coverage?.find((candidate) => candidate.requirementId === requirementId)
|
|
3657
|
+
: undefined;
|
|
3658
|
+
if (!requirement || coverage?.status === 'covered') {
|
|
3659
|
+
throw new Error('APP_BUILD_GAP_NOT_FOUND: choose an uncovered App requirement.');
|
|
3660
|
+
}
|
|
3661
|
+
const answer = await generate(requirement.question);
|
|
3662
|
+
const sql = cleanString(answer.sql);
|
|
3663
|
+
if (!sql)
|
|
3664
|
+
throw new Error('APP_BUILD_GAP_NO_QUERY: the provider did not return executable review DQL.');
|
|
3665
|
+
if (sql.includes('"""'))
|
|
3666
|
+
throw new Error('APP_BUILD_GAP_INVALID_QUERY: generated SQL contains an unsupported triple-quote delimiter.');
|
|
3667
|
+
const proposalId = `proposal_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
3668
|
+
const key = slugify(requirement.id) || createHash('sha256').update(requirement.question).digest('hex').slice(0, 12);
|
|
3669
|
+
const sourceRef = `proposals/${proposalId}/drafts/${key}.dql`;
|
|
3670
|
+
const source = [
|
|
3671
|
+
`block "app_${slugify(draft.appId)}_${key}" {`,
|
|
3672
|
+
` domain = "${escapeDqlString(draft.pages[0]?.metadata.domain ?? 'general')}"`,
|
|
3673
|
+
' type = "custom"',
|
|
3674
|
+
' status = "review"',
|
|
3675
|
+
' owner = "local-app-builder"',
|
|
3676
|
+
` description = "${escapeDqlString(answer.text?.slice(0, 240) || requirement.question)}"`,
|
|
3677
|
+
` llmContext = "${escapeDqlString(`Explicitly generated App gap: ${requirement.question}`)}"`,
|
|
3678
|
+
' caveats = ["AI-generated App gap. Validate joins, grain, filters, and interpretation before promotion."]',
|
|
3679
|
+
' tags = ["app-scoped", "ai-generated", "needs-review"]',
|
|
3680
|
+
'',
|
|
3681
|
+
' query = """',
|
|
3682
|
+
sql,
|
|
3683
|
+
' """',
|
|
3684
|
+
'',
|
|
3685
|
+
' visualization {',
|
|
3686
|
+
` chart = "${escapeDqlString(normalizeGeneratedViz(answer.suggestedViz))}"`,
|
|
3687
|
+
' }',
|
|
3688
|
+
'}',
|
|
3689
|
+
'',
|
|
3690
|
+
].join('\n');
|
|
3691
|
+
const fingerprint = `sha256:${createHash('sha256').update(source).digest('hex')}`;
|
|
3692
|
+
const absolute = join(projectRoot, '.dql', 'local', 'app-builds', draft.id, sourceRef);
|
|
3693
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
3694
|
+
writeFileSync(absolute, source, 'utf8');
|
|
3695
|
+
const sourceId = `review-dql:gap:${createHash('sha256').update(`${proposalId}:${requirement.id}`).digest('hex').slice(0, 16)}`;
|
|
3696
|
+
const pageOperation = previous.operations.find((operation) => operation.type === 'upsert_page');
|
|
3697
|
+
if (!pageOperation || pageOperation.type !== 'upsert_page')
|
|
3698
|
+
throw new Error('APP_BUILD_PAGE_REQUIRED: the proposal has no App page.');
|
|
3699
|
+
const tileId = uniqueTileId(pageOperation.page.layout.items, `gap-${requirement.id}`);
|
|
3700
|
+
const y = pageOperation.page.layout.items.reduce((max, tile) => Math.max(max, tile.y + tile.h), 0);
|
|
3701
|
+
const tile = {
|
|
3702
|
+
i: tileId,
|
|
3703
|
+
x: 0,
|
|
3704
|
+
y,
|
|
3705
|
+
w: 6,
|
|
3706
|
+
h: 4,
|
|
3707
|
+
sourceId,
|
|
3708
|
+
sourceRevision: fingerprint,
|
|
3709
|
+
draftAnalysis: { ref: sourceRef, artifactFingerprint: fingerprint },
|
|
3710
|
+
viz: { type: normalizeGeneratedViz(answer.suggestedViz) },
|
|
3711
|
+
title: titleForGapQuestion(requirement.question),
|
|
3712
|
+
sourceClass: 'exploratory_analysis',
|
|
3713
|
+
review: { status: 'required', sourceFingerprint: fingerprint },
|
|
3714
|
+
trustState: 'review_required',
|
|
3715
|
+
reviewStatus: 'review_required',
|
|
3716
|
+
};
|
|
3717
|
+
const operations = previous.operations.map((operation) => {
|
|
3718
|
+
if (operation === pageOperation && operation.type === 'upsert_page') {
|
|
3719
|
+
const items = [...operation.page.layout.items, tile];
|
|
3720
|
+
return { type: 'upsert_page', page: { ...operation.page, layout: { ...operation.page.layout, items, responsive: responsiveLayoutsForDashboardItems(items) } } };
|
|
3721
|
+
}
|
|
3722
|
+
if (operation.type === 'set_requirements') {
|
|
3723
|
+
return {
|
|
3724
|
+
...operation,
|
|
3725
|
+
coverage: (operation.coverage ?? []).map((item) => item.requirementId === requirement.id
|
|
3726
|
+
? { ...item, status: 'covered', sourceIds: [sourceId], componentIds: [tileId], reasons: ['Covered by explicitly generated review-required App DQL.'] }
|
|
3727
|
+
: item),
|
|
3728
|
+
};
|
|
3729
|
+
}
|
|
3730
|
+
return operation;
|
|
3731
|
+
});
|
|
3732
|
+
operations.push({
|
|
3733
|
+
type: 'upsert_source',
|
|
3734
|
+
source: {
|
|
3735
|
+
id: sourceId,
|
|
3736
|
+
kind: 'review_dql',
|
|
3737
|
+
sourceRef,
|
|
3738
|
+
qualifiedIdentity: tile.title,
|
|
3739
|
+
sourceRevision: fingerprint,
|
|
3740
|
+
sourceFingerprint: fingerprint,
|
|
3741
|
+
lifecycle: 'review',
|
|
3742
|
+
capabilities: { measures: requirement.measures, dimensions: requirement.dimensions, outputs: [], filters: requirement.filters, allowedVisualizations: [tile.viz.type], parameters: [] },
|
|
3743
|
+
trustState: 'review_required',
|
|
3744
|
+
reviewStatus: 'required',
|
|
3745
|
+
},
|
|
3746
|
+
});
|
|
3747
|
+
operations.push({
|
|
3748
|
+
type: 'set_review_task',
|
|
3749
|
+
task: {
|
|
3750
|
+
id: `review-gap-${key}`,
|
|
3751
|
+
message: `Validate the joins, grain, filters, and business interpretation for ${tile.title}.`,
|
|
3752
|
+
status: 'open',
|
|
3753
|
+
sourceId,
|
|
3754
|
+
pageId: pageOperation.page.id,
|
|
3755
|
+
tileId,
|
|
3756
|
+
},
|
|
3757
|
+
});
|
|
3758
|
+
return {
|
|
3759
|
+
...previous,
|
|
3760
|
+
id: proposalId,
|
|
3761
|
+
operations,
|
|
3762
|
+
warnings: Array.from(new Set([...(previous.warnings ?? []), 'Generated gap remains review-required and local-only.'])),
|
|
3763
|
+
summary: {
|
|
3764
|
+
...previous.summary,
|
|
3765
|
+
covered: previous.summary.covered + 1,
|
|
3766
|
+
gaps: Math.max(0, previous.summary.gaps - 1),
|
|
3767
|
+
},
|
|
3768
|
+
};
|
|
3769
|
+
}
|
|
3770
|
+
async function composeStoredAppBuildDraft(projectRoot, current, request) {
|
|
3771
|
+
if (request.mode === 'ai') {
|
|
3772
|
+
const proposal = loadAppBuildDraftAiProposal(projectRoot, current.id, cleanString(request.proposalId));
|
|
3773
|
+
if (!proposal)
|
|
3774
|
+
throw new Error('APP_BUILD_PROPOSAL_NOT_FOUND: ask AI to rebuild the proposal.');
|
|
3775
|
+
if (proposal.baseRevision !== current.revision || proposal.baseProposalHash !== current.proposalHash) {
|
|
3776
|
+
throw new Error('APP_BUILD_PROPOSAL_CONFLICT: the proposal is based on an older draft revision.');
|
|
3777
|
+
}
|
|
3778
|
+
const selected = new Set(unique((request.selectedSourceIds ?? []).map(cleanString).filter(Boolean)));
|
|
3779
|
+
const available = new Map(proposal.operations.flatMap((operation) => (operation.type === 'upsert_source' ? [[operation.source.id, operation.source]] : [])));
|
|
3780
|
+
const missing = Array.from(selected).filter((sourceId) => !available.has(sourceId));
|
|
3781
|
+
if (missing.length)
|
|
3782
|
+
throw new Error(`APP_BUILD_SOURCE_NOT_IN_PROPOSAL: ${missing.join(', ')}`);
|
|
3783
|
+
for (const sourceId of selected) {
|
|
3784
|
+
const source = available.get(sourceId);
|
|
3785
|
+
if (current.sourcePolicy === 'governed_only' && source.trustState !== 'certified') {
|
|
3786
|
+
throw new Error(`APP_BUILD_REVIEW_POLICY_REQUIRED: enable review-required sources before adding ${source.qualifiedIdentity ?? source.sourceRef}.`);
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
const operations = operationsForSelectedAppBuildSources(proposal.operations, selected);
|
|
3790
|
+
const next = applyAppBuildDraftOperations(current, current.revision, operations);
|
|
3791
|
+
writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: current.revision, operations });
|
|
3792
|
+
const pages = operations.flatMap((operation) => operation.type === 'upsert_page' ? [operation.page] : []);
|
|
3793
|
+
return {
|
|
3794
|
+
draft: next,
|
|
3795
|
+
pageIds: pages.map((page) => page.id),
|
|
3796
|
+
tileIds: pages.flatMap((page) => page.layout.items.filter((item) => item.sourceId && selected.has(item.sourceId)).map((item) => item.i)),
|
|
3797
|
+
};
|
|
3798
|
+
}
|
|
3799
|
+
const selections = request.selections ?? [];
|
|
3800
|
+
if (selections.length === 0)
|
|
3801
|
+
throw new Error('APP_BUILD_SOURCE_REQUIRED: choose at least one source.');
|
|
3802
|
+
if (selections.length > 100)
|
|
3803
|
+
throw new Error('APP_BUILD_SOURCE_LIMIT: at most 100 components can be composed at once.');
|
|
3804
|
+
const { ensureAgentProjectReady, resolveAppSourceCatalogRecords } = await import('@duckcodeailabs/dql-agent');
|
|
3805
|
+
await ensureAgentProjectReady(projectRoot);
|
|
3806
|
+
const resolved = resolveAppSourceCatalogRecords(projectRoot, selections.map((selection) => selection.sourceId), current.sourcePolicy);
|
|
3807
|
+
if (resolved.missingSourceIds.length) {
|
|
3808
|
+
throw new Error(`APP_BUILD_SOURCE_DRIFT: these sources no longer resolve: ${resolved.missingSourceIds.join(', ')}`);
|
|
3809
|
+
}
|
|
3810
|
+
const byId = new Map(resolved.items.map((source) => [source.sourceId, source]));
|
|
3811
|
+
const workingItems = new Map(current.pages.map((page) => [page.id, [...page.layout.items]]));
|
|
3812
|
+
const operations = [];
|
|
3813
|
+
const pageIds = new Set();
|
|
3814
|
+
const tileIds = [];
|
|
3815
|
+
const coverageAdditions = new Map();
|
|
3816
|
+
for (const [index, selection] of selections.entries()) {
|
|
3817
|
+
const source = byId.get(selection.sourceId);
|
|
3818
|
+
if (!source)
|
|
3819
|
+
throw new Error(`APP_BUILD_SOURCE_DRIFT: ${selection.sourceId} no longer resolves.`);
|
|
3820
|
+
if (!source.eligibility.localPreview) {
|
|
3821
|
+
throw new Error(`APP_BUILD_REVIEW_POLICY_REQUIRED: ${source.title} is ${source.lifecycle}; enable review-required sources to add it locally.`);
|
|
3822
|
+
}
|
|
3823
|
+
const page = current.pages.find((candidate) => candidate.id === cleanString(selection.pageId)) ?? current.pages[0];
|
|
3824
|
+
if (!page)
|
|
3825
|
+
throw new Error('APP_BUILD_PAGE_REQUIRED: add a page before adding a source.');
|
|
3826
|
+
const pageItems = workingItems.get(page.id) ?? [...page.layout.items];
|
|
3827
|
+
const tileId = uniqueTileId(pageItems, `${source.name}-${selection.view}`);
|
|
3828
|
+
const y = pageItems.reduce((max, item) => Math.max(max, item.y + item.h), 0);
|
|
3829
|
+
const certified = source.lifecycle === 'certified';
|
|
3830
|
+
const view = selection.view === 'chart'
|
|
3831
|
+
? normalizeVizType(source.capabilities.chartType ?? source.capabilities.allowedVisualizations?.[0] ?? 'bar')
|
|
3832
|
+
: selection.view === 'kpi' ? 'kpi' : 'table';
|
|
3833
|
+
const tile = {
|
|
3834
|
+
i: tileId,
|
|
3835
|
+
x: (index % 2) * 6,
|
|
3836
|
+
y,
|
|
3837
|
+
w: selection.view === 'kpi' ? 3 : 6,
|
|
3838
|
+
h: selection.view === 'kpi' ? 2 : 4,
|
|
3839
|
+
sourceId: source.sourceId,
|
|
3840
|
+
sourceRevision: source.sourceRevision,
|
|
3841
|
+
block: { ref: source.executionRef },
|
|
3842
|
+
viz: { type: view },
|
|
3843
|
+
title: source.title,
|
|
3844
|
+
sourceClass: certified ? 'certified_block' : 'exploratory_analysis',
|
|
3845
|
+
filterBindings: source.capabilities.filters.map((filter) => ({ filter, binding: filter, mode: 'predicate', capability: 'preflight_required' })),
|
|
3846
|
+
review: { status: certified ? 'not_required' : 'required', sourceFingerprint: source.sourceRevision },
|
|
3847
|
+
trustState: certified ? 'certified' : 'review_required',
|
|
3848
|
+
reviewStatus: certified ? 'certified' : 'review_required',
|
|
3849
|
+
};
|
|
3850
|
+
pageItems.push(tile);
|
|
3851
|
+
workingItems.set(page.id, pageItems);
|
|
3852
|
+
operations.push({ type: 'upsert_source', source: appDraftSourceFromCatalog(source) });
|
|
3853
|
+
operations.push({ type: 'add_tile', pageId: page.id, tile });
|
|
3854
|
+
if (!certified) {
|
|
3855
|
+
operations.push({
|
|
3856
|
+
type: 'set_review_task',
|
|
3857
|
+
task: {
|
|
3858
|
+
id: `review-source-${createHash('sha256').update(source.sourceId).digest('hex').slice(0, 12)}`,
|
|
3859
|
+
message: `${source.title} is ${source.lifecycle} and must be certified, replaced, or removed before Project publication.`,
|
|
3860
|
+
status: 'open',
|
|
3861
|
+
sourceId: source.sourceId,
|
|
3862
|
+
pageId: page.id,
|
|
3863
|
+
tileId,
|
|
3864
|
+
},
|
|
3865
|
+
});
|
|
3866
|
+
}
|
|
3867
|
+
pageIds.add(page.id);
|
|
3868
|
+
tileIds.push(tileId);
|
|
3869
|
+
for (const requirement of current.requirements) {
|
|
3870
|
+
if (!catalogSourceSatisfiesRequirement(source, requirement))
|
|
3871
|
+
continue;
|
|
3872
|
+
const addition = coverageAdditions.get(requirement.id) ?? { sourceIds: [], componentIds: [] };
|
|
3873
|
+
addition.sourceIds.push(source.sourceId);
|
|
3874
|
+
addition.componentIds.push(tileId);
|
|
3875
|
+
coverageAdditions.set(requirement.id, addition);
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
if (current.requirements.length > 0) {
|
|
3879
|
+
const existingByRequirement = new Map(current.coverage.map((item) => [item.requirementId, item]));
|
|
3880
|
+
operations.push({
|
|
3881
|
+
type: 'set_coverage',
|
|
3882
|
+
coverage: current.requirements.map((requirement) => {
|
|
3883
|
+
const existing = existingByRequirement.get(requirement.id);
|
|
3884
|
+
const addition = coverageAdditions.get(requirement.id);
|
|
3885
|
+
const sourceIds = unique([...(existing?.sourceIds ?? []), ...(addition?.sourceIds ?? [])]);
|
|
3886
|
+
const componentIds = unique([...(existing?.componentIds ?? []), ...(addition?.componentIds ?? [])]);
|
|
3887
|
+
const newlyCovered = Boolean(addition?.sourceIds.length && addition.componentIds.length);
|
|
3888
|
+
return {
|
|
3889
|
+
requirementId: requirement.id,
|
|
3890
|
+
status: newlyCovered ? 'covered' : existing?.status ?? 'gap',
|
|
3891
|
+
sourceIds,
|
|
3892
|
+
componentIds,
|
|
3893
|
+
reasons: newlyCovered
|
|
3894
|
+
? unique([...(existing?.reasons ?? []), 'Covered by a server-resolved source added during manual composition.'])
|
|
3895
|
+
: existing?.reasons ?? ['No selected App source covers this requirement.'],
|
|
3896
|
+
};
|
|
3897
|
+
}),
|
|
3898
|
+
});
|
|
3899
|
+
}
|
|
3900
|
+
const next = applyAppBuildDraftOperations(current, current.revision, operations);
|
|
3901
|
+
writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: current.revision, operations });
|
|
3902
|
+
return { draft: next, pageIds: Array.from(pageIds), tileIds };
|
|
3903
|
+
}
|
|
3904
|
+
function operationsForSelectedAppBuildSources(operations, selected) {
|
|
3905
|
+
const keptComponentIds = new Set(operations.flatMap((operation) => {
|
|
3906
|
+
if (operation.type === 'upsert_page') {
|
|
3907
|
+
return operation.page.layout.items.filter((item) => !item.sourceId || selected.has(item.sourceId)).map((item) => item.i);
|
|
3908
|
+
}
|
|
3909
|
+
if (operation.type === 'add_tile' && (!operation.tile.sourceId || selected.has(operation.tile.sourceId)))
|
|
3910
|
+
return [operation.tile.i];
|
|
3911
|
+
return [];
|
|
3912
|
+
}));
|
|
3913
|
+
return operations.flatMap((operation) => {
|
|
3914
|
+
if (operation.type === 'upsert_source')
|
|
3915
|
+
return selected.has(operation.source.id) ? [operation] : [];
|
|
3916
|
+
if (operation.type === 'set_review_task') {
|
|
3917
|
+
return !operation.task.sourceId || selected.has(operation.task.sourceId) ? [operation] : [];
|
|
3918
|
+
}
|
|
3919
|
+
if (operation.type === 'upsert_page') {
|
|
3920
|
+
const items = operation.page.layout.items.filter((item) => !item.sourceId || selected.has(item.sourceId));
|
|
3921
|
+
return [{
|
|
3922
|
+
...operation,
|
|
3923
|
+
page: {
|
|
3924
|
+
...operation.page,
|
|
3925
|
+
layout: {
|
|
3926
|
+
...operation.page.layout,
|
|
3927
|
+
items,
|
|
3928
|
+
responsive: responsiveLayoutsForDashboardItems(items),
|
|
3929
|
+
},
|
|
3930
|
+
},
|
|
3931
|
+
}];
|
|
3932
|
+
}
|
|
3933
|
+
if (operation.type === 'set_requirements') {
|
|
3934
|
+
const coverage = (operation.coverage ?? []).map((item) => {
|
|
3935
|
+
const sourceIds = item.sourceIds.filter((sourceId) => selected.has(sourceId));
|
|
3936
|
+
const componentIds = item.componentIds.filter((componentId) => keptComponentIds.has(componentId));
|
|
3937
|
+
return {
|
|
3938
|
+
...item,
|
|
3939
|
+
sourceIds,
|
|
3940
|
+
componentIds,
|
|
3941
|
+
status: sourceIds.length && componentIds.length ? item.status : 'gap',
|
|
3942
|
+
};
|
|
3943
|
+
});
|
|
3944
|
+
return [{ ...operation, coverage }];
|
|
3945
|
+
}
|
|
3946
|
+
if (operation.type === 'set_coverage') {
|
|
3947
|
+
return [{
|
|
3948
|
+
...operation,
|
|
3949
|
+
coverage: operation.coverage.map((item) => {
|
|
3950
|
+
const sourceIds = item.sourceIds.filter((sourceId) => selected.has(sourceId));
|
|
3951
|
+
const componentIds = item.componentIds.filter((componentId) => keptComponentIds.has(componentId));
|
|
3952
|
+
return { ...item, sourceIds, componentIds, status: sourceIds.length && componentIds.length ? item.status : 'gap' };
|
|
3953
|
+
}),
|
|
3954
|
+
}];
|
|
3955
|
+
}
|
|
3956
|
+
return [operation];
|
|
3957
|
+
});
|
|
3958
|
+
}
|
|
3959
|
+
/**
|
|
3960
|
+
* Rehydrate the canonical v3 source registry when a Git-owned App is opened in
|
|
3961
|
+
* Studio. Published dashboard documents intentionally keep the immutable
|
|
3962
|
+
* source binding on each tile; the mutable local draft must restore that
|
|
3963
|
+
* registry before core aggregate validation runs. This is metadata-only and
|
|
3964
|
+
* performs no request-time block scan. Current lifecycle/fingerprint checks
|
|
3965
|
+
* remain the responsibility of preview and publication preflight.
|
|
3966
|
+
*/
|
|
3967
|
+
function rehydratePublishedAppSources(pages) {
|
|
3968
|
+
const sources = new Map();
|
|
3969
|
+
const reviewTasks = new Map();
|
|
3970
|
+
let requiresReviewPolicy = false;
|
|
3971
|
+
const normalizedPages = pages.map((page) => ({
|
|
3972
|
+
...page,
|
|
3973
|
+
layout: {
|
|
3974
|
+
...page.layout,
|
|
3975
|
+
items: page.layout.items.map((tile) => {
|
|
3976
|
+
const hasDataSource = Boolean(tile.block || tile.semantic || tile.draftAnalysis || tile.aiPin);
|
|
3977
|
+
if (!hasDataSource)
|
|
3978
|
+
return tile;
|
|
3979
|
+
const blockRef = tile.block ? ('ref' in tile.block ? tile.block.ref : tile.block.blockId) : undefined;
|
|
3980
|
+
const sourceRef = blockRef
|
|
3981
|
+
?? tile.draftAnalysis?.ref
|
|
3982
|
+
?? tile.semantic?.id
|
|
3983
|
+
?? tile.aiPin?.id
|
|
3984
|
+
?? `${page.id}/${tile.i}`;
|
|
3985
|
+
const sourceRevision = tile.sourceRevision
|
|
3986
|
+
?? tile.review?.sourceFingerprint
|
|
3987
|
+
?? tile.draftAnalysis?.artifactFingerprint
|
|
3988
|
+
?? tile.semantic?.definitionFingerprint;
|
|
3989
|
+
const sourceId = tile.sourceId
|
|
3990
|
+
?? `published:${createHash('sha256').update(`${page.id}\0${tile.i}\0${sourceRef}`).digest('hex').slice(0, 20)}`;
|
|
3991
|
+
const certified = Boolean(tile.block
|
|
3992
|
+
&& sourceRevision
|
|
3993
|
+
&& tile.trustState === 'certified'
|
|
3994
|
+
&& tile.review?.status !== 'required');
|
|
3995
|
+
const kind = tile.semantic
|
|
3996
|
+
? 'governed_semantic'
|
|
3997
|
+
: tile.draftAnalysis
|
|
3998
|
+
? 'review_dql'
|
|
3999
|
+
: tile.block
|
|
4000
|
+
? 'block'
|
|
4001
|
+
: 'text';
|
|
4002
|
+
const nextSource = {
|
|
4003
|
+
id: sourceId,
|
|
4004
|
+
kind,
|
|
4005
|
+
sourceRef,
|
|
4006
|
+
qualifiedIdentity: tile.title || sourceRef,
|
|
4007
|
+
...(tile.block ? { sourcePath: sourceRef, executionRef: sourceRef } : {}),
|
|
4008
|
+
...(sourceRevision ? { sourceRevision, sourceFingerprint: sourceRevision } : {}),
|
|
4009
|
+
lifecycle: certified ? 'certified' : tile.draftAnalysis ? 'review' : 'unknown',
|
|
4010
|
+
capabilities: {
|
|
4011
|
+
measures: [],
|
|
4012
|
+
dimensions: [],
|
|
4013
|
+
outputs: [],
|
|
4014
|
+
filters: Array.from(new Set((tile.filterBindings ?? [])
|
|
4015
|
+
.filter((binding) => binding.capability !== 'unsupported')
|
|
4016
|
+
.map((binding) => binding.filter))),
|
|
4017
|
+
allowedVisualizations: Array.from(new Set([
|
|
4018
|
+
tile.viz.type,
|
|
4019
|
+
...(tile.display?.allowedVisualizations ?? []),
|
|
4020
|
+
])),
|
|
4021
|
+
parameters: [],
|
|
4022
|
+
},
|
|
4023
|
+
trustState: certified ? 'certified' : 'review_required',
|
|
4024
|
+
reviewStatus: certified ? 'not_required' : 'required',
|
|
4025
|
+
};
|
|
4026
|
+
const previous = sources.get(sourceId);
|
|
4027
|
+
if (!previous) {
|
|
4028
|
+
sources.set(sourceId, nextSource);
|
|
4029
|
+
}
|
|
4030
|
+
else if (previous.sourceRef !== nextSource.sourceRef
|
|
4031
|
+
|| previous.sourceRevision !== nextSource.sourceRevision
|
|
4032
|
+
|| previous.trustState !== nextSource.trustState) {
|
|
4033
|
+
requiresReviewPolicy = true;
|
|
4034
|
+
sources.set(sourceId, {
|
|
4035
|
+
...previous,
|
|
4036
|
+
lifecycle: 'unknown',
|
|
4037
|
+
trustState: 'review_required',
|
|
4038
|
+
reviewStatus: 'required',
|
|
4039
|
+
});
|
|
4040
|
+
reviewTasks.set(`published-source-${sourceId}`, {
|
|
4041
|
+
id: `published-source-${createHash('sha256').update(sourceId).digest('hex').slice(0, 12)}`,
|
|
4042
|
+
message: `${tile.title || sourceRef} has conflicting published source bindings. Refresh or replace it before publication.`,
|
|
4043
|
+
status: 'open',
|
|
4044
|
+
sourceId,
|
|
4045
|
+
pageId: page.id,
|
|
4046
|
+
tileId: tile.i,
|
|
4047
|
+
});
|
|
4048
|
+
}
|
|
4049
|
+
if (!certified) {
|
|
4050
|
+
requiresReviewPolicy = true;
|
|
4051
|
+
reviewTasks.set(`published-source-${sourceId}`, {
|
|
4052
|
+
id: `published-source-${createHash('sha256').update(sourceId).digest('hex').slice(0, 12)}`,
|
|
4053
|
+
message: sourceRevision
|
|
4054
|
+
? `${tile.title || sourceRef} must be refreshed against the current project source before publication.`
|
|
4055
|
+
: `${tile.title || sourceRef} has no published source revision. Replace or refresh it before publication.`,
|
|
4056
|
+
status: 'open',
|
|
4057
|
+
sourceId,
|
|
4058
|
+
pageId: page.id,
|
|
4059
|
+
tileId: tile.i,
|
|
4060
|
+
});
|
|
4061
|
+
}
|
|
4062
|
+
return tile.sourceId === sourceId ? tile : { ...tile, sourceId, ...(sourceRevision ? { sourceRevision } : {}) };
|
|
4063
|
+
}),
|
|
4064
|
+
},
|
|
4065
|
+
}));
|
|
4066
|
+
return {
|
|
4067
|
+
pages: normalizedPages,
|
|
4068
|
+
sources: Array.from(sources.values()),
|
|
4069
|
+
reviewTasks: Array.from(reviewTasks.values()),
|
|
4070
|
+
requiresReviewPolicy,
|
|
4071
|
+
};
|
|
4072
|
+
}
|
|
4073
|
+
export function createStoredAppBuildDraft(projectRoot, input) {
|
|
4074
|
+
const baseAppId = cleanString(input.baseAppId);
|
|
4075
|
+
const base = baseAppId ? loadAppById(projectRoot, baseAppId) : null;
|
|
4076
|
+
if (baseAppId && !base)
|
|
4077
|
+
throw new Error(`App "${baseAppId}" not found.`);
|
|
4078
|
+
const name = cleanString(input.name) || base?.app.name || cleanString(input.goal) || 'Untitled App';
|
|
4079
|
+
const appId = base?.app.id || cleanString(input.appId) || suggestAppId(name);
|
|
4080
|
+
const id = `build_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
4081
|
+
const pageId = 'overview';
|
|
4082
|
+
const emptyItems = [];
|
|
4083
|
+
const blankPage = {
|
|
4084
|
+
version: 2,
|
|
4085
|
+
id: pageId,
|
|
4086
|
+
metadata: {
|
|
4087
|
+
title: 'Overview',
|
|
4088
|
+
description: cleanString(input.goal) || `Local draft canvas for ${name}`,
|
|
4089
|
+
domain: cleanString(input.domain) || base?.app.domain || 'general',
|
|
4090
|
+
audience: cleanString(input.audience) || base?.app.audience || 'stakeholders',
|
|
4091
|
+
visibility: 'private',
|
|
4092
|
+
lifecycle: 'draft',
|
|
4093
|
+
},
|
|
4094
|
+
layout: {
|
|
4095
|
+
kind: 'grid', cols: 12, rowHeight: 80, items: emptyItems,
|
|
4096
|
+
responsive: {
|
|
4097
|
+
wide: { kind: 'grid', cols: 12, rowHeight: 80, items: emptyItems },
|
|
4098
|
+
medium: { kind: 'grid', cols: 6, rowHeight: 80, items: emptyItems },
|
|
4099
|
+
narrow: { kind: 'grid', cols: 1, rowHeight: 80, items: emptyItems },
|
|
4100
|
+
},
|
|
4101
|
+
},
|
|
4102
|
+
};
|
|
4103
|
+
const basePages = base
|
|
4104
|
+
? findDashboardsForApp(base.appDir)
|
|
4105
|
+
.map((path) => loadDashboardDocument(path).document)
|
|
4106
|
+
.filter((page) => Boolean(page))
|
|
4107
|
+
: [];
|
|
4108
|
+
const template = input.template ?? 'blank';
|
|
4109
|
+
const published = rehydratePublishedAppSources(basePages);
|
|
4110
|
+
const pages = published.pages.length ? published.pages : [applyAppStudioTemplate(blankPage, template, name)];
|
|
4111
|
+
const draft = createAppBuildDraft({
|
|
4112
|
+
id,
|
|
4113
|
+
appId,
|
|
4114
|
+
name,
|
|
4115
|
+
...(base ? { baseApp: { appId: base.app.id, fingerprint: fingerprintAppDirectory(base.appDir) } } : {}),
|
|
4116
|
+
authoringMode: input.authoringMode === 'manual' ? 'manual' : 'ai',
|
|
4117
|
+
template,
|
|
4118
|
+
sourcePolicy: published.requiresReviewPolicy ? 'include_review_required' : input.sourcePolicy,
|
|
4119
|
+
frame: {
|
|
4120
|
+
goal: cleanString(input.goal) || `Create ${name}`,
|
|
4121
|
+
audience: cleanString(input.audience) || base?.app.audience || 'stakeholders',
|
|
4122
|
+
metrics: [], dimensions: [], filters: [], desiredOutput: 'One responsive analytical page',
|
|
4123
|
+
// Clarifications are returned only when the App-specific planner finds a
|
|
4124
|
+
// material ambiguity. A synthetic unanswered question here used to make
|
|
4125
|
+
// every new AI draft enter a UI dead-end before planning even ran.
|
|
4126
|
+
clarificationQuestions: [],
|
|
4127
|
+
},
|
|
4128
|
+
sources: published.sources,
|
|
4129
|
+
pages,
|
|
4130
|
+
reviewTasks: published.reviewTasks,
|
|
4131
|
+
});
|
|
4132
|
+
writeStoredAppBuildDraft(projectRoot, draft);
|
|
4133
|
+
return draft;
|
|
4134
|
+
}
|
|
4135
|
+
/**
|
|
4136
|
+
* Adds an Ask result to the same local AppBuildDraft used by manual and AI
|
|
4137
|
+
* authoring. Certified answers retain their block identity. Exploratory DQL is
|
|
4138
|
+
* materialized as an app-scoped local artifact and remains review-required;
|
|
4139
|
+
* SQL is never embedded in the dashboard JSON or written to Project source.
|
|
4140
|
+
*/
|
|
4141
|
+
export function addAskResultToAppBuildDraft(projectRoot, draftId, input) {
|
|
4142
|
+
const draft = loadStoredAppBuildDraft(projectRoot, draftId);
|
|
4143
|
+
if (!draft)
|
|
4144
|
+
throw new Error('App Build Draft not found.');
|
|
4145
|
+
if (input.expectedRevision !== draft.revision || input.expectedProposalHash !== draft.proposalHash) {
|
|
4146
|
+
throw new Error('APP_BUILD_REVISION_CONFLICT: refresh the local draft before adding this Ask result.');
|
|
4147
|
+
}
|
|
4148
|
+
const page = draft.pages.find((candidate) => candidate.id === cleanString(input.pageId)) ?? draft.pages[0];
|
|
4149
|
+
if (!page)
|
|
4150
|
+
throw new Error('The local App draft needs a page before adding an Ask result.');
|
|
4151
|
+
const question = cleanString(input.question).slice(0, 500);
|
|
4152
|
+
const answer = cleanString(input.answer).slice(0, 40_000);
|
|
4153
|
+
const title = (cleanString(input.title) || titleForGapQuestion(question || 'Ask result')).slice(0, 120);
|
|
4154
|
+
const requestedBlockId = cleanString(input.certifiedBlockId);
|
|
4155
|
+
const dqlSource = cleanString(input.dqlSource);
|
|
4156
|
+
const sql = cleanString(input.sql);
|
|
4157
|
+
if (dqlSource.length > 400_000 || sql.length > 200_000)
|
|
4158
|
+
throw new Error('Ask result source exceeds the local App artifact limit.');
|
|
4159
|
+
const sourceIdentity = requestedBlockId || dqlSource || sql || `${question}\n${answer}`;
|
|
4160
|
+
if (!sourceIdentity)
|
|
4161
|
+
throw new Error('The Ask result did not include reusable evidence.');
|
|
4162
|
+
const identity = createHash('sha256').update(sourceIdentity).digest('hex').slice(0, 16);
|
|
4163
|
+
const tileId = `ask-${identity}`;
|
|
4164
|
+
if (draft.pages.some((candidate) => candidate.layout.items.some((tile) => tile.i === tileId))) {
|
|
4165
|
+
return { ok: true, draft, pageId: page.id, tileId, deduped: true };
|
|
4166
|
+
}
|
|
4167
|
+
const nextY = page.layout.items.reduce((max, tile) => Math.max(max, tile.y + tile.h), 0);
|
|
4168
|
+
const viz = normalizeGeneratedViz(input.visualization);
|
|
4169
|
+
const operations = [];
|
|
4170
|
+
let artifactPath;
|
|
4171
|
+
let artifactWasCreated = false;
|
|
4172
|
+
let tile;
|
|
4173
|
+
if (requestedBlockId) {
|
|
4174
|
+
const block = collectBlockCandidates(projectRoot).find((candidate) => candidate.status === 'certified'
|
|
4175
|
+
&& (candidate.id === requestedBlockId || candidate.name === requestedBlockId || candidate.path === requestedBlockId));
|
|
4176
|
+
if (!block)
|
|
4177
|
+
throw new Error(`Certified block "${requestedBlockId}" is no longer available.`);
|
|
4178
|
+
const sourceId = `block:${block.id}`;
|
|
4179
|
+
operations.push({
|
|
4180
|
+
type: 'upsert_source',
|
|
4181
|
+
source: {
|
|
4182
|
+
id: sourceId,
|
|
4183
|
+
kind: 'certified_block',
|
|
4184
|
+
sourceRef: block.id,
|
|
4185
|
+
sourceFingerprint: block.fingerprint,
|
|
4186
|
+
trustState: 'certified',
|
|
4187
|
+
reviewStatus: 'not_required',
|
|
4188
|
+
},
|
|
4189
|
+
});
|
|
4190
|
+
tile = {
|
|
4191
|
+
i: tileId, x: 0, y: nextY, w: 6, h: 4,
|
|
4192
|
+
block: { blockId: block.id },
|
|
4193
|
+
viz: { type: viz === 'table' ? normalizeVizType(block.chartType) : viz },
|
|
4194
|
+
title,
|
|
4195
|
+
sourceClass: 'certified_block',
|
|
4196
|
+
filterBindings: block.filterIds.map((filter) => ({ filter, binding: filter, mode: 'predicate', capability: 'preflight_required' })),
|
|
4197
|
+
sourceEvidence: [{ source: block.id, reason: question ? `Ask answer for: ${question}` : 'Added from a certified Ask answer.', kind: 'certified_block', trustState: 'certified' }],
|
|
4198
|
+
review: { status: 'not_required', sourceFingerprint: block.fingerprint },
|
|
4199
|
+
trustState: 'certified',
|
|
4200
|
+
reviewStatus: 'certified',
|
|
4201
|
+
};
|
|
4202
|
+
}
|
|
4203
|
+
else if (dqlSource || sql) {
|
|
4204
|
+
const sourceId = `review-dql:ask-${identity}`;
|
|
4205
|
+
const sourceRef = `analysis/ask-${identity}.dql`;
|
|
4206
|
+
const source = dqlSource || [
|
|
4207
|
+
`block "ask_${identity}" {`,
|
|
4208
|
+
` domain = "${escapeDqlString(page.metadata.domain ?? 'general')}"`,
|
|
4209
|
+
' type = "custom"',
|
|
4210
|
+
' status = "review"',
|
|
4211
|
+
' owner = "local-ask"',
|
|
4212
|
+
` description = "${escapeDqlString(answer || question || title)}"`,
|
|
4213
|
+
question ? ` llmContext = "${escapeDqlString(`Ask result: ${question}`)}"` : '',
|
|
4214
|
+
' caveats = ["Ask-generated local analysis. Validate joins, grain, filters, and interpretation before promotion."]',
|
|
4215
|
+
' tags = ["app-scoped", "ask-generated", "needs-review"]',
|
|
4216
|
+
'',
|
|
4217
|
+
' query = """',
|
|
4218
|
+
sql,
|
|
4219
|
+
' """',
|
|
4220
|
+
'',
|
|
4221
|
+
' visualization {',
|
|
4222
|
+
` chart = "${escapeDqlString(normalizeGeneratedViz(input.visualization))}"`,
|
|
4223
|
+
' }',
|
|
4224
|
+
'}',
|
|
4225
|
+
'',
|
|
4226
|
+
].filter(Boolean).join('\n');
|
|
4227
|
+
const fingerprint = `sha256:${createHash('sha256').update(source).digest('hex')}`;
|
|
4228
|
+
artifactPath = join(projectRoot, '.dql', 'local', 'app-builds', draft.id, sourceRef);
|
|
4229
|
+
operations.push(...(draft.sourcePolicy === 'include_review_required'
|
|
4230
|
+
? []
|
|
4231
|
+
: [{ type: 'set_source_policy', sourcePolicy: 'include_review_required' }]), {
|
|
4232
|
+
type: 'upsert_source',
|
|
4233
|
+
source: {
|
|
4234
|
+
id: sourceId,
|
|
4235
|
+
kind: 'review_dql',
|
|
4236
|
+
sourceRef,
|
|
4237
|
+
qualifiedIdentity: title,
|
|
4238
|
+
sourceFingerprint: fingerprint,
|
|
4239
|
+
trustState: 'review_required',
|
|
4240
|
+
reviewStatus: 'required',
|
|
4241
|
+
},
|
|
4242
|
+
}, {
|
|
4243
|
+
type: 'set_review_task',
|
|
4244
|
+
task: {
|
|
4245
|
+
id: `review-ask-${identity}`,
|
|
4246
|
+
message: `Validate the joins, grain, filters, and business interpretation for ${title}.`,
|
|
4247
|
+
status: 'open',
|
|
4248
|
+
sourceId,
|
|
4249
|
+
pageId: page.id,
|
|
4250
|
+
tileId,
|
|
4251
|
+
},
|
|
4252
|
+
});
|
|
4253
|
+
tile = {
|
|
4254
|
+
i: tileId, x: 0, y: nextY, w: 6, h: 4,
|
|
4255
|
+
draftAnalysis: { ref: sourceRef, artifactFingerprint: fingerprint },
|
|
4256
|
+
viz: { type: viz },
|
|
4257
|
+
title,
|
|
4258
|
+
sourceClass: 'exploratory_analysis',
|
|
4259
|
+
sourceEvidence: [{ source: sourceRef, reason: question ? `Ask generated this private analysis for: ${question}` : 'Ask generated this private analysis.', kind: 'review_dql', trustState: 'review_required' }],
|
|
4260
|
+
review: { status: 'required', sourceFingerprint: fingerprint },
|
|
4261
|
+
trustState: 'review_required',
|
|
4262
|
+
reviewStatus: 'review_required',
|
|
4263
|
+
};
|
|
4264
|
+
if (!existsSync(artifactPath)) {
|
|
4265
|
+
mkdirSync(dirname(artifactPath), { recursive: true });
|
|
4266
|
+
writeFileSync(artifactPath, source, 'utf-8');
|
|
4267
|
+
artifactWasCreated = true;
|
|
4268
|
+
}
|
|
4269
|
+
}
|
|
4270
|
+
else {
|
|
4271
|
+
const sourceId = `text:ask-${identity}`;
|
|
4272
|
+
operations.push(...(draft.sourcePolicy === 'include_review_required'
|
|
4273
|
+
? []
|
|
4274
|
+
: [{ 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 } });
|
|
4275
|
+
tile = {
|
|
4276
|
+
i: tileId, x: 0, y: nextY, w: 12, h: 2,
|
|
4277
|
+
text: { markdown: answer || question },
|
|
4278
|
+
viz: { type: 'text' },
|
|
4279
|
+
title,
|
|
4280
|
+
sourceClass: 'narrative',
|
|
4281
|
+
sourceEvidence: [{ source: `ask:${identity}`, reason: 'Saved from Ask as an editable review-required narrative.', kind: 'text', trustState: 'review_required' }],
|
|
4282
|
+
review: { status: 'required' },
|
|
4283
|
+
trustState: 'review_required',
|
|
4284
|
+
reviewStatus: 'review_required',
|
|
4285
|
+
};
|
|
4286
|
+
}
|
|
4287
|
+
operations.push({ type: 'add_tile', pageId: page.id, tile });
|
|
4288
|
+
try {
|
|
4289
|
+
const next = applyAppBuildDraftOperations(draft, draft.revision, operations);
|
|
4290
|
+
writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: draft.revision, operations });
|
|
4291
|
+
return { ok: true, draft: next, pageId: page.id, tileId, deduped: false };
|
|
4292
|
+
}
|
|
4293
|
+
catch (error) {
|
|
4294
|
+
if (artifactWasCreated && artifactPath)
|
|
4295
|
+
rmSync(artifactPath, { force: true });
|
|
4296
|
+
throw error;
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
function applyAppStudioTemplate(page, template, appName) {
|
|
4300
|
+
if (template === 'blank')
|
|
4301
|
+
return page;
|
|
4302
|
+
const intro = template === 'executive_brief'
|
|
4303
|
+
? `# ${appName}\n\nState the decision this brief supports, then add governed evidence below.`
|
|
4304
|
+
: template === 'operational_dashboard'
|
|
4305
|
+
? `# ${appName}\n\nMonitor current performance, changes, and the details that require action.`
|
|
4306
|
+
: `# ${appName}\n\nDocument the question, findings, caveats, and evidence for this investigation.`;
|
|
4307
|
+
const items = [{
|
|
4308
|
+
i: 'template-introduction', x: 0, y: 0, w: 12, h: 2,
|
|
4309
|
+
title: template === 'investigation' ? 'Investigation question' : 'Executive context',
|
|
4310
|
+
sectionId: 'exec_summary',
|
|
4311
|
+
text: { markdown: intro },
|
|
4312
|
+
viz: { type: 'text' },
|
|
4313
|
+
sourceClass: 'narrative',
|
|
4314
|
+
trustState: 'draft_ready',
|
|
4315
|
+
reviewStatus: 'draft_ready',
|
|
4316
|
+
}];
|
|
4317
|
+
const sections = template === 'investigation'
|
|
4318
|
+
? [
|
|
4319
|
+
{ id: 'exec_summary', title: 'Question', kind: 'exec_summary', order: 0 },
|
|
4320
|
+
{ id: 'insight', title: 'Findings and comparisons', kind: 'insight', order: 1 },
|
|
4321
|
+
{ id: 'appendix', title: 'Caveats and evidence', kind: 'appendix', order: 2 },
|
|
4322
|
+
]
|
|
4323
|
+
: [
|
|
4324
|
+
{ id: 'exec_summary', title: 'Executive summary', kind: 'exec_summary', order: 0 },
|
|
4325
|
+
{ id: 'kpi_band', title: 'Key metrics', kind: 'kpi_band', order: 1 },
|
|
4326
|
+
{ id: 'insight', title: template === 'executive_brief' ? 'Decision evidence' : 'Trends and drivers', kind: 'insight', order: 2 },
|
|
4327
|
+
{ id: 'appendix', title: 'Detail and evidence', kind: 'appendix', order: 3 },
|
|
4328
|
+
];
|
|
4329
|
+
return {
|
|
4330
|
+
...page,
|
|
4331
|
+
sections,
|
|
4332
|
+
layout: {
|
|
4333
|
+
...page.layout,
|
|
4334
|
+
items,
|
|
4335
|
+
responsive: responsiveLayoutsForDashboardItems(items),
|
|
4336
|
+
},
|
|
4337
|
+
};
|
|
4338
|
+
}
|
|
4339
|
+
function loadStoredAppBuildDraft(projectRoot, id) {
|
|
4340
|
+
const clean = cleanString(id);
|
|
4341
|
+
if (!clean || !/^[a-z0-9_:-]+$/i.test(clean))
|
|
4342
|
+
return null;
|
|
4343
|
+
const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
4344
|
+
try {
|
|
4345
|
+
const stored = storage.getAppBuildDraft(clean);
|
|
4346
|
+
if (stored) {
|
|
4347
|
+
const migrated = migrateLegacyAppBuildDraft(projectRoot, stored);
|
|
4348
|
+
if (migrated && stored.version !== migrated.version)
|
|
4349
|
+
storage.saveAppBuildDraft(migrated);
|
|
4350
|
+
return migrated;
|
|
4351
|
+
}
|
|
4352
|
+
const legacyPath = join(appBuildDraftDir(projectRoot), `${clean}.json`);
|
|
4353
|
+
if (!existsSync(legacyPath))
|
|
4354
|
+
return null;
|
|
4355
|
+
const migrated = migrateLegacyAppBuildDraft(projectRoot, JSON.parse(readFileSync(legacyPath, 'utf-8')));
|
|
4356
|
+
if (!migrated)
|
|
4357
|
+
return null;
|
|
4358
|
+
storage.saveAppBuildDraft(migrated);
|
|
4359
|
+
return migrated;
|
|
4360
|
+
}
|
|
4361
|
+
catch {
|
|
4362
|
+
return null;
|
|
4363
|
+
}
|
|
4364
|
+
finally {
|
|
4365
|
+
storage.close();
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4368
|
+
export function writeStoredAppBuildDraft(projectRoot, draft, input = {}) {
|
|
4369
|
+
const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
4370
|
+
try {
|
|
4371
|
+
storage.saveAppBuildDraft(draft, input);
|
|
4372
|
+
}
|
|
4373
|
+
finally {
|
|
4374
|
+
storage.close();
|
|
4375
|
+
}
|
|
4376
|
+
}
|
|
4377
|
+
function migrateLegacyAppBuildDraft(projectRoot, value) {
|
|
4378
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
4379
|
+
return null;
|
|
4380
|
+
const legacy = value;
|
|
4381
|
+
if (legacy.version === 3)
|
|
4382
|
+
return legacy;
|
|
4383
|
+
if (legacy.version === 2)
|
|
4384
|
+
return migrateV2AppBuildDraft(projectRoot, legacy);
|
|
4385
|
+
if (legacy.version !== 1 || typeof legacy.id !== 'string' || typeof legacy.appId !== 'string')
|
|
4386
|
+
return null;
|
|
4387
|
+
const frame = (legacy.frame && typeof legacy.frame === 'object' ? legacy.frame : {});
|
|
4388
|
+
const clarificationQuestions = Array.isArray(frame.clarificationQuestions)
|
|
4389
|
+
? frame.clarificationQuestions.flatMap((raw) => {
|
|
4390
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
4391
|
+
return [];
|
|
4392
|
+
const question = raw;
|
|
4393
|
+
if (typeof question.id !== 'string' || typeof question.question !== 'string')
|
|
4394
|
+
return [];
|
|
4395
|
+
const choices = Array.isArray(question.choices)
|
|
4396
|
+
? question.choices.map((choice, index) => typeof choice === 'string'
|
|
4397
|
+
? { id: slugify(choice) || `choice-${index + 1}`, label: choice }
|
|
4398
|
+
: choice).filter((choice) => Boolean(choice && typeof choice === 'object' && 'id' in choice && 'label' in choice))
|
|
4399
|
+
: [];
|
|
4400
|
+
return [{ id: question.id, question: question.question, choices, required: question.required !== false }];
|
|
4401
|
+
})
|
|
4402
|
+
: [];
|
|
4403
|
+
const migrated = createAppBuildDraft({
|
|
4404
|
+
id: legacy.id,
|
|
4405
|
+
appId: legacy.appId,
|
|
4406
|
+
name: typeof legacy.name === 'string' ? legacy.name : (typeof frame.goal === 'string' ? frame.goal : legacy.appId),
|
|
4407
|
+
authoringMode: legacy.authoringMode === 'manual' ? 'manual' : 'ai',
|
|
4408
|
+
sourcePolicy: legacy.sourcePolicy === 'include_review_required' ? 'include_review_required' : 'governed_only',
|
|
4409
|
+
frame: {
|
|
4410
|
+
goal: typeof frame.goal === 'string' ? frame.goal : `Create ${legacy.appId}`,
|
|
4411
|
+
decision: typeof frame.decision === 'string' ? frame.decision : undefined,
|
|
4412
|
+
audience: typeof frame.audience === 'string' ? frame.audience : undefined,
|
|
4413
|
+
metrics: Array.isArray(frame.metrics) ? frame.metrics.filter((item) => typeof item === 'string') : [],
|
|
4414
|
+
dimensions: Array.isArray(frame.dimensions) ? frame.dimensions.filter((item) => typeof item === 'string') : [],
|
|
4415
|
+
filters: Array.isArray(frame.filters) ? frame.filters.filter((item) => typeof item === 'string') : [],
|
|
4416
|
+
desiredOutput: typeof frame.desiredOutput === 'string' ? frame.desiredOutput : undefined,
|
|
4417
|
+
clarificationQuestions,
|
|
4418
|
+
},
|
|
4419
|
+
sources: Array.isArray(legacy.sources) ? legacy.sources : [],
|
|
4420
|
+
pages: Array.isArray(legacy.pages) ? legacy.pages : [],
|
|
4421
|
+
reviewTasks: Array.isArray(legacy.reviewTasks) ? legacy.reviewTasks : [],
|
|
4422
|
+
now: typeof legacy.createdAt === 'string' ? legacy.createdAt : undefined,
|
|
4423
|
+
});
|
|
4424
|
+
return {
|
|
4425
|
+
...migrated,
|
|
4426
|
+
revision: typeof legacy.revision === 'number' ? legacy.revision : migrated.revision,
|
|
4427
|
+
updatedAt: typeof legacy.updatedAt === 'string' ? legacy.updatedAt : migrated.updatedAt,
|
|
4428
|
+
};
|
|
4429
|
+
}
|
|
4430
|
+
function migrateV2AppBuildDraft(projectRoot, legacy) {
|
|
4431
|
+
if (typeof legacy.id !== 'string' || typeof legacy.appId !== 'string')
|
|
4432
|
+
return null;
|
|
4433
|
+
const declarations = collectBlockCandidates(projectRoot, { preserveDuplicates: true });
|
|
4434
|
+
const oldSources = Array.isArray(legacy.sources) ? legacy.sources : [];
|
|
4435
|
+
const idMap = new Map();
|
|
4436
|
+
const revisionById = new Map();
|
|
4437
|
+
const pathById = new Map();
|
|
4438
|
+
const migrationTasks = [];
|
|
4439
|
+
const sources = oldSources.flatMap((raw, index) => {
|
|
4440
|
+
const oldId = cleanString(raw.id) || `legacy-source-${index + 1}`;
|
|
4441
|
+
const kind = cleanString(raw.kind);
|
|
4442
|
+
if (!['block', 'certified_block', 'review_block'].includes(kind)) {
|
|
4443
|
+
idMap.set(oldId, oldId);
|
|
4444
|
+
return [{ ...raw, id: oldId }];
|
|
4445
|
+
}
|
|
4446
|
+
const sourceRef = cleanString(raw.sourceRef);
|
|
4447
|
+
const fingerprint = cleanString(raw.sourceFingerprint);
|
|
4448
|
+
const matches = declarations.filter((candidate) => (candidate.path === sourceRef
|
|
4449
|
+
|| candidate.id === sourceRef
|
|
4450
|
+
|| candidate.name === sourceRef
|
|
4451
|
+
|| candidate.path.endsWith(`/${sourceRef}`)) && (!fingerprint || candidate.fingerprint === fingerprint));
|
|
4452
|
+
if (matches.length === 1) {
|
|
4453
|
+
const candidate = matches[0];
|
|
4454
|
+
const id = canonicalAppBlockSourceId(candidate);
|
|
4455
|
+
idMap.set(oldId, id);
|
|
4456
|
+
revisionById.set(id, candidate.fingerprint);
|
|
4457
|
+
pathById.set(id, candidate.path);
|
|
4458
|
+
const certified = candidate.status === 'certified';
|
|
4459
|
+
return [{
|
|
4460
|
+
id,
|
|
4461
|
+
kind: 'block',
|
|
4462
|
+
sourceRef: candidate.path,
|
|
4463
|
+
qualifiedIdentity: `${candidate.domain || 'global'}::block::${candidate.name}`,
|
|
4464
|
+
sourcePath: candidate.path,
|
|
4465
|
+
executionRef: candidate.path,
|
|
4466
|
+
sourceRevision: candidate.fingerprint,
|
|
4467
|
+
sourceFingerprint: candidate.fingerprint,
|
|
4468
|
+
lifecycle: certified ? 'certified' : candidate.status === 'review' ? 'review' : 'draft',
|
|
4469
|
+
capabilities: {
|
|
4470
|
+
measures: [],
|
|
4471
|
+
dimensions: candidate.dimensionIds,
|
|
4472
|
+
outputs: [],
|
|
4473
|
+
filters: candidate.filterIds,
|
|
4474
|
+
chartType: candidate.chartType,
|
|
4475
|
+
allowedVisualizations: candidate.chartType ? [candidate.chartType] : [],
|
|
4476
|
+
parameters: [],
|
|
4477
|
+
},
|
|
4478
|
+
trustState: certified ? 'certified' : 'review_required',
|
|
4479
|
+
reviewStatus: certified ? 'not_required' : 'required',
|
|
4480
|
+
}];
|
|
4481
|
+
}
|
|
4482
|
+
idMap.set(oldId, oldId);
|
|
4483
|
+
migrationTasks.push({
|
|
4484
|
+
id: `legacy-source-${createHash('sha256').update(oldId).digest('hex').slice(0, 12)}`,
|
|
4485
|
+
message: matches.length > 1
|
|
4486
|
+
? `Legacy source ${sourceRef || oldId} is ambiguous. Select the exact path and revision before previewing or publishing.`
|
|
4487
|
+
: `Legacy source ${sourceRef || oldId} no longer resolves. Replace or remove it before previewing or publishing.`,
|
|
4488
|
+
status: 'open',
|
|
4489
|
+
sourceId: oldId,
|
|
4490
|
+
});
|
|
4491
|
+
return [{
|
|
4492
|
+
id: oldId,
|
|
4493
|
+
kind: 'block',
|
|
4494
|
+
sourceRef: sourceRef || oldId,
|
|
4495
|
+
qualifiedIdentity: cleanString(raw.qualifiedIdentity) || sourceRef || oldId,
|
|
4496
|
+
sourceRevision: fingerprint || undefined,
|
|
4497
|
+
sourceFingerprint: fingerprint || undefined,
|
|
4498
|
+
lifecycle: 'unknown',
|
|
4499
|
+
capabilities: { measures: [], dimensions: [], outputs: [], filters: [], allowedVisualizations: [], parameters: [] },
|
|
4500
|
+
trustState: 'review_required',
|
|
4501
|
+
reviewStatus: 'required',
|
|
4502
|
+
}];
|
|
4503
|
+
});
|
|
4504
|
+
const pages = (Array.isArray(legacy.pages) ? legacy.pages : []).map((page) => ({
|
|
4505
|
+
...page,
|
|
4506
|
+
layout: {
|
|
4507
|
+
...page.layout,
|
|
4508
|
+
items: page.layout.items.map((item) => {
|
|
4509
|
+
if (item.sourceId)
|
|
4510
|
+
return item;
|
|
4511
|
+
const blockRef = item.block
|
|
4512
|
+
? ('ref' in item.block ? item.block.ref : item.block.blockId)
|
|
4513
|
+
: undefined;
|
|
4514
|
+
if (!blockRef)
|
|
4515
|
+
return item;
|
|
4516
|
+
const oldSource = oldSources.find((source) => {
|
|
4517
|
+
const ref = cleanString(source.sourceRef);
|
|
4518
|
+
return ref === blockRef || cleanString(source.id) === `block:${blockRef}` || cleanString(source.id) === `review-block:${blockRef}`;
|
|
4519
|
+
});
|
|
4520
|
+
const sourceId = oldSource ? idMap.get(cleanString(oldSource.id)) : undefined;
|
|
4521
|
+
if (!sourceId)
|
|
4522
|
+
return item;
|
|
4523
|
+
return {
|
|
4524
|
+
...item,
|
|
4525
|
+
sourceId,
|
|
4526
|
+
sourceRevision: revisionById.get(sourceId) ?? (cleanString(oldSource?.sourceFingerprint) || undefined),
|
|
4527
|
+
...(pathById.get(sourceId) ? { block: { ref: pathById.get(sourceId) } } : {}),
|
|
4528
|
+
};
|
|
4529
|
+
}),
|
|
4530
|
+
},
|
|
4531
|
+
}));
|
|
4532
|
+
const coverage = (Array.isArray(legacy.coverage) ? legacy.coverage : []).map((item) => ({
|
|
4533
|
+
...item,
|
|
4534
|
+
sourceIds: item.sourceIds.map((id) => idMap.get(id) ?? id),
|
|
4535
|
+
}));
|
|
4536
|
+
const migratedWithoutHash = {
|
|
4537
|
+
...legacy,
|
|
4538
|
+
version: 3,
|
|
4539
|
+
sources,
|
|
4540
|
+
pages,
|
|
4541
|
+
coverage,
|
|
4542
|
+
reviewTasks: [
|
|
4543
|
+
...(Array.isArray(legacy.reviewTasks) ? legacy.reviewTasks : []),
|
|
4544
|
+
...migrationTasks,
|
|
4545
|
+
],
|
|
4546
|
+
};
|
|
4547
|
+
return {
|
|
4548
|
+
...migratedWithoutHash,
|
|
4549
|
+
proposalHash: appBuildDraftHash({ ...migratedWithoutHash, proposalHash: undefined }),
|
|
4550
|
+
};
|
|
4551
|
+
}
|
|
4552
|
+
export function deleteStoredAppBuildDraft(projectRoot, draft) {
|
|
4553
|
+
const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
4554
|
+
let deleted;
|
|
4555
|
+
try {
|
|
4556
|
+
deleted = storage.deleteAppBuildDraft(draft.id);
|
|
4557
|
+
}
|
|
4558
|
+
finally {
|
|
4559
|
+
storage.close();
|
|
4560
|
+
}
|
|
4561
|
+
if (!deleted)
|
|
4562
|
+
throw new Error(`App Build Draft not found: ${draft.id}`);
|
|
4563
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
4564
|
+
const recoveryId = `draft-${stamp}-${draft.id}`;
|
|
4565
|
+
const recoveryDir = join(projectRoot, '.dql', 'local', 'trash', 'app-drafts', recoveryId);
|
|
4566
|
+
const localArtifactsDir = join(projectRoot, '.dql', 'local', 'app-builds', draft.id);
|
|
4567
|
+
const recoveryArtifactsDir = join(recoveryDir, 'artifacts');
|
|
4568
|
+
let artifactsMoved = false;
|
|
4569
|
+
try {
|
|
4570
|
+
mkdirSync(recoveryDir, { recursive: true });
|
|
4571
|
+
if (existsSync(localArtifactsDir)) {
|
|
4572
|
+
renameSync(localArtifactsDir, recoveryArtifactsDir);
|
|
4573
|
+
artifactsMoved = true;
|
|
4574
|
+
}
|
|
4575
|
+
writeFileSync(join(recoveryDir, 'recovery.json'), JSON.stringify({
|
|
4576
|
+
version: 1,
|
|
4577
|
+
kind: 'app_build_draft',
|
|
4578
|
+
recoveryId,
|
|
4579
|
+
deletedAt: new Date().toISOString(),
|
|
4580
|
+
draft: deleted.draft,
|
|
4581
|
+
operations: deleted.operations,
|
|
4582
|
+
}, null, 2) + '\n', 'utf-8');
|
|
4583
|
+
}
|
|
4584
|
+
catch (error) {
|
|
4585
|
+
if (artifactsMoved && existsSync(recoveryArtifactsDir)) {
|
|
4586
|
+
mkdirSync(dirname(localArtifactsDir), { recursive: true });
|
|
4587
|
+
renameSync(recoveryArtifactsDir, localArtifactsDir);
|
|
4588
|
+
}
|
|
4589
|
+
const restore = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
4590
|
+
try {
|
|
4591
|
+
restore.restoreAppBuildDraft(deleted.draft, deleted.operations);
|
|
4592
|
+
}
|
|
4593
|
+
finally {
|
|
4594
|
+
restore.close();
|
|
4595
|
+
}
|
|
4596
|
+
rmSync(recoveryDir, { recursive: true, force: true });
|
|
4597
|
+
throw error;
|
|
4598
|
+
}
|
|
4599
|
+
return { ok: true, id: draft.id, recoveryId, recoverableUntilRestart: false };
|
|
4600
|
+
}
|
|
4601
|
+
function restoreStoredAppBuildDraft(projectRoot, recoveryId) {
|
|
4602
|
+
const recoveryDir = join(projectRoot, '.dql', 'local', 'trash', 'app-drafts', recoveryId);
|
|
4603
|
+
const recoveryPath = join(recoveryDir, 'recovery.json');
|
|
4604
|
+
if (!existsSync(recoveryPath))
|
|
4605
|
+
return { ok: false, status: 404, error: 'App recovery bundle not found.' };
|
|
4606
|
+
let payload;
|
|
4607
|
+
try {
|
|
4608
|
+
payload = JSON.parse(readFileSync(recoveryPath, 'utf-8'));
|
|
4609
|
+
}
|
|
4610
|
+
catch {
|
|
4611
|
+
return { ok: false, status: 400, error: 'App draft recovery bundle is invalid.' };
|
|
4612
|
+
}
|
|
4613
|
+
if (payload.kind !== 'app_build_draft' || !payload.draft)
|
|
4614
|
+
return { ok: false, status: 400, error: 'App draft recovery bundle is invalid.' };
|
|
4615
|
+
const recoveredArtifactsDir = join(recoveryDir, 'artifacts');
|
|
4616
|
+
const localArtifactsDir = join(projectRoot, '.dql', 'local', 'app-builds', payload.draft.id);
|
|
4617
|
+
const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
4618
|
+
let artifactsRestored = false;
|
|
4619
|
+
try {
|
|
4620
|
+
if (storage.getAppBuildDraft(payload.draft.id))
|
|
4621
|
+
return { ok: false, status: 409, error: `App Build Draft already exists: ${payload.draft.id}` };
|
|
4622
|
+
if (existsSync(localArtifactsDir))
|
|
4623
|
+
return { ok: false, status: 409, error: `App Build Draft artifacts already exist: ${payload.draft.id}` };
|
|
4624
|
+
if (existsSync(recoveredArtifactsDir)) {
|
|
4625
|
+
mkdirSync(dirname(localArtifactsDir), { recursive: true });
|
|
4626
|
+
renameSync(recoveredArtifactsDir, localArtifactsDir);
|
|
4627
|
+
artifactsRestored = true;
|
|
4628
|
+
}
|
|
4629
|
+
storage.restoreAppBuildDraft(payload.draft, payload.operations ?? []);
|
|
2462
4630
|
}
|
|
2463
4631
|
catch (error) {
|
|
2464
|
-
if (
|
|
2465
|
-
renameSync(
|
|
4632
|
+
if (artifactsRestored && existsSync(localArtifactsDir))
|
|
4633
|
+
renameSync(localArtifactsDir, recoveredArtifactsDir);
|
|
2466
4634
|
throw error;
|
|
2467
4635
|
}
|
|
2468
|
-
|
|
2469
|
-
|
|
4636
|
+
finally {
|
|
4637
|
+
storage.close();
|
|
4638
|
+
}
|
|
4639
|
+
rmSync(recoveryDir, { recursive: true, force: true });
|
|
4640
|
+
return { ok: true, id: payload.draft.id, restoredPath: '.dql/local/apps.sqlite', draft: payload.draft };
|
|
2470
4641
|
}
|
|
2471
|
-
export function
|
|
2472
|
-
const
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
if (
|
|
2477
|
-
|
|
4642
|
+
export function preflightStoredAppBuildDraft(projectRoot, draft) {
|
|
4643
|
+
const errors = [];
|
|
4644
|
+
const blocks = collectBlockCandidates(projectRoot);
|
|
4645
|
+
if (draft.state === 'clarification_required')
|
|
4646
|
+
errors.push('Resolve required Build Frame clarifications before publication.');
|
|
4647
|
+
if (draft.pages.length === 0)
|
|
4648
|
+
errors.push('App Build Draft needs at least one page.');
|
|
4649
|
+
for (const requirement of draft.requirements.filter((item) => appBuildRequirementBlocksPublication(draft, item))) {
|
|
4650
|
+
const coverage = draft.coverage.find((item) => item.requirementId === requirement.id);
|
|
4651
|
+
if (!coverage || coverage.status !== 'covered')
|
|
4652
|
+
errors.push(`Required App question is unresolved: ${requirement.question}`);
|
|
4653
|
+
}
|
|
4654
|
+
for (const task of draft.reviewTasks.filter((item) => appBuildReviewTaskBlocksPublication(draft, item))) {
|
|
4655
|
+
errors.push(`Review task is still open: ${task.message}`);
|
|
4656
|
+
}
|
|
4657
|
+
if (draft.baseApp) {
|
|
4658
|
+
const base = loadAppById(projectRoot, draft.baseApp.appId);
|
|
4659
|
+
if (!base || fingerprintAppDirectory(base.appDir) !== draft.baseApp.fingerprint) {
|
|
4660
|
+
errors.push('The published App changed after this local draft was created. Refresh the draft before publishing.');
|
|
4661
|
+
}
|
|
4662
|
+
}
|
|
4663
|
+
for (const page of draft.pages) {
|
|
4664
|
+
const parsed = parseDashboardDocument(JSON.stringify(page), `<app-build:${draft.id}/${page.id}>`);
|
|
4665
|
+
if (!parsed.document)
|
|
4666
|
+
errors.push(...parsed.errors.map((error) => error.message));
|
|
4667
|
+
}
|
|
4668
|
+
const analysisPages = draft.pages.filter((page) => page.layout.items.some((tile) => !tile.text && !tile.aiPin));
|
|
4669
|
+
const receipts = draft.previewReceipts ?? (draft.previewReceipt ? [draft.previewReceipt] : []);
|
|
4670
|
+
for (const page of analysisPages) {
|
|
4671
|
+
const receipt = receipts.find((candidate) => candidate.pageId === page.id);
|
|
4672
|
+
if (!receipt || receipt.revision !== draft.revision)
|
|
4673
|
+
errors.push(`Run a current settled preview for page ${page.metadata.title || page.id} before Project publication.`);
|
|
4674
|
+
}
|
|
4675
|
+
for (const source of draft.sources) {
|
|
4676
|
+
if (source.kind === 'block' || source.kind === 'certified_block') {
|
|
4677
|
+
const block = blocks.find((candidate) => candidate.id === source.sourceRef || candidate.name === source.sourceRef || candidate.path === source.sourceRef);
|
|
4678
|
+
const lifecycle = source.lifecycle ?? (source.kind === 'certified_block' ? 'certified' : block?.status);
|
|
4679
|
+
const boundRevision = source.sourceRevision ?? source.sourceFingerprint;
|
|
4680
|
+
if (!block)
|
|
4681
|
+
errors.push(`Source ${source.id} no longer resolves at ${source.sourceRef}.`);
|
|
4682
|
+
if (block && canonicalAppBlockSourceId(block) !== source.id && source.kind === 'block')
|
|
4683
|
+
errors.push(`Source ${source.id} no longer matches its qualified path identity.`);
|
|
4684
|
+
if (!block || block.status !== 'certified' || lifecycle !== 'certified' || source.trustState !== 'certified') {
|
|
4685
|
+
errors.push(`Source ${source.qualifiedIdentity ?? source.id} is ${lifecycle ?? 'unknown'} and cannot be published to the Project.`);
|
|
4686
|
+
}
|
|
4687
|
+
if (block && !boundRevision) {
|
|
4688
|
+
errors.push(`Source ${source.qualifiedIdentity ?? source.id} has no bound source revision; refresh it before Project publication.`);
|
|
4689
|
+
}
|
|
4690
|
+
else if (block && boundRevision !== block.fingerprint) {
|
|
4691
|
+
errors.push(`Source ${source.qualifiedIdentity ?? source.id} changed after selection.`);
|
|
4692
|
+
}
|
|
4693
|
+
}
|
|
4694
|
+
if (source.kind === 'governed_semantic' || source.kind === 'semantic_query') {
|
|
4695
|
+
if (source.reviewStatus !== 'approved' || !source.snapshotId || !source.receiptId) {
|
|
4696
|
+
errors.push(`Source ${source.id} requires snapshot-bound semantic approval before Project publication.`);
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4699
|
+
if ((source.kind === 'block' && source.trustState !== 'certified') || source.kind === 'review_block' || source.kind === 'review_dql' || source.kind === 'exploratory_sql') {
|
|
4700
|
+
errors.push(`Source ${source.id} is review-required and cannot be published to the Project.`);
|
|
4701
|
+
}
|
|
4702
|
+
if (((source.kind === 'block' && source.trustState !== 'certified') || source.kind === 'review_block' || source.kind === 'review_dql' || source.kind === 'exploratory_sql') && draft.sourcePolicy !== 'include_review_required') {
|
|
4703
|
+
errors.push(`Source ${source.id} requires include_review_required policy.`);
|
|
4704
|
+
}
|
|
2478
4705
|
}
|
|
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
|
-
|
|
4706
|
+
const settledPageIds = new Set(receipts.filter((receipt) => receipt.revision === draft.revision).map((receipt) => receipt.pageId));
|
|
4707
|
+
const readiness = appPublicationReadiness(projectRoot, draft.pages, { settledPageIds });
|
|
4708
|
+
errors.push(...readiness.blockers.map((blocker) => blocker.message));
|
|
4709
|
+
return Array.from(new Set(errors));
|
|
4710
|
+
}
|
|
4711
|
+
function preflightAppBuildRequestErrors(projectRoot, draft, verifyPreview) {
|
|
4712
|
+
const errors = preflightStoredAppBuildDraft(projectRoot, draft);
|
|
4713
|
+
const analysisPages = draft.pages
|
|
4714
|
+
.map((page) => ({
|
|
4715
|
+
pageId: page.id,
|
|
4716
|
+
tileIds: page.layout.items.filter((tile) => !tile.text && !tile.aiPin).map((tile) => tile.i),
|
|
4717
|
+
}))
|
|
4718
|
+
.filter((page) => page.tileIds.length > 0);
|
|
4719
|
+
const receipts = draft.previewReceipts ?? (draft.previewReceipt ? [draft.previewReceipt] : []);
|
|
4720
|
+
if (analysisPages.length > 0 && !verifyPreview) {
|
|
4721
|
+
errors.push('The runtime cannot verify the settled App preview receipts.');
|
|
4722
|
+
}
|
|
4723
|
+
for (const page of analysisPages) {
|
|
4724
|
+
const receipt = receipts.find((candidate) => candidate.pageId === page.pageId);
|
|
4725
|
+
if (!receipt || !verifyPreview)
|
|
4726
|
+
continue;
|
|
4727
|
+
const verified = verifyPreview({
|
|
4728
|
+
runId: receipt.id,
|
|
4729
|
+
draftId: draft.id,
|
|
4730
|
+
dashboardId: page.pageId,
|
|
4731
|
+
tileIds: page.tileIds,
|
|
4732
|
+
});
|
|
4733
|
+
if (!verified.ok
|
|
4734
|
+
|| verified.snapshotId !== receipt.snapshotId
|
|
4735
|
+
|| verified.resultFingerprint !== receipt.resultFingerprint) {
|
|
4736
|
+
errors.push(verified.ok
|
|
4737
|
+
? `The saved preview receipt for ${page.pageId} no longer matches the settled run.`
|
|
4738
|
+
: verified.error);
|
|
4739
|
+
}
|
|
4740
|
+
}
|
|
4741
|
+
return Array.from(new Set(errors));
|
|
4742
|
+
}
|
|
4743
|
+
function appBuildRequirementBlocksPublication(draft, requirement) {
|
|
4744
|
+
if (!requirement.required)
|
|
4745
|
+
return false;
|
|
4746
|
+
if (draft.authoringMode === 'manual' && draft.frame.metrics.length === 0 && requirement.measures.length === 0)
|
|
4747
|
+
return false;
|
|
4748
|
+
return true;
|
|
4749
|
+
}
|
|
4750
|
+
function appBuildReviewTaskBlocksPublication(draft, task) {
|
|
4751
|
+
if (task.status !== 'open')
|
|
4752
|
+
return false;
|
|
4753
|
+
if (task.sourceId && draft.sources.some((source) => source.id === task.sourceId))
|
|
4754
|
+
return true;
|
|
4755
|
+
if (task.tileId && draft.pages.some((page) => page.layout.items.some((tile) => tile.i === task.tileId)))
|
|
4756
|
+
return true;
|
|
4757
|
+
if (task.pageId && draft.pages.some((page) => page.id === task.pageId))
|
|
4758
|
+
return true;
|
|
4759
|
+
// v2 AI plans previously persisted general guidance as open tasks without an
|
|
4760
|
+
// attached source, page, or tile. Keep those reminders readable, but do not
|
|
4761
|
+
// turn them into an unexplained hard publication gate.
|
|
4762
|
+
return false;
|
|
4763
|
+
}
|
|
4764
|
+
function hasCurrentAppBuildPreflightReceipt(draft) {
|
|
4765
|
+
return Boolean(draft.preflightReceipt
|
|
4766
|
+
&& draft.preflightReceipt.revision === draft.revision
|
|
4767
|
+
&& draft.preflightReceipt.proposalHash === draft.proposalHash
|
|
4768
|
+
&& draft.preflightReceipt.sourceFingerprint === appBuildSourceFingerprint(draft));
|
|
4769
|
+
}
|
|
4770
|
+
export function markStoredAppBuildDraftPreflighted(projectRoot, draft) {
|
|
4771
|
+
const errors = preflightStoredAppBuildDraft(projectRoot, draft);
|
|
4772
|
+
if (errors.length)
|
|
4773
|
+
throw new Error(`App Build preflight failed: ${errors.join(' ')}`);
|
|
4774
|
+
const now = new Date().toISOString();
|
|
4775
|
+
const next = {
|
|
4776
|
+
...draft,
|
|
4777
|
+
state: 'preflight_ready',
|
|
4778
|
+
preflightReceipt: {
|
|
4779
|
+
id: `app_preflight_${createHash('sha256').update(`${draft.id}:${draft.revision}:${draft.proposalHash}`).digest('hex').slice(0, 16)}`,
|
|
4780
|
+
revision: draft.revision,
|
|
4781
|
+
proposalHash: draft.proposalHash,
|
|
4782
|
+
sourceFingerprint: appBuildSourceFingerprint(draft),
|
|
4783
|
+
createdAt: now,
|
|
2506
4784
|
},
|
|
4785
|
+
updatedAt: now,
|
|
2507
4786
|
};
|
|
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) };
|
|
4787
|
+
writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: draft.revision });
|
|
4788
|
+
return next;
|
|
2513
4789
|
}
|
|
2514
|
-
function
|
|
2515
|
-
const
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
4790
|
+
function appBuildSourceFingerprint(draft) {
|
|
4791
|
+
const sources = [...draft.sources]
|
|
4792
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
4793
|
+
.map((source) => ({
|
|
4794
|
+
id: source.id,
|
|
4795
|
+
kind: source.kind,
|
|
4796
|
+
sourceRef: source.sourceRef,
|
|
4797
|
+
snapshotId: source.snapshotId,
|
|
4798
|
+
sourceFingerprint: source.sourceFingerprint,
|
|
4799
|
+
receiptId: source.receiptId,
|
|
4800
|
+
reviewStatus: source.reviewStatus,
|
|
4801
|
+
}));
|
|
4802
|
+
return `sha256:${createHash('sha256').update(JSON.stringify(sources)).digest('hex')}`;
|
|
4803
|
+
}
|
|
4804
|
+
export function publishStoredAppBuildDraft(projectRoot, draft) {
|
|
4805
|
+
const errors = preflightStoredAppBuildDraft(projectRoot, draft);
|
|
4806
|
+
if (errors.length)
|
|
4807
|
+
throw new Error(`App Build preflight failed: ${errors.join(' ')}`);
|
|
4808
|
+
if (!hasCurrentAppBuildPreflightReceipt(draft)) {
|
|
4809
|
+
throw new Error('App Build Draft needs a current preflight receipt before publication.');
|
|
4810
|
+
}
|
|
4811
|
+
const destination = resolveAppPackageDir(projectRoot, draft.appId);
|
|
4812
|
+
if (!draft.baseApp && existsSync(destination))
|
|
4813
|
+
throw new Error(`App already exists: ${draft.appId}`);
|
|
4814
|
+
if (draft.baseApp && (!existsSync(destination) || draft.baseApp.appId !== draft.appId)) {
|
|
4815
|
+
throw new Error('Published App base no longer exists at the expected path.');
|
|
4816
|
+
}
|
|
4817
|
+
const stage = join(projectRoot, '.dql', 'local', 'app-build-staging', `${draft.id}-${Date.now()}`);
|
|
4818
|
+
const dashboardDir = join(stage, 'dashboards');
|
|
4819
|
+
const owner = `${process.env.USER ?? 'owner'}@local`;
|
|
4820
|
+
const domain = draft.pages[0]?.metadata.domain || 'general';
|
|
4821
|
+
const name = draft.name.trim() || draft.appId;
|
|
4822
|
+
const containsSemantic = draft.sources.some((source) => source.kind === 'governed_semantic' || source.kind === 'semantic_query');
|
|
4823
|
+
const hasCertifiedAnalysis = draft.sources.some((source) => (source.kind === 'block' || source.kind === 'certified_block') && source.trustState === 'certified');
|
|
4824
|
+
const lifecycle = hasCertifiedAnalysis && !containsSemantic ? 'certified' : 'review';
|
|
4825
|
+
const app = {
|
|
4826
|
+
version: 1,
|
|
4827
|
+
id: draft.appId,
|
|
4828
|
+
name,
|
|
4829
|
+
description: draft.frame.decision || `Governed analytical App for ${name}`,
|
|
4830
|
+
visibility: 'shared',
|
|
4831
|
+
publicationIntent: 'shared_project',
|
|
4832
|
+
ownerDomain: domain,
|
|
4833
|
+
usesDomains: [domain],
|
|
4834
|
+
requiredExports: [],
|
|
4835
|
+
domain,
|
|
4836
|
+
audience: draft.frame.audience,
|
|
4837
|
+
lifecycle,
|
|
4838
|
+
owners: [owner],
|
|
4839
|
+
members: [{ userId: owner, displayName: owner, roles: ['owner', 'analyst'] }],
|
|
4840
|
+
roles: [
|
|
4841
|
+
{ id: 'owner', displayName: 'Owner' },
|
|
4842
|
+
{ id: 'analyst', displayName: 'Analyst' },
|
|
4843
|
+
{ id: 'viewer', displayName: 'Viewer' },
|
|
4844
|
+
],
|
|
4845
|
+
policies: [
|
|
4846
|
+
{ id: 'viewers-read', domain, minClassification: 'internal', allowedRoles: ['viewer', 'analyst', 'owner'], accessLevel: 'read', enabled: true },
|
|
4847
|
+
{ id: 'analyst-execute', domain, minClassification: 'internal', allowedRoles: ['analyst', 'owner'], accessLevel: 'execute', enabled: true },
|
|
4848
|
+
{ id: 'owner-admin', domain, minClassification: 'restricted', allowedRoles: ['owner'], accessLevel: 'admin', enabled: true },
|
|
4849
|
+
],
|
|
4850
|
+
rlsBindings: [],
|
|
4851
|
+
schedules: [],
|
|
4852
|
+
homepage: { type: 'dashboard', id: draft.pages[0].id },
|
|
4853
|
+
};
|
|
4854
|
+
const committedPages = draft.pages.map((page) => ({
|
|
4855
|
+
...page,
|
|
4856
|
+
version: 2,
|
|
4857
|
+
metadata: { ...page.metadata, visibility: 'shared', lifecycle },
|
|
4858
|
+
}));
|
|
4859
|
+
mkdirSync(dashboardDir, { recursive: true });
|
|
4860
|
+
mkdirSync(join(stage, 'notebooks'), { recursive: true });
|
|
4861
|
+
mkdirSync(join(stage, 'drafts'), { recursive: true });
|
|
4862
|
+
try {
|
|
4863
|
+
writeFileSync(join(stage, 'dql.app.json'), JSON.stringify(app, null, 2) + '\n', 'utf-8');
|
|
4864
|
+
for (const page of committedPages) {
|
|
4865
|
+
writeFileSync(join(dashboardDir, `${page.id}.dqld`), JSON.stringify(page, null, 2) + '\n', 'utf-8');
|
|
4866
|
+
}
|
|
4867
|
+
writeFileSync(join(stage, 'README.md'), appReadme(app, draft.frame.audience ?? '', [], committedPages[0].id), 'utf-8');
|
|
4868
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
4869
|
+
if (draft.baseApp) {
|
|
4870
|
+
const backup = join(projectRoot, '.dql', 'local', 'app-build-staging', `${draft.id}-backup-${Date.now()}`);
|
|
4871
|
+
renameSync(destination, backup);
|
|
4872
|
+
try {
|
|
4873
|
+
renameSync(stage, destination);
|
|
4874
|
+
rmSync(backup, { recursive: true, force: true });
|
|
2528
4875
|
}
|
|
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
|
-
}
|
|
4876
|
+
catch (error) {
|
|
4877
|
+
if (!existsSync(destination) && existsSync(backup))
|
|
4878
|
+
renameSync(backup, destination);
|
|
4879
|
+
throw error;
|
|
2547
4880
|
}
|
|
2548
4881
|
}
|
|
4882
|
+
else {
|
|
4883
|
+
renameSync(stage, destination);
|
|
4884
|
+
}
|
|
2549
4885
|
}
|
|
2550
|
-
|
|
4886
|
+
catch (error) {
|
|
4887
|
+
rmSync(stage, { recursive: true, force: true });
|
|
4888
|
+
throw error;
|
|
4889
|
+
}
|
|
4890
|
+
const next = {
|
|
4891
|
+
...draft,
|
|
4892
|
+
revision: draft.revision + 1,
|
|
4893
|
+
state: 'project_published',
|
|
4894
|
+
publishedFingerprint: fingerprintAppDirectory(destination),
|
|
4895
|
+
updatedAt: new Date().toISOString(),
|
|
4896
|
+
proposalHash: draft.proposalHash,
|
|
4897
|
+
};
|
|
4898
|
+
writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: draft.revision });
|
|
4899
|
+
const created = collectAppsList(projectRoot).find((entry) => entry.id === draft.appId);
|
|
4900
|
+
if (!created)
|
|
4901
|
+
throw new Error(`App was committed but could not be reloaded: ${draft.appId}`);
|
|
4902
|
+
return {
|
|
4903
|
+
ok: true,
|
|
4904
|
+
app: created,
|
|
4905
|
+
draft: next,
|
|
4906
|
+
paths: ['dql.app.json', 'README.md', ...committedPages.map((page) => `dashboards/${page.id}.dqld`)].map((path) => `apps/${draft.appId}/${path}`),
|
|
4907
|
+
};
|
|
2551
4908
|
}
|
|
2552
|
-
|
|
2553
|
-
|
|
4909
|
+
/** @deprecated Compatibility adapter for callers that still use the old name. */
|
|
4910
|
+
export function commitStoredAppBuildDraft(projectRoot, draft) {
|
|
4911
|
+
return publishStoredAppBuildDraft(projectRoot, draft);
|
|
2554
4912
|
}
|
|
2555
4913
|
function writeAppAiBuildSession(projectRoot, session) {
|
|
2556
4914
|
const dir = appAiBuildSessionDir(projectRoot);
|
|
@@ -2646,7 +5004,7 @@ function appAskTileContext(tile) {
|
|
|
2646
5004
|
sourceEvidence: tile.sourceEvidence,
|
|
2647
5005
|
};
|
|
2648
5006
|
}
|
|
2649
|
-
function buildAppContextEnvelope(app, appDir, focus) {
|
|
5007
|
+
function buildAppContextEnvelope(projectRoot, app, appDir, focus) {
|
|
2650
5008
|
const dashboards = findDashboardsForApp(appDir)
|
|
2651
5009
|
.map((path) => loadDashboardDocument(path).document)
|
|
2652
5010
|
.filter((document) => Boolean(document))
|
|
@@ -2671,6 +5029,7 @@ function buildAppContextEnvelope(app, appDir, focus) {
|
|
|
2671
5029
|
caveats: app.caveats ?? [],
|
|
2672
5030
|
},
|
|
2673
5031
|
dashboards,
|
|
5032
|
+
drafts: loadAppDraftContext(projectRoot, appDir),
|
|
2674
5033
|
focus,
|
|
2675
5034
|
};
|
|
2676
5035
|
}
|
|
@@ -2683,6 +5042,9 @@ function filterBindingsForBlockSource(projectRoot, block) {
|
|
|
2683
5042
|
const parameterSection = sectionBody(source, 'parameterPolicy');
|
|
2684
5043
|
const bindings = Array.from(filterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"([^"]+)"/gm))
|
|
2685
5044
|
.map((match) => ({ filter: match[1], binding: match[2], mode: 'predicate' }));
|
|
5045
|
+
const allowedFilterBindings = matchArray(source, /allowedFilters\s*=\s*\[([^\]]*)\]/)
|
|
5046
|
+
.filter((filter) => !bindings.some((binding) => binding.filter === filter))
|
|
5047
|
+
.map((filter) => ({ filter, binding: filter, mode: 'predicate' }));
|
|
2686
5048
|
const parameterNames = Array.from(parameterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"dynamic"/gm))
|
|
2687
5049
|
.map((match) => match[1]);
|
|
2688
5050
|
const parameterBindings = parameterNames.map((param) => ({
|
|
@@ -2691,7 +5053,7 @@ function filterBindingsForBlockSource(projectRoot, block) {
|
|
|
2691
5053
|
mode: 'parameter',
|
|
2692
5054
|
paramNames: [param],
|
|
2693
5055
|
}));
|
|
2694
|
-
return uniqueFilterBindings([...bindings, ...parameterBindings]);
|
|
5056
|
+
return uniqueFilterBindings([...bindings, ...allowedFilterBindings, ...parameterBindings]);
|
|
2695
5057
|
}
|
|
2696
5058
|
function sectionBody(source, sectionName) {
|
|
2697
5059
|
const match = new RegExp(`${sectionName}\\s*\\{([\\s\\S]*?)\\n\\s*\\}`, 'm').exec(source);
|
|
@@ -2857,7 +5219,7 @@ function promoteSharedDashboardItem(item) {
|
|
|
2857
5219
|
reviewStatus,
|
|
2858
5220
|
};
|
|
2859
5221
|
}
|
|
2860
|
-
export function deleteAppPackage(projectRoot, id) {
|
|
5222
|
+
export function deleteAppPackage(projectRoot, id, input = {}) {
|
|
2861
5223
|
const cleanId = cleanString(id);
|
|
2862
5224
|
if (!cleanId || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(cleanId)) {
|
|
2863
5225
|
return { ok: false, status: 400, error: 'A valid App id is required.' };
|
|
@@ -2865,27 +5227,130 @@ export function deleteAppPackage(projectRoot, id) {
|
|
|
2865
5227
|
const loaded = loadAppById(projectRoot, cleanId);
|
|
2866
5228
|
if (!loaded)
|
|
2867
5229
|
return { ok: false, status: 404, error: `App "${cleanId}" not found.` };
|
|
5230
|
+
const currentFingerprint = fingerprintAppDirectory(loaded.appDir);
|
|
5231
|
+
if (!input.expectedFingerprint) {
|
|
5232
|
+
return { ok: false, status: 409, error: 'App deletion requires the fingerprint shown during confirmation.' };
|
|
5233
|
+
}
|
|
5234
|
+
if (input.expectedFingerprint !== currentFingerprint) {
|
|
5235
|
+
return { ok: false, status: 409, error: 'This App changed since deletion was requested. Refresh and review it again.' };
|
|
5236
|
+
}
|
|
2868
5237
|
const relativeAppDir = relative(projectRoot, loaded.appDir).replaceAll('\\', '/');
|
|
2869
5238
|
const isProjectApp = /^apps\/[^/]+$/.test(relativeAppDir);
|
|
2870
5239
|
const isDomainApp = /^domains\/(?:[^/]+\/)*apps\/[^/]+$/.test(relativeAppDir);
|
|
2871
5240
|
if ((!isProjectApp && !isDomainApp) || relativeAppDir.startsWith('../')) {
|
|
2872
5241
|
return { ok: false, status: 400, error: 'DQL refused to delete an App outside this project.' };
|
|
2873
5242
|
}
|
|
2874
|
-
const trashRoot = join(projectRoot, '.dql', 'trash', 'apps');
|
|
5243
|
+
const trashRoot = join(projectRoot, '.dql', 'local', 'trash', 'apps');
|
|
2875
5244
|
mkdirSync(trashRoot, { recursive: true });
|
|
2876
5245
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
2877
|
-
let
|
|
5246
|
+
let recoveryId = `${stamp}-${cleanId}`;
|
|
5247
|
+
let trashDir = join(trashRoot, recoveryId);
|
|
2878
5248
|
let suffix = 2;
|
|
2879
|
-
while (existsSync(trashDir))
|
|
2880
|
-
|
|
2881
|
-
|
|
5249
|
+
while (existsSync(trashDir)) {
|
|
5250
|
+
recoveryId = `${stamp}-${cleanId}-${suffix++}`;
|
|
5251
|
+
trashDir = join(trashRoot, recoveryId);
|
|
5252
|
+
}
|
|
5253
|
+
mkdirSync(trashDir, { recursive: true });
|
|
5254
|
+
const packageDir = join(trashDir, 'package');
|
|
5255
|
+
renameSync(loaded.appDir, packageDir);
|
|
5256
|
+
let localArchive;
|
|
5257
|
+
try {
|
|
5258
|
+
const dbPath = defaultLocalAppsDbPath(projectRoot);
|
|
5259
|
+
if (existsSync(dbPath)) {
|
|
5260
|
+
const storage = new LocalAppStorage(dbPath);
|
|
5261
|
+
try {
|
|
5262
|
+
localArchive = storage.archiveAndDeleteAppState(cleanId);
|
|
5263
|
+
}
|
|
5264
|
+
finally {
|
|
5265
|
+
storage.close();
|
|
5266
|
+
}
|
|
5267
|
+
writeFileSync(join(trashDir, 'local-state.json'), JSON.stringify(localArchive, null, 2) + '\n', 'utf-8');
|
|
5268
|
+
}
|
|
5269
|
+
writeFileSync(join(trashDir, 'recovery.json'), JSON.stringify({
|
|
5270
|
+
version: 1,
|
|
5271
|
+
recoveryId,
|
|
5272
|
+
appId: cleanId,
|
|
5273
|
+
originalPath: relativeAppDir,
|
|
5274
|
+
appFingerprint: currentFingerprint,
|
|
5275
|
+
deletedAt: new Date().toISOString(),
|
|
5276
|
+
}, null, 2) + '\n', 'utf-8');
|
|
5277
|
+
}
|
|
5278
|
+
catch (error) {
|
|
5279
|
+
if (localArchive) {
|
|
5280
|
+
const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
5281
|
+
try {
|
|
5282
|
+
storage.restoreAppState(localArchive);
|
|
5283
|
+
}
|
|
5284
|
+
finally {
|
|
5285
|
+
storage.close();
|
|
5286
|
+
}
|
|
5287
|
+
}
|
|
5288
|
+
if (!existsSync(loaded.appDir) && existsSync(packageDir))
|
|
5289
|
+
renameSync(packageDir, loaded.appDir);
|
|
5290
|
+
rmSync(trashDir, { recursive: true, force: true });
|
|
5291
|
+
throw error;
|
|
5292
|
+
}
|
|
5293
|
+
const localRowsArchived = localArchive
|
|
5294
|
+
? Object.values(localArchive.rows).reduce((count, rows) => count + rows.length, 0)
|
|
5295
|
+
: 0;
|
|
2882
5296
|
return {
|
|
2883
5297
|
ok: true,
|
|
2884
5298
|
id: cleanId,
|
|
2885
5299
|
deletedPath: relativeAppDir,
|
|
2886
5300
|
trashPath: relative(projectRoot, trashDir).replaceAll('\\', '/'),
|
|
5301
|
+
recoveryId,
|
|
5302
|
+
localRowsArchived,
|
|
5303
|
+
sourceControlImpact: 'delete',
|
|
2887
5304
|
};
|
|
2888
5305
|
}
|
|
5306
|
+
export function restoreAppPackage(projectRoot, recoveryId) {
|
|
5307
|
+
const cleanRecoveryId = cleanString(recoveryId);
|
|
5308
|
+
if (!cleanRecoveryId || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(cleanRecoveryId)) {
|
|
5309
|
+
return { ok: false, status: 400, error: 'A valid recovery id is required.' };
|
|
5310
|
+
}
|
|
5311
|
+
const recoveryDir = join(projectRoot, '.dql', 'local', 'trash', 'apps', cleanRecoveryId);
|
|
5312
|
+
const manifestPath = join(recoveryDir, 'recovery.json');
|
|
5313
|
+
const packageDir = join(recoveryDir, 'package');
|
|
5314
|
+
if (!existsSync(manifestPath) || !existsSync(packageDir))
|
|
5315
|
+
return { ok: false, status: 404, error: 'App recovery bundle not found.' };
|
|
5316
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
5317
|
+
if (!manifest.appId || !manifest.originalPath || manifest.originalPath.startsWith('../')) {
|
|
5318
|
+
return { ok: false, status: 400, error: 'App recovery bundle is invalid.' };
|
|
5319
|
+
}
|
|
5320
|
+
const destination = join(projectRoot, manifest.originalPath);
|
|
5321
|
+
if (existsSync(destination))
|
|
5322
|
+
return { ok: false, status: 409, error: `Cannot restore because ${manifest.originalPath} already exists.` };
|
|
5323
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
5324
|
+
renameSync(packageDir, destination);
|
|
5325
|
+
try {
|
|
5326
|
+
const localStatePath = join(recoveryDir, 'local-state.json');
|
|
5327
|
+
if (existsSync(localStatePath)) {
|
|
5328
|
+
const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
5329
|
+
try {
|
|
5330
|
+
storage.restoreAppState(JSON.parse(readFileSync(localStatePath, 'utf-8')));
|
|
5331
|
+
}
|
|
5332
|
+
finally {
|
|
5333
|
+
storage.close();
|
|
5334
|
+
}
|
|
5335
|
+
}
|
|
5336
|
+
}
|
|
5337
|
+
catch (error) {
|
|
5338
|
+
renameSync(destination, packageDir);
|
|
5339
|
+
throw error;
|
|
5340
|
+
}
|
|
5341
|
+
rmSync(recoveryDir, { recursive: true, force: true });
|
|
5342
|
+
return { ok: true, id: manifest.appId, restoredPath: manifest.originalPath };
|
|
5343
|
+
}
|
|
5344
|
+
export function restoreAppRecovery(projectRoot, recoveryId) {
|
|
5345
|
+
const cleanRecoveryId = cleanString(recoveryId);
|
|
5346
|
+
if (!cleanRecoveryId || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(cleanRecoveryId)) {
|
|
5347
|
+
return { ok: false, status: 400, error: 'A valid recovery id is required.' };
|
|
5348
|
+
}
|
|
5349
|
+
const draftRecovery = join(projectRoot, '.dql', 'local', 'trash', 'app-drafts', cleanRecoveryId, 'recovery.json');
|
|
5350
|
+
return existsSync(draftRecovery)
|
|
5351
|
+
? restoreStoredAppBuildDraft(projectRoot, cleanRecoveryId)
|
|
5352
|
+
: restoreAppPackage(projectRoot, cleanRecoveryId);
|
|
5353
|
+
}
|
|
2889
5354
|
export function createAppPackage(projectRoot, input) {
|
|
2890
5355
|
const name = cleanString(input.name);
|
|
2891
5356
|
const domain = cleanString(input.ownerDomain) || cleanString(input.domain);
|
|
@@ -2903,16 +5368,23 @@ export function createAppPackage(projectRoot, input) {
|
|
|
2903
5368
|
const audience = cleanString(input.audience);
|
|
2904
5369
|
const subdomain = cleanString(input.subdomain);
|
|
2905
5370
|
const groups = normalizeTags(input.groups ?? []);
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
5371
|
+
// API-013: authoring always starts as a private draft. Project visibility is
|
|
5372
|
+
// available only through the publication endpoint and its live trust checks.
|
|
5373
|
+
const visibility = 'private';
|
|
5374
|
+
const lifecycle = 'draft';
|
|
2910
5375
|
const tags = normalizeTags([...(input.tags ?? []), audience ? `audience:${slugify(audience)}` : '']);
|
|
2911
5376
|
const selectedIds = Array.from(new Set((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean)));
|
|
2912
5377
|
const blocks = collectBlockCandidates(projectRoot);
|
|
2913
5378
|
const selectedBlocks = selectedIds
|
|
2914
5379
|
.map((blockId) => blocks.find((block) => block.id === blockId || block.name === blockId))
|
|
2915
5380
|
.filter((block) => Boolean(block));
|
|
5381
|
+
const missingBlocks = selectedIds.filter((blockId) => !selectedBlocks.some((block) => block.id === blockId || block.name === blockId));
|
|
5382
|
+
if (missingBlocks.length)
|
|
5383
|
+
return { ok: false, error: `Unknown block selection: ${missingBlocks.join(', ')}` };
|
|
5384
|
+
const uncertifiedBlocks = selectedBlocks.filter((block) => block.status !== 'certified');
|
|
5385
|
+
if (uncertifiedBlocks.length) {
|
|
5386
|
+
return { ok: false, error: `Manual App creation accepts certified blocks only: ${uncertifiedBlocks.map((block) => block.name).join(', ')}` };
|
|
5387
|
+
}
|
|
2916
5388
|
const app = {
|
|
2917
5389
|
version: 1,
|
|
2918
5390
|
id,
|
|
@@ -2970,7 +5442,7 @@ export function createAppPackage(projectRoot, input) {
|
|
|
2970
5442
|
homepage: { type: 'dashboard', id: dashboardId },
|
|
2971
5443
|
};
|
|
2972
5444
|
const dashboard = {
|
|
2973
|
-
version:
|
|
5445
|
+
version: 2,
|
|
2974
5446
|
id: dashboardId,
|
|
2975
5447
|
metadata: {
|
|
2976
5448
|
title: dashboardTitle,
|
|
@@ -2983,11 +5455,13 @@ export function createAppPackage(projectRoot, input) {
|
|
|
2983
5455
|
lifecycle,
|
|
2984
5456
|
tags,
|
|
2985
5457
|
},
|
|
5458
|
+
filters: dashboardFiltersForBlocks(selectedBlocks),
|
|
2986
5459
|
layout: {
|
|
2987
5460
|
kind: 'grid',
|
|
2988
5461
|
cols: 12,
|
|
2989
5462
|
rowHeight: 80,
|
|
2990
|
-
items: buildDashboardItems(selectedBlocks),
|
|
5463
|
+
items: buildDashboardItems(projectRoot, selectedBlocks),
|
|
5464
|
+
responsive: responsiveLayoutsForDashboardItems(buildDashboardItems(projectRoot, selectedBlocks)),
|
|
2991
5465
|
},
|
|
2992
5466
|
};
|
|
2993
5467
|
const paths = [
|
|
@@ -3013,7 +5487,81 @@ export function createAppPackage(projectRoot, input) {
|
|
|
3013
5487
|
dashboardId,
|
|
3014
5488
|
};
|
|
3015
5489
|
}
|
|
3016
|
-
function
|
|
5490
|
+
function createAppPackageViaBuildDraft(projectRoot, input) {
|
|
5491
|
+
const name = cleanString(input.name);
|
|
5492
|
+
const domain = cleanString(input.ownerDomain) || cleanString(input.domain);
|
|
5493
|
+
if (!name)
|
|
5494
|
+
return { ok: false, error: 'name is required' };
|
|
5495
|
+
if (!domain)
|
|
5496
|
+
return { ok: false, error: 'domain is required' };
|
|
5497
|
+
const appId = suggestAppId(name);
|
|
5498
|
+
if (existsSync(resolveAppPackageDir(projectRoot, appId)))
|
|
5499
|
+
return { ok: false, error: `App already exists: ${appId}` };
|
|
5500
|
+
const selectedIds = unique((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean));
|
|
5501
|
+
const blocks = collectBlockCandidates(projectRoot);
|
|
5502
|
+
const selectedBlocks = selectedIds
|
|
5503
|
+
.map((id) => blocks.find((block) => block.id === id || block.name === id))
|
|
5504
|
+
.filter((block) => Boolean(block));
|
|
5505
|
+
const missing = selectedIds.filter((id) => !selectedBlocks.some((block) => block.id === id || block.name === id));
|
|
5506
|
+
if (missing.length)
|
|
5507
|
+
return { ok: false, error: `Unknown block selection: ${missing.join(', ')}` };
|
|
5508
|
+
const uncertified = selectedBlocks.filter((block) => block.status !== 'certified');
|
|
5509
|
+
if (uncertified.length)
|
|
5510
|
+
return { ok: false, error: `Manual App creation accepts certified blocks only: ${uncertified.map((block) => block.name).join(', ')}` };
|
|
5511
|
+
const draft = createStoredAppBuildDraft(projectRoot, {
|
|
5512
|
+
appId,
|
|
5513
|
+
name,
|
|
5514
|
+
goal: name,
|
|
5515
|
+
audience: cleanString(input.audience),
|
|
5516
|
+
domain,
|
|
5517
|
+
authoringMode: 'manual',
|
|
5518
|
+
sourcePolicy: 'governed_only',
|
|
5519
|
+
});
|
|
5520
|
+
const items = buildDashboardItems(projectRoot, selectedBlocks);
|
|
5521
|
+
const page = {
|
|
5522
|
+
...draft.pages[0],
|
|
5523
|
+
id: slugify(cleanString(input.dashboardTitle) || 'Overview') || 'overview',
|
|
5524
|
+
metadata: {
|
|
5525
|
+
...draft.pages[0].metadata,
|
|
5526
|
+
title: cleanString(input.dashboardTitle) || 'Overview',
|
|
5527
|
+
description: cleanString(input.purpose) || `Starter dashboard for ${name}`,
|
|
5528
|
+
domain,
|
|
5529
|
+
audience: cleanString(input.audience) || undefined,
|
|
5530
|
+
},
|
|
5531
|
+
filters: dashboardFiltersForBlocks(selectedBlocks),
|
|
5532
|
+
layout: {
|
|
5533
|
+
...draft.pages[0].layout,
|
|
5534
|
+
items,
|
|
5535
|
+
responsive: responsiveLayoutsForDashboardItems(items),
|
|
5536
|
+
},
|
|
5537
|
+
};
|
|
5538
|
+
const operations = [
|
|
5539
|
+
...(page.id === draft.pages[0].id ? [] : [{ type: 'remove_page', pageId: draft.pages[0].id }]),
|
|
5540
|
+
{ type: 'upsert_page', page },
|
|
5541
|
+
...selectedBlocks.map((block) => ({
|
|
5542
|
+
type: 'upsert_source',
|
|
5543
|
+
source: {
|
|
5544
|
+
id: `block:${block.id}`,
|
|
5545
|
+
kind: 'certified_block',
|
|
5546
|
+
sourceRef: block.id,
|
|
5547
|
+
sourceFingerprint: block.fingerprint,
|
|
5548
|
+
trustState: 'certified',
|
|
5549
|
+
reviewStatus: 'not_required',
|
|
5550
|
+
},
|
|
5551
|
+
})),
|
|
5552
|
+
];
|
|
5553
|
+
const revised = applyAppBuildDraftOperations(draft, draft.revision, operations);
|
|
5554
|
+
writeStoredAppBuildDraft(projectRoot, revised, { expectedRevision: draft.revision, operations });
|
|
5555
|
+
try {
|
|
5556
|
+
const preflighted = markStoredAppBuildDraftPreflighted(projectRoot, revised);
|
|
5557
|
+
const committed = publishStoredAppBuildDraft(projectRoot, preflighted);
|
|
5558
|
+
return { ok: true, app: committed.app, paths: committed.paths, dashboardId: page.id };
|
|
5559
|
+
}
|
|
5560
|
+
catch (error) {
|
|
5561
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
5562
|
+
}
|
|
5563
|
+
}
|
|
5564
|
+
function buildDashboardItems(projectRoot, blocks) {
|
|
3017
5565
|
let x = 0;
|
|
3018
5566
|
let y = 0;
|
|
3019
5567
|
let rowH = 0;
|
|
@@ -3027,13 +5575,51 @@ function buildDashboardItems(blocks) {
|
|
|
3027
5575
|
y += rowH || size.h;
|
|
3028
5576
|
rowH = 0;
|
|
3029
5577
|
}
|
|
3030
|
-
const item = dashboardItemForBlock(block, chartType, x, y, size, index);
|
|
5578
|
+
const item = dashboardItemForBlock(block, chartType, x, y, size, index, filterBindingsForBlockSource(projectRoot, block));
|
|
3031
5579
|
x += size.w;
|
|
3032
5580
|
rowH = Math.max(rowH, size.h);
|
|
3033
5581
|
return item;
|
|
3034
5582
|
});
|
|
3035
5583
|
}
|
|
3036
|
-
function
|
|
5584
|
+
function dashboardFiltersForBlocks(blocks) {
|
|
5585
|
+
return unique(blocks.flatMap((block) => block.filterIds)).map((id) => ({
|
|
5586
|
+
id,
|
|
5587
|
+
label: id.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase()),
|
|
5588
|
+
type: /(?:^|_)(?:top_n|limit|count|amount|number|minimum|maximum)(?:_|$)/i.test(id)
|
|
5589
|
+
? 'number'
|
|
5590
|
+
: /(?:date|time|period|month|quarter|year)/i.test(id)
|
|
5591
|
+
? 'date'
|
|
5592
|
+
: /(?:^|_)(?:name|id|search|email)(?:_|$)/i.test(id)
|
|
5593
|
+
? 'search'
|
|
5594
|
+
: 'select',
|
|
5595
|
+
bindsTo: id,
|
|
5596
|
+
}));
|
|
5597
|
+
}
|
|
5598
|
+
function responsiveLayoutsForDashboardItems(items) {
|
|
5599
|
+
const project = (cols) => {
|
|
5600
|
+
let x = 0;
|
|
5601
|
+
let y = 0;
|
|
5602
|
+
let rowHeight = 0;
|
|
5603
|
+
return [...items].sort((left, right) => left.y - right.y || left.x - right.x).map((item) => {
|
|
5604
|
+
const width = cols === 1 ? 1 : Math.min(cols, Math.max(2, Math.ceil(item.w / 2)));
|
|
5605
|
+
if (x + width > cols) {
|
|
5606
|
+
x = 0;
|
|
5607
|
+
y += rowHeight || item.h;
|
|
5608
|
+
rowHeight = 0;
|
|
5609
|
+
}
|
|
5610
|
+
const next = { ...item, x, y, w: width };
|
|
5611
|
+
x += width;
|
|
5612
|
+
rowHeight = Math.max(rowHeight, item.h);
|
|
5613
|
+
return next;
|
|
5614
|
+
});
|
|
5615
|
+
};
|
|
5616
|
+
return {
|
|
5617
|
+
wide: { kind: 'grid', cols: 12, rowHeight: 80, items },
|
|
5618
|
+
medium: { kind: 'grid', cols: 6, rowHeight: 80, items: project(6) },
|
|
5619
|
+
narrow: { kind: 'grid', cols: 1, rowHeight: 80, items: project(1) },
|
|
5620
|
+
};
|
|
5621
|
+
}
|
|
5622
|
+
function dashboardItemForBlock(block, chartType, x, y, size, index, filterBindings = []) {
|
|
3037
5623
|
return {
|
|
3038
5624
|
i: slugify(block.name) || `tile-${index + 1}`,
|
|
3039
5625
|
x,
|
|
@@ -3043,7 +5629,12 @@ function dashboardItemForBlock(block, chartType, x, y, size, index) {
|
|
|
3043
5629
|
block: { blockId: block.name },
|
|
3044
5630
|
viz: { type: chartType },
|
|
3045
5631
|
display: displayForBlockHint(block, chartType),
|
|
5632
|
+
sourceClass: 'certified_block',
|
|
5633
|
+
review: { status: 'not_required', sourceFingerprint: block.fingerprint },
|
|
5634
|
+
trustState: 'certified',
|
|
5635
|
+
reviewStatus: 'certified',
|
|
3046
5636
|
title: block.name,
|
|
5637
|
+
...(filterBindings.length ? { filterBindings } : {}),
|
|
3047
5638
|
};
|
|
3048
5639
|
}
|
|
3049
5640
|
function vizRank(chartType) {
|
|
@@ -3209,8 +5800,13 @@ function visualizationFitsResult(viz, columns, rows) {
|
|
|
3209
5800
|
const hasTime = columns.some((column) => /\b(date|time|week|month|quarter|year|season|period)\b/i.test(column.name));
|
|
3210
5801
|
if (viz === 'table' || viz === 'pivot' || viz === 'text' || viz === 'heading')
|
|
3211
5802
|
return true;
|
|
5803
|
+
// An empty row sample means "cannot judge", not "does not fit". Returning
|
|
5804
|
+
// false here silently rewrote every requested visualization to `table`
|
|
5805
|
+
// whenever no rows were supplied — which is the normal case when a tile is
|
|
5806
|
+
// configured before its first run, so authored and AI-chosen charts were
|
|
5807
|
+
// routinely discarded. Keep the request; the renderer copes with the shape.
|
|
3212
5808
|
if (rows.length === 0)
|
|
3213
|
-
return
|
|
5809
|
+
return true;
|
|
3214
5810
|
if (viz === 'single_value' || viz === 'kpi' || viz === 'gauge')
|
|
3215
5811
|
return rows.length === 1 && measures.length >= 1;
|
|
3216
5812
|
if (viz === 'line' || viz === 'area')
|
|
@@ -3399,7 +5995,7 @@ function appReadme(app, audience, blocks, dashboardId = 'overview') {
|
|
|
3399
5995
|
'',
|
|
3400
5996
|
].join('\n');
|
|
3401
5997
|
}
|
|
3402
|
-
function collectBlockCandidates(projectRoot) {
|
|
5998
|
+
function collectBlockCandidates(projectRoot, options = {}) {
|
|
3403
5999
|
const blocks = [];
|
|
3404
6000
|
const seen = new Set();
|
|
3405
6001
|
const scanDir = (dir) => {
|
|
@@ -3415,7 +6011,12 @@ function collectBlockCandidates(projectRoot) {
|
|
|
3415
6011
|
seen.add(filePath);
|
|
3416
6012
|
const source = readFileSync(filePath, 'utf-8');
|
|
3417
6013
|
const stat = statSyncSafe(filePath);
|
|
3418
|
-
const name = matchString(source, /block\s+"([^"]+)"/)
|
|
6014
|
+
const name = matchString(source, /block\s+"([^"]+)"/);
|
|
6015
|
+
// The App source picker is an executable-block catalog. Domain terms,
|
|
6016
|
+
// skills, and other .dql context remain available to planning, but they
|
|
6017
|
+
// must not appear as addable dashboard data sources.
|
|
6018
|
+
if (!name)
|
|
6019
|
+
continue;
|
|
3419
6020
|
const tags = matchArray(source, /tags\s*=\s*\[([^\]]*)\]/);
|
|
3420
6021
|
blocks.push({
|
|
3421
6022
|
id: name,
|
|
@@ -3425,10 +6026,13 @@ function collectBlockCandidates(projectRoot) {
|
|
|
3425
6026
|
owner: matchString(source, /owner\s*=\s*"([^"]+)"/),
|
|
3426
6027
|
tags,
|
|
3427
6028
|
path: relative(projectRoot, filePath).replaceAll('\\', '/'),
|
|
6029
|
+
fingerprint: `sha256:${createHash('sha256').update(source).digest('hex')}`,
|
|
3428
6030
|
lastModified: stat?.mtime.toISOString() ?? new Date(0).toISOString(),
|
|
3429
6031
|
description: matchString(source, /description\s*=\s*"((?:[^"\\]|\\.)*)"/) ?? '',
|
|
3430
6032
|
llmContext: matchString(source, /llmContext\s*=\s*"((?:[^"\\]|\\.)*)"/),
|
|
3431
6033
|
chartType: matchString(source, /chart\s*=\s*"([^"]+)"/) ?? matchString(source, /chart\.(\w+)\s*\(/) ?? undefined,
|
|
6034
|
+
filterIds: declaredAppFilterIds(source),
|
|
6035
|
+
dimensionIds: declaredAppDimensionIds(source),
|
|
3432
6036
|
score: 0,
|
|
3433
6037
|
reasons: [],
|
|
3434
6038
|
});
|
|
@@ -3441,7 +6045,37 @@ function collectBlockCandidates(projectRoot) {
|
|
|
3441
6045
|
};
|
|
3442
6046
|
scanDir(join(projectRoot, 'blocks'));
|
|
3443
6047
|
scanDir(join(projectRoot, 'domains'));
|
|
3444
|
-
|
|
6048
|
+
if (options.preserveDuplicates)
|
|
6049
|
+
return blocks.sort((left, right) => left.path.localeCompare(right.path));
|
|
6050
|
+
const byId = new Map();
|
|
6051
|
+
for (const block of blocks) {
|
|
6052
|
+
const current = byId.get(block.id);
|
|
6053
|
+
if (!current) {
|
|
6054
|
+
byId.set(block.id, block);
|
|
6055
|
+
continue;
|
|
6056
|
+
}
|
|
6057
|
+
const blockRank = (block.status === 'certified' ? 4 : 0) + (block.path.startsWith('domains/') ? 2 : 0);
|
|
6058
|
+
const currentRank = (current.status === 'certified' ? 4 : 0) + (current.path.startsWith('domains/') ? 2 : 0);
|
|
6059
|
+
if (blockRank > currentRank || (blockRank === currentRank && block.lastModified > current.lastModified))
|
|
6060
|
+
byId.set(block.id, block);
|
|
6061
|
+
}
|
|
6062
|
+
return Array.from(byId.values());
|
|
6063
|
+
}
|
|
6064
|
+
function canonicalAppBlockSourceId(block) {
|
|
6065
|
+
const pathIdentity = createHash('sha256').update(`${block.path}\u0000${block.name}`).digest('hex').slice(0, 20);
|
|
6066
|
+
return `app:block:${block.domain || 'global'}:${pathIdentity}`;
|
|
6067
|
+
}
|
|
6068
|
+
function declaredAppFilterIds(source) {
|
|
6069
|
+
const filterSection = sectionBody(source, 'filterBindings');
|
|
6070
|
+
const parameterSection = sectionBody(source, 'parameterPolicy');
|
|
6071
|
+
return unique([
|
|
6072
|
+
...matchArray(source, /allowedFilters\s*=\s*\[([^\]]*)\]/),
|
|
6073
|
+
...Array.from(filterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"[^"]+"/gm)).map((match) => match[1]),
|
|
6074
|
+
...Array.from(parameterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"(?:dynamic|optional|business)"/gm)).map((match) => match[1]),
|
|
6075
|
+
]);
|
|
6076
|
+
}
|
|
6077
|
+
function declaredAppDimensionIds(source) {
|
|
6078
|
+
return unique(matchArray(source, /dimensions\s*=\s*\[([^\]]*)\]/));
|
|
3445
6079
|
}
|
|
3446
6080
|
function readdirSyncSafe(dir) {
|
|
3447
6081
|
try {
|
|
@@ -4111,10 +6745,13 @@ function resolveSelectedBlock(projectRoot, selected, sourceBlockId) {
|
|
|
4111
6745
|
owner: matchString(source, /owner\s*=\s*"([^"]+)"/),
|
|
4112
6746
|
tags: matchArray(source, /tags\s*=\s*\[([^\]]*)\]/),
|
|
4113
6747
|
path: normalizedPath,
|
|
6748
|
+
fingerprint: `sha256:${createHash('sha256').update(source).digest('hex')}`,
|
|
4114
6749
|
lastModified: statSyncSafe(join(projectRoot, normalizedPath))?.mtime.toISOString() ?? new Date(0).toISOString(),
|
|
4115
6750
|
description: matchString(source, /description\s*=\s*"((?:[^"\\]|\\.)*)"/) ?? '',
|
|
4116
6751
|
llmContext: matchString(source, /llmContext\s*=\s*"((?:[^"\\]|\\.)*)"/),
|
|
4117
6752
|
chartType: matchString(source, /chart\s*=\s*"([^"]+)"/) ?? undefined,
|
|
6753
|
+
filterIds: declaredAppFilterIds(source),
|
|
6754
|
+
dimensionIds: declaredAppDimensionIds(source),
|
|
4118
6755
|
score: 0,
|
|
4119
6756
|
reasons: [],
|
|
4120
6757
|
};
|
|
@@ -5051,14 +7688,29 @@ function uniqueAppPageId(base, existingIds) {
|
|
|
5051
7688
|
* deliberately stricter than the metadata snapshot: page, notebook, draft, and
|
|
5052
7689
|
* App-document edits all invalidate approval before the atomic directory swap.
|
|
5053
7690
|
*/
|
|
7691
|
+
/**
|
|
7692
|
+
* Fingerprint the parts of an App package a write can actually conflict with:
|
|
7693
|
+
* the manifest and the dashboard documents.
|
|
7694
|
+
*
|
|
7695
|
+
* Hashing every byte under the App directory made the guard far too sensitive —
|
|
7696
|
+
* a notebook run output, a README edit, or an attached notebook would
|
|
7697
|
+
* invalidate a page proposal that could not possibly conflict with them, and
|
|
7698
|
+
* the commit failed with an unexplained 409.
|
|
7699
|
+
*/
|
|
5054
7700
|
function fingerprintAppDirectory(appDir) {
|
|
5055
7701
|
const files = [];
|
|
5056
7702
|
const visit = (directory) => {
|
|
5057
7703
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
5058
7704
|
const absolutePath = join(directory, entry.name);
|
|
5059
|
-
if (entry.isDirectory())
|
|
7705
|
+
if (entry.isDirectory()) {
|
|
7706
|
+
// Only `dashboards/` participates; notebooks, drafts and generated prose do not.
|
|
7707
|
+
if (directory === appDir && entry.name !== 'dashboards')
|
|
7708
|
+
continue;
|
|
5060
7709
|
visit(absolutePath);
|
|
7710
|
+
}
|
|
5061
7711
|
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
7712
|
+
if (directory === appDir && entry.name !== 'dql.app.json')
|
|
7713
|
+
continue;
|
|
5062
7714
|
files.push({ path: absolutePath, symbolicLink: entry.isSymbolicLink() });
|
|
5063
7715
|
}
|
|
5064
7716
|
}
|
|
@@ -5679,6 +8331,32 @@ function readNotebookRunSnapshot(absNotebookPath) {
|
|
|
5679
8331
|
function normalizeNotebookRole(value) {
|
|
5680
8332
|
return value === 'source' || value === 'analysis' ? value : 'supporting';
|
|
5681
8333
|
}
|
|
8334
|
+
/** Longest draft SQL carried into a prompt; drafts are context, not the answer. */
|
|
8335
|
+
const APP_DRAFT_SQL_PROMPT_LIMIT = 1200;
|
|
8336
|
+
/**
|
|
8337
|
+
* The app's drafts, with enough of each to be usable as context.
|
|
8338
|
+
*
|
|
8339
|
+
* `listAppDrafts` reads the same files for the Drafts panel but keeps only a
|
|
8340
|
+
* name and review status, which is why the drafts were visible in the UI and
|
|
8341
|
+
* invisible to the copilot even once the envelope carried them.
|
|
8342
|
+
*/
|
|
8343
|
+
function loadAppDraftContext(projectRoot, appDir) {
|
|
8344
|
+
return scanFiles(join(appDir, 'drafts'), '.dql').map((file) => {
|
|
8345
|
+
const source = readFileSync(file, 'utf-8');
|
|
8346
|
+
const path = relative(projectRoot, file).replaceAll('\\', '/');
|
|
8347
|
+
const query = matchString(source, /query\s*=\s*"""([\s\S]*?)"""/)?.trim();
|
|
8348
|
+
return {
|
|
8349
|
+
name: matchString(source, /block\s+"([^"]+)"/) ?? titleFromPath(path),
|
|
8350
|
+
path,
|
|
8351
|
+
status: matchString(source, /status\s*=\s*"([^"]+)"/) ?? 'review',
|
|
8352
|
+
description: matchString(source, /description\s*=\s*"([^"]*)"/) ?? undefined,
|
|
8353
|
+
question: matchString(source, /question\s*=\s*"([^"]*)"/) ?? undefined,
|
|
8354
|
+
sql: query
|
|
8355
|
+
? query.length > APP_DRAFT_SQL_PROMPT_LIMIT ? `${query.slice(0, APP_DRAFT_SQL_PROMPT_LIMIT)}\n-- …truncated` : query
|
|
8356
|
+
: undefined,
|
|
8357
|
+
};
|
|
8358
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
8359
|
+
}
|
|
5682
8360
|
function listAppDrafts(projectRoot, appDir) {
|
|
5683
8361
|
return scanFiles(join(appDir, 'drafts'), '.dql').map((file) => {
|
|
5684
8362
|
const source = readFileSync(file, 'utf-8');
|
|
@@ -5788,6 +8466,7 @@ function loadDashboardForApp(projectRoot, appId, dashboardId) {
|
|
|
5788
8466
|
}
|
|
5789
8467
|
return null;
|
|
5790
8468
|
}
|
|
8469
|
+
export { writeDashboard as writeDashboardForTest };
|
|
5791
8470
|
function writeDashboard(projectRoot, appId, dashboardId, payload) {
|
|
5792
8471
|
// Validate against the dashboard schema before touching disk.
|
|
5793
8472
|
const { document, errors } = parseDashboardDocument(JSON.stringify(payload), '<incoming>');
|
|
@@ -5803,6 +8482,22 @@ function writeDashboard(projectRoot, appId, dashboardId, payload) {
|
|
|
5803
8482
|
return { ok: false, error: `App "${appId}" not found` };
|
|
5804
8483
|
const appDir = loaded.appDir;
|
|
5805
8484
|
const dashboardPath = join(appDir, 'dashboards', `${dashboardId}.dqld`);
|
|
8485
|
+
// Dashboard ids are unique only within an App — `overview` is a common name —
|
|
8486
|
+
// so a caller holding one App's document and another App's id would silently
|
|
8487
|
+
// overwrite the wrong file. Only accept a title change to an existing page,
|
|
8488
|
+
// never a wholesale replacement of a different App's page of the same name.
|
|
8489
|
+
if (existsSync(dashboardPath)) {
|
|
8490
|
+
const existing = loadDashboardDocument(dashboardPath).document;
|
|
8491
|
+
const incomingBlocks = new Set(document.layout.items.map((item) => item.i));
|
|
8492
|
+
const existingBlocks = new Set((existing?.layout.items ?? []).map((item) => item.i));
|
|
8493
|
+
const overlap = [...incomingBlocks].filter((tileId) => existingBlocks.has(tileId)).length;
|
|
8494
|
+
if (existingBlocks.size > 0 && incomingBlocks.size > 0 && overlap === 0) {
|
|
8495
|
+
return {
|
|
8496
|
+
ok: false,
|
|
8497
|
+
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.`,
|
|
8498
|
+
};
|
|
8499
|
+
}
|
|
8500
|
+
}
|
|
5806
8501
|
mkdirSync(dirname(dashboardPath), { recursive: true });
|
|
5807
8502
|
writeFileSync(dashboardPath, JSON.stringify(document, null, 2) + '\n', 'utf-8');
|
|
5808
8503
|
return { ok: true, path: dashboardPath };
|