@duckcodeailabs/dql-cli 1.5.1 → 1.5.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 +45 -0
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +351 -12
- package/dist/apps-api.js.map +1 -1
- package/dist/apps-api.test.js +39 -2
- package/dist/apps-api.test.js.map +1 -1
- package/dist/assets/dql-notebook/assets/index-B5jI3I8Q.js +869 -0
- package/dist/assets/dql-notebook/assets/index-cv-O4BEj.css +1 -0
- package/dist/assets/dql-notebook/index.html +2 -2
- package/dist/block-studio-import.d.ts +51 -1
- package/dist/block-studio-import.d.ts.map +1 -1
- package/dist/block-studio-import.js +153 -18
- package/dist/block-studio-import.js.map +1 -1
- package/dist/block-studio-import.test.js +59 -1
- package/dist/block-studio-import.test.js.map +1 -1
- package/dist/local-runtime.d.ts +46 -0
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +716 -98
- package/dist/local-runtime.js.map +1 -1
- package/dist/local-runtime.test.js +61 -2
- package/dist/local-runtime.test.js.map +1 -1
- package/package.json +11 -11
- package/dist/assets/dql-notebook/assets/index-DZ2X3-OY.js +0 -862
- package/dist/assets/dql-notebook/assets/index-R3UrqjLQ.css +0 -1
package/dist/local-runtime.js
CHANGED
|
@@ -12,8 +12,9 @@ import { handleAppsApi } from './apps-api.js';
|
|
|
12
12
|
import { getEffectiveProviderConfig, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
|
|
13
13
|
import { DQLAccessDeniedError, activePersonaAppId, assertAppAccess, loadRuntimeApp, runtimeVariables, } from './governance-runtime.js';
|
|
14
14
|
import { LocalAppStorage, defaultLocalAppsDbPath } from '@duckcodeailabs/dql-project';
|
|
15
|
+
import { Certifier } from '@duckcodeailabs/dql-governance';
|
|
15
16
|
import { buildSemanticObjectDetail, buildSemanticTree, computeSyncDiff, loadSemanticImportManifest, performSemanticImport, previewSemanticImport, syncSemanticImport, } from './semantic-import.js';
|
|
16
|
-
import { createBlockStudioImportSession, loadBlockStudioImportSession, readBlockStudioImportCandidate, updateBlockStudioImportCandidate, writeBlockStudioImportCandidate, } from './block-studio-import.js';
|
|
17
|
+
import { clearBlockStudioImportSessions, createBlockStudioImportSession, deleteBlockStudioImportSession, listBlockStudioImportSessions, loadBlockStudioImportSession, readBlockStudioImportCandidate, updateBlockStudioImportCandidate, writeBlockStudioImportSession, writeBlockStudioImportCandidate, } from './block-studio-import.js';
|
|
17
18
|
import { MetricFlowUnavailableError, compileMetricFlowQuery, hasDbtSemanticManifest, } from './metricflow.js';
|
|
18
19
|
export async function startLocalServer(opts) {
|
|
19
20
|
const { rootDir, executor, connection: rawConnection, preferredPort, projectRoot = process.cwd() } = opts;
|
|
@@ -81,6 +82,94 @@ export async function startLocalServer(opts) {
|
|
|
81
82
|
const result = await executor.executeQuery(prepared.sql, [], runtimeVariables({}), prepared.connection);
|
|
82
83
|
return normalizeQueryResult(result, semantic.semanticRefs);
|
|
83
84
|
};
|
|
85
|
+
const runNotebookForApp = async (appId, notebookPath) => {
|
|
86
|
+
const absPath = safeJoin(projectRoot, notebookPath);
|
|
87
|
+
if (!absPath || !existsSync(absPath) || statSync(absPath).isDirectory() || !absPath.endsWith('.dqlnb')) {
|
|
88
|
+
throw new Error(`Notebook not found: ${notebookPath}`);
|
|
89
|
+
}
|
|
90
|
+
const app = loadRuntimeApp(projectRoot, appId);
|
|
91
|
+
if (!app)
|
|
92
|
+
throw new Error(`App "${appId}" not found`);
|
|
93
|
+
const raw = readFileSync(absPath, 'utf-8');
|
|
94
|
+
const parsed = JSON.parse(raw);
|
|
95
|
+
const sourceCells = parsed.cells ?? [];
|
|
96
|
+
const resultByName = new Map();
|
|
97
|
+
const resultById = new Map();
|
|
98
|
+
const snapshotCells = [];
|
|
99
|
+
for (let index = 0; index < sourceCells.length; index++) {
|
|
100
|
+
const sourceCell = sourceCells[index];
|
|
101
|
+
const cellId = typeof sourceCell.id === 'string' ? sourceCell.id : `cell-${index + 1}`;
|
|
102
|
+
const type = typeof sourceCell.type === 'string' ? sourceCell.type : 'sql';
|
|
103
|
+
const title = typeof sourceCell.name === 'string'
|
|
104
|
+
? sourceCell.name
|
|
105
|
+
: typeof sourceCell.title === 'string'
|
|
106
|
+
? sourceCell.title
|
|
107
|
+
: undefined;
|
|
108
|
+
const executedAt = new Date().toISOString();
|
|
109
|
+
if (type === 'sql' || type === 'dql') {
|
|
110
|
+
try {
|
|
111
|
+
const cell = {
|
|
112
|
+
id: cellId,
|
|
113
|
+
type: type,
|
|
114
|
+
source: typeof sourceCell.content === 'string'
|
|
115
|
+
? sourceCell.content
|
|
116
|
+
: typeof sourceCell.source === 'string'
|
|
117
|
+
? sourceCell.source
|
|
118
|
+
: '',
|
|
119
|
+
title,
|
|
120
|
+
config: (sourceCell.chartConfig ?? sourceCell.config),
|
|
121
|
+
};
|
|
122
|
+
const resolved = resolveNotebookBlockReferenceCell(cell, projectRoot);
|
|
123
|
+
const plan = buildExecutionPlan(resolved.cell, { semanticLayer, driver: connection.driver });
|
|
124
|
+
if (!plan) {
|
|
125
|
+
snapshotCells.push({ cellId, status: 'idle', executionCount: 0, executedAt });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const prepared = prepareLocalExecution(plan.sql, connection, projectRoot, projectConfig);
|
|
129
|
+
assertAppAccess({ app, domain: resolved.domain ?? app.domain, level: 'execute' });
|
|
130
|
+
const rawResult = await executor.executeQuery(prepared.sql, plan.sqlParams, runtimeVariables(plan.variables), prepared.connection);
|
|
131
|
+
const result = normalizeQueryResult(rawResult);
|
|
132
|
+
snapshotCells.push({
|
|
133
|
+
cellId,
|
|
134
|
+
status: 'success',
|
|
135
|
+
result,
|
|
136
|
+
executionCount: 1,
|
|
137
|
+
executedAt,
|
|
138
|
+
});
|
|
139
|
+
resultById.set(cellId, result);
|
|
140
|
+
if (title)
|
|
141
|
+
resultByName.set(title, result);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
snapshotCells.push({
|
|
145
|
+
cellId,
|
|
146
|
+
status: 'error',
|
|
147
|
+
error: err instanceof Error ? err.message : String(err),
|
|
148
|
+
executionCount: 1,
|
|
149
|
+
executedAt,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const upstream = typeof sourceCell.upstream === 'string' ? sourceCell.upstream : undefined;
|
|
155
|
+
const upstreamResult = upstream ? resultByName.get(upstream) ?? resultById.get(upstream) : undefined;
|
|
156
|
+
if (upstreamResult && (type === 'chart' || type === 'table' || type === 'pivot' || type === 'single_value' || type === 'filter')) {
|
|
157
|
+
snapshotCells.push({
|
|
158
|
+
cellId,
|
|
159
|
+
status: 'success',
|
|
160
|
+
result: upstreamResult,
|
|
161
|
+
executionCount: 1,
|
|
162
|
+
executedAt,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
writeRunSnapshot(projectRoot, notebookPath, {
|
|
167
|
+
version: 1,
|
|
168
|
+
notebookPath,
|
|
169
|
+
capturedAt: new Date().toISOString(),
|
|
170
|
+
cells: snapshotCells,
|
|
171
|
+
});
|
|
172
|
+
};
|
|
84
173
|
const executeCertifiedBlockForAgent = async (node) => {
|
|
85
174
|
if (node.kind !== 'block') {
|
|
86
175
|
throw new Error(`Certified ${node.kind} "${node.name}" is a navigation artifact and cannot be executed as a block.`);
|
|
@@ -218,6 +307,122 @@ export async function startLocalServer(opts) {
|
|
|
218
307
|
chartConfig: plan?.chartConfig ?? validation.chartConfig ?? null,
|
|
219
308
|
};
|
|
220
309
|
};
|
|
310
|
+
const runBlockStudioTestSummary = async (source, targetConnection = connection) => {
|
|
311
|
+
const start = Date.now();
|
|
312
|
+
const plan = buildExecutionPlan({ id: 'block-studio-tests', type: 'dql', source, title: 'Block Studio' }, { semanticLayer, driver: targetConnection.driver });
|
|
313
|
+
const tests = plan?.tests ?? [];
|
|
314
|
+
if (!plan || !plan.sql) {
|
|
315
|
+
return {
|
|
316
|
+
passed: 0,
|
|
317
|
+
failed: Math.max(tests.length, 1),
|
|
318
|
+
skipped: 0,
|
|
319
|
+
duration: Date.now() - start,
|
|
320
|
+
assertions: [{
|
|
321
|
+
name: 'build execution plan',
|
|
322
|
+
passed: false,
|
|
323
|
+
error: 'Could not build an execution plan for this block.',
|
|
324
|
+
}],
|
|
325
|
+
runAt: new Date(),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (tests.length === 0) {
|
|
329
|
+
return { passed: 0, failed: 0, skipped: 0, duration: Date.now() - start, assertions: [], runAt: new Date() };
|
|
330
|
+
}
|
|
331
|
+
const prepared = prepareLocalExecution(plan.sql, targetConnection, projectRoot, projectConfig);
|
|
332
|
+
const rawResult = await executor.executeQuery(prepared.sql, plan.sqlParams ?? [], runtimeVariables(plan.variables ?? {}), prepared.connection);
|
|
333
|
+
const rows = Array.isArray(rawResult?.rows) ? rawResult.rows : [];
|
|
334
|
+
const columns = Array.isArray(rawResult?.columns)
|
|
335
|
+
? rawResult.columns.map((column) => typeof column === 'string' ? column : column?.name ?? String(column))
|
|
336
|
+
: [];
|
|
337
|
+
const assertions = [];
|
|
338
|
+
let passed = 0;
|
|
339
|
+
let failed = 0;
|
|
340
|
+
for (const test of tests) {
|
|
341
|
+
const name = `assert ${test.field} ${test.operator} ${formatBlockStudioExpected(test.expected)}`;
|
|
342
|
+
let actual;
|
|
343
|
+
if (test.field === 'row_count') {
|
|
344
|
+
actual = rows.length;
|
|
345
|
+
}
|
|
346
|
+
else if (!columns.includes(test.field)) {
|
|
347
|
+
assertions.push({ name, passed: false, expected: test.expected, error: `Column '${test.field}' not found in results` });
|
|
348
|
+
failed += 1;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
actual = rows[0]?.[test.field];
|
|
353
|
+
}
|
|
354
|
+
const ok = compareBlockStudioValues(actual, test.operator, test.expected);
|
|
355
|
+
if (ok) {
|
|
356
|
+
assertions.push({ name, passed: true, actual, expected: test.expected });
|
|
357
|
+
passed += 1;
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
assertions.push({
|
|
361
|
+
name,
|
|
362
|
+
passed: false,
|
|
363
|
+
actual,
|
|
364
|
+
expected: test.expected,
|
|
365
|
+
error: `${String(actual)} ${test.operator} ${formatBlockStudioExpected(test.expected)} is false`,
|
|
366
|
+
});
|
|
367
|
+
failed += 1;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { passed, failed, skipped: 0, duration: Date.now() - start, assertions, runAt: new Date() };
|
|
371
|
+
};
|
|
372
|
+
const certifyBlockStudioSource = async (source, blockPath) => {
|
|
373
|
+
const validation = validateBlockStudioSource(source, semanticLayer);
|
|
374
|
+
let preview = null;
|
|
375
|
+
let testResults = null;
|
|
376
|
+
const blockers = [];
|
|
377
|
+
try {
|
|
378
|
+
preview = await runBlockStudioPreviewSource(source);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
blockers.push(error instanceof Error ? error.message : String(error));
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
testResults = await runBlockStudioTestSummary(source);
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
testResults = {
|
|
388
|
+
passed: 0,
|
|
389
|
+
failed: 1,
|
|
390
|
+
skipped: 0,
|
|
391
|
+
duration: 0,
|
|
392
|
+
assertions: [{ name: 'run tests', passed: false, error: error instanceof Error ? error.message : String(error) }],
|
|
393
|
+
runAt: new Date(),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const parsed = parseBlockSourceMetadata(source);
|
|
397
|
+
const record = {
|
|
398
|
+
id: parsed.name || 'local',
|
|
399
|
+
name: parsed.name || 'unnamed',
|
|
400
|
+
domain: parsed.domain,
|
|
401
|
+
type: parsed.blockType || 'custom',
|
|
402
|
+
version: '0.0.0',
|
|
403
|
+
status: 'draft',
|
|
404
|
+
gitRepo: '',
|
|
405
|
+
gitPath: blockPath ?? '',
|
|
406
|
+
gitCommitSha: '',
|
|
407
|
+
description: parsed.description,
|
|
408
|
+
owner: parsed.owner,
|
|
409
|
+
tags: parsed.tags,
|
|
410
|
+
dependencies: [],
|
|
411
|
+
usedInCount: 0,
|
|
412
|
+
createdAt: new Date(),
|
|
413
|
+
updatedAt: new Date(),
|
|
414
|
+
};
|
|
415
|
+
const certification = new Certifier().evaluate(record, testResults ?? undefined);
|
|
416
|
+
const checklist = buildBlockStudioCertificationChecklist({
|
|
417
|
+
source,
|
|
418
|
+
validation,
|
|
419
|
+
previewSucceeded: Boolean(preview),
|
|
420
|
+
testResults,
|
|
421
|
+
certificationErrors: certification.errors,
|
|
422
|
+
extraBlockers: blockers,
|
|
423
|
+
});
|
|
424
|
+
return { certification, checklist, validation, preview, testResults };
|
|
425
|
+
};
|
|
221
426
|
const server = createServer(async (req, res) => {
|
|
222
427
|
const requestUrl = req.url || '/';
|
|
223
428
|
const url = new URL(requestUrl, 'http://127.0.0.1');
|
|
@@ -521,6 +726,7 @@ export async function startLocalServer(opts) {
|
|
|
521
726
|
path,
|
|
522
727
|
projectRoot,
|
|
523
728
|
executeSql: executeLocalSqlForStoredResult,
|
|
729
|
+
runNotebook: (appId, notebookPath) => runNotebookForApp(appId, notebookPath),
|
|
524
730
|
});
|
|
525
731
|
if (handled)
|
|
526
732
|
return;
|
|
@@ -837,7 +1043,7 @@ export async function startLocalServer(opts) {
|
|
|
837
1043
|
if (req.method === 'POST' && path === '/api/blocks') {
|
|
838
1044
|
try {
|
|
839
1045
|
const body = await readJSON(req);
|
|
840
|
-
const { name, domain, content, description, tags, metricRefs, template, } = body;
|
|
1046
|
+
const { name, domain, content, description, tags, metricRefs, template, blockType, } = body;
|
|
841
1047
|
if (!name || typeof name !== 'string') {
|
|
842
1048
|
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
843
1049
|
res.end(serializeJSON({ error: 'Missing block name' }));
|
|
@@ -851,6 +1057,7 @@ export async function startLocalServer(opts) {
|
|
|
851
1057
|
tags,
|
|
852
1058
|
metricRefs,
|
|
853
1059
|
template,
|
|
1060
|
+
blockType,
|
|
854
1061
|
});
|
|
855
1062
|
res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
856
1063
|
res.end(serializeJSON(created));
|
|
@@ -1052,22 +1259,12 @@ export async function startLocalServer(opts) {
|
|
|
1052
1259
|
res.end(serializeJSON({ error: `Status must be one of: ${validStatuses.join(', ')}` }));
|
|
1053
1260
|
return;
|
|
1054
1261
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
res.
|
|
1058
|
-
res.end(serializeJSON({ error: 'Block file not found' }));
|
|
1262
|
+
if (newStatus === 'certified') {
|
|
1263
|
+
res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1264
|
+
res.end(serializeJSON({ error: 'Use /api/block-studio/certify so validation, run, and tests gate certification.' }));
|
|
1059
1265
|
return;
|
|
1060
1266
|
}
|
|
1061
|
-
|
|
1062
|
-
// Update or insert status field
|
|
1063
|
-
if (/status\s*=\s*"[^"]*"/.test(source)) {
|
|
1064
|
-
source = source.replace(/status\s*=\s*"[^"]*"/, `status = "${newStatus}"`);
|
|
1065
|
-
}
|
|
1066
|
-
else {
|
|
1067
|
-
// Insert after first { in block declaration
|
|
1068
|
-
source = source.replace(/block\s+"[^"]*"\s*\{/, (match) => `${match}\n status = "${newStatus}"`);
|
|
1069
|
-
}
|
|
1070
|
-
writeFileSync(absPath, source, 'utf-8');
|
|
1267
|
+
setBlockStudioStatus(projectRoot, blockPath, newStatus);
|
|
1071
1268
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1072
1269
|
res.end(serializeJSON({ ok: true, status: newStatus }));
|
|
1073
1270
|
}
|
|
@@ -1158,80 +1355,48 @@ export async function startLocalServer(opts) {
|
|
|
1158
1355
|
res.end(serializeJSON({ error: 'source is required' }));
|
|
1159
1356
|
return;
|
|
1160
1357
|
}
|
|
1161
|
-
|
|
1162
|
-
const
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1358
|
+
const summary = await runBlockStudioTestSummary(source);
|
|
1359
|
+
const results = summary.assertions.map((assertion) => ({
|
|
1360
|
+
name: assertion.name,
|
|
1361
|
+
field: assertion.name.match(/^assert\s+(\S+)/)?.[1] ?? assertion.name,
|
|
1362
|
+
operator: assertion.name.match(/^assert\s+\S+\s+(\S+)/)?.[1] ?? '',
|
|
1363
|
+
expected: assertion.expected !== undefined ? String(assertion.expected) : assertion.name.replace(/^assert\s+\S+\s+\S+\s*/, ''),
|
|
1364
|
+
passed: assertion.passed,
|
|
1365
|
+
actual: assertion.error ?? (assertion.actual !== undefined ? String(assertion.actual) : undefined),
|
|
1366
|
+
}));
|
|
1367
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1368
|
+
res.end(serializeJSON({ assertions: results, passed: summary.passed, failed: summary.failed, duration: summary.duration }));
|
|
1369
|
+
}
|
|
1370
|
+
catch (error) {
|
|
1371
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1372
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
1373
|
+
}
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
if (req.method === 'POST' && path === '/api/block-studio/certify') {
|
|
1377
|
+
try {
|
|
1378
|
+
const body = await readJSON(req);
|
|
1379
|
+
const source = typeof body.source === 'string' ? body.source : '';
|
|
1380
|
+
const blockPath = typeof body.path === 'string' ? body.path : null;
|
|
1381
|
+
if (!source.trim()) {
|
|
1166
1382
|
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1167
|
-
res.end(serializeJSON({ error: '
|
|
1168
|
-
return;
|
|
1169
|
-
}
|
|
1170
|
-
const testNodes = blockNode.tests ?? [];
|
|
1171
|
-
if (testNodes.length === 0) {
|
|
1172
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1173
|
-
res.end(serializeJSON({ assertions: [], passed: 0, failed: 0, duration: 0 }));
|
|
1383
|
+
res.end(serializeJSON({ error: 'source is required' }));
|
|
1174
1384
|
return;
|
|
1175
1385
|
}
|
|
1176
|
-
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1386
|
+
const result = await certifyBlockStudioSource(source, blockPath);
|
|
1387
|
+
const blockers = [
|
|
1388
|
+
...result.checklist.blockers,
|
|
1389
|
+
...result.certification.errors.map((error) => `${error.rule}: ${error.message}`),
|
|
1390
|
+
];
|
|
1391
|
+
if (!result.certification.certified || blockers.length > 0) {
|
|
1392
|
+
res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1393
|
+
res.end(serializeJSON({ ok: false, ...result, blockers }));
|
|
1181
1394
|
return;
|
|
1182
1395
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
const start = Date.now();
|
|
1186
|
-
const results = [];
|
|
1187
|
-
for (const test of testNodes) {
|
|
1188
|
-
const field = test.field;
|
|
1189
|
-
const op = test.operator;
|
|
1190
|
-
const expected = test.expected;
|
|
1191
|
-
// Extract the expected value from the AST node
|
|
1192
|
-
const expectedValue = typeof expected === 'object' && expected !== null
|
|
1193
|
-
? (expected.value ?? String(expected))
|
|
1194
|
-
: expected;
|
|
1195
|
-
// Build a SQL query that computes the aggregate for this assertion
|
|
1196
|
-
const testSql = `SELECT ${field} AS test_value FROM (${resolvedSql}) AS __test_block`;
|
|
1197
|
-
try {
|
|
1198
|
-
const result = await executor.executeQuery(testSql, [], {}, connection);
|
|
1199
|
-
const actualRaw = result.rows?.[0];
|
|
1200
|
-
const actual = actualRaw ? Object.values(actualRaw)[0] : undefined;
|
|
1201
|
-
const actualNum = Number(actual);
|
|
1202
|
-
const expectedNum = Number(expectedValue);
|
|
1203
|
-
let passed = false;
|
|
1204
|
-
switch (op) {
|
|
1205
|
-
case '>':
|
|
1206
|
-
passed = actualNum > expectedNum;
|
|
1207
|
-
break;
|
|
1208
|
-
case '<':
|
|
1209
|
-
passed = actualNum < expectedNum;
|
|
1210
|
-
break;
|
|
1211
|
-
case '>=':
|
|
1212
|
-
passed = actualNum >= expectedNum;
|
|
1213
|
-
break;
|
|
1214
|
-
case '<=':
|
|
1215
|
-
passed = actualNum <= expectedNum;
|
|
1216
|
-
break;
|
|
1217
|
-
case '==':
|
|
1218
|
-
passed = String(actual) === String(expectedValue);
|
|
1219
|
-
break;
|
|
1220
|
-
case '!=':
|
|
1221
|
-
passed = String(actual) !== String(expectedValue);
|
|
1222
|
-
break;
|
|
1223
|
-
default: passed = false;
|
|
1224
|
-
}
|
|
1225
|
-
results.push({ field, operator: op, expected: String(expectedValue), passed, actual: String(actual ?? '') });
|
|
1226
|
-
}
|
|
1227
|
-
catch (err) {
|
|
1228
|
-
results.push({ field, operator: op, expected: String(expectedValue), passed: false, actual: `Error: ${err instanceof Error ? err.message : String(err)}` });
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
const duration = Date.now() - start;
|
|
1232
|
-
const passed = results.filter((r) => r.passed).length;
|
|
1396
|
+
if (blockPath)
|
|
1397
|
+
setBlockStudioStatus(projectRoot, blockPath, 'certified');
|
|
1233
1398
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1234
|
-
res.end(serializeJSON({
|
|
1399
|
+
res.end(serializeJSON({ ok: true, status: 'certified', ...result }));
|
|
1235
1400
|
}
|
|
1236
1401
|
catch (error) {
|
|
1237
1402
|
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
@@ -1239,12 +1404,36 @@ export async function startLocalServer(opts) {
|
|
|
1239
1404
|
}
|
|
1240
1405
|
return;
|
|
1241
1406
|
}
|
|
1242
|
-
if (req.method === '
|
|
1407
|
+
if (req.method === 'GET' && path === '/api/block-studio/imports') {
|
|
1408
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1409
|
+
res.end(serializeJSON({ sessions: listBlockStudioImportSessions(projectRoot) }));
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
if (req.method === 'DELETE' && path === '/api/block-studio/imports') {
|
|
1413
|
+
try {
|
|
1414
|
+
const removed = clearBlockStudioImportSessions(projectRoot);
|
|
1415
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1416
|
+
res.end(serializeJSON({ ok: true, removed }));
|
|
1417
|
+
}
|
|
1418
|
+
catch (error) {
|
|
1419
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1420
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
1421
|
+
}
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
if (req.method === 'POST' && (path === '/api/block-studio/import/preview' || path === '/api/block-studio/imports')) {
|
|
1243
1425
|
try {
|
|
1244
1426
|
const body = await readJSON(req);
|
|
1245
1427
|
const inputPath = typeof body.path === 'string' ? body.path : '';
|
|
1246
1428
|
const session = createBlockStudioImportSession(projectRoot, {
|
|
1247
1429
|
inputPath,
|
|
1430
|
+
inputMode: body.inputMode === 'paste' || body.inputMode === 'upload' || body.inputMode === 'path' ? body.inputMode : undefined,
|
|
1431
|
+
sources: Array.isArray(body.sources)
|
|
1432
|
+
? body.sources.map((source, index) => ({
|
|
1433
|
+
path: typeof source?.path === 'string' ? source.path : `source-${index + 1}.sql`,
|
|
1434
|
+
content: typeof source?.content === 'string' ? source.content : '',
|
|
1435
|
+
}))
|
|
1436
|
+
: undefined,
|
|
1248
1437
|
sourceKind: typeof body.sourceKind === 'string' ? body.sourceKind : 'raw-sql',
|
|
1249
1438
|
domain: typeof body.domain === 'string' ? body.domain : undefined,
|
|
1250
1439
|
owner: typeof body.owner === 'string' ? body.owner : undefined,
|
|
@@ -1264,12 +1453,65 @@ export async function startLocalServer(opts) {
|
|
|
1264
1453
|
}
|
|
1265
1454
|
return;
|
|
1266
1455
|
}
|
|
1267
|
-
const
|
|
1456
|
+
const importSaveAllMatch = path.match(/^\/api\/block-studio\/imports\/([^/]+)\/save-all$/);
|
|
1457
|
+
if (importSaveAllMatch && req.method === 'POST') {
|
|
1458
|
+
const importId = decodeURIComponent(importSaveAllMatch[1]);
|
|
1459
|
+
try {
|
|
1460
|
+
const session = loadBlockStudioImportSession(projectRoot, importId);
|
|
1461
|
+
const saved = [];
|
|
1462
|
+
const errors = [];
|
|
1463
|
+
const nextCandidates = [...session.candidates];
|
|
1464
|
+
for (let i = 0; i < nextCandidates.length; i += 1) {
|
|
1465
|
+
const candidate = nextCandidates[i];
|
|
1466
|
+
if (candidate.reviewStatus === 'saved' || candidate.reviewStatus === 'rejected' || candidate.validation?.valid === false)
|
|
1467
|
+
continue;
|
|
1468
|
+
try {
|
|
1469
|
+
const savedPath = saveBlockStudioArtifacts(projectRoot, {
|
|
1470
|
+
source: candidate.dqlSource,
|
|
1471
|
+
name: candidate.name,
|
|
1472
|
+
domain: candidate.domain,
|
|
1473
|
+
description: candidate.description,
|
|
1474
|
+
owner: candidate.owner,
|
|
1475
|
+
tags: candidate.tags,
|
|
1476
|
+
lineage: candidate.lineage.sourceTables,
|
|
1477
|
+
importMeta: {
|
|
1478
|
+
importId,
|
|
1479
|
+
candidateId: candidate.id,
|
|
1480
|
+
sourceKind: candidate.sourceKind,
|
|
1481
|
+
sourcePath: candidate.sourcePath,
|
|
1482
|
+
},
|
|
1483
|
+
});
|
|
1484
|
+
nextCandidates[i] = { ...candidate, reviewStatus: 'saved', savedPath };
|
|
1485
|
+
writeBlockStudioImportCandidate(projectRoot, importId, nextCandidates[i]);
|
|
1486
|
+
saved.push({ candidateId: candidate.id, path: savedPath });
|
|
1487
|
+
}
|
|
1488
|
+
catch (error) {
|
|
1489
|
+
errors.push({ candidateId: candidate.id, error: error instanceof Error ? error.message : String(error) });
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
const nextSession = { ...session, candidates: nextCandidates, updatedAt: new Date().toISOString() };
|
|
1493
|
+
writeBlockStudioImportSession(projectRoot, nextSession);
|
|
1494
|
+
res.writeHead(errors.length > 0 ? 207 : 200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1495
|
+
res.end(serializeJSON({ ok: errors.length === 0, session: nextSession, saved, errors }));
|
|
1496
|
+
}
|
|
1497
|
+
catch (error) {
|
|
1498
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1499
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
1500
|
+
}
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
const importPathMatch = path.match(/^\/api\/block-studio\/imports\/([^/]+)(?:\/candidates\/([^/]+)(?:\/(run|save|ai-assist))?)?$/);
|
|
1268
1504
|
if (importPathMatch) {
|
|
1269
1505
|
const importId = decodeURIComponent(importPathMatch[1]);
|
|
1270
1506
|
const candidateId = importPathMatch[2] ? decodeURIComponent(importPathMatch[2]) : null;
|
|
1271
1507
|
const action = importPathMatch[3] ?? null;
|
|
1272
1508
|
try {
|
|
1509
|
+
if (req.method === 'DELETE' && !candidateId) {
|
|
1510
|
+
deleteBlockStudioImportSession(projectRoot, importId);
|
|
1511
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1512
|
+
res.end(serializeJSON({ ok: true }));
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1273
1515
|
if (req.method === 'GET' && !candidateId) {
|
|
1274
1516
|
const session = loadBlockStudioImportSession(projectRoot, importId);
|
|
1275
1517
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
@@ -1305,6 +1547,31 @@ export async function startLocalServer(opts) {
|
|
|
1305
1547
|
res.end(serializeJSON(next));
|
|
1306
1548
|
return;
|
|
1307
1549
|
}
|
|
1550
|
+
if (req.method === 'POST' && candidateId && action === 'ai-assist') {
|
|
1551
|
+
const body = await readJSON(req).catch(() => ({}));
|
|
1552
|
+
const candidate = readBlockStudioImportCandidate(projectRoot, importId, candidateId);
|
|
1553
|
+
const actionName = typeof body.action === 'string' ? body.action : 'explain';
|
|
1554
|
+
const validation = validateBlockStudioSource(candidate.dqlSource, semanticLayer);
|
|
1555
|
+
const assist = await buildBlockStudioAiAssistSummary(projectRoot, actionName, candidate, validation, isProviderSettingsId(body.provider) ? body.provider : undefined);
|
|
1556
|
+
const next = {
|
|
1557
|
+
...candidate,
|
|
1558
|
+
validation,
|
|
1559
|
+
aiAssistance: [
|
|
1560
|
+
...(candidate.aiAssistance ?? []),
|
|
1561
|
+
{
|
|
1562
|
+
action: actionName,
|
|
1563
|
+
summary: assist.summary,
|
|
1564
|
+
createdAt: new Date().toISOString(),
|
|
1565
|
+
status: 'suggested',
|
|
1566
|
+
provider: assist.provider,
|
|
1567
|
+
},
|
|
1568
|
+
],
|
|
1569
|
+
};
|
|
1570
|
+
writeBlockStudioImportCandidate(projectRoot, importId, next);
|
|
1571
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1572
|
+
res.end(serializeJSON(next));
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1308
1575
|
if (req.method === 'POST' && candidateId && action === 'save') {
|
|
1309
1576
|
const candidate = readBlockStudioImportCandidate(projectRoot, importId, candidateId);
|
|
1310
1577
|
const savedPath = saveBlockStudioArtifacts(projectRoot, {
|
|
@@ -1371,6 +1638,18 @@ export async function startLocalServer(opts) {
|
|
|
1371
1638
|
}
|
|
1372
1639
|
return;
|
|
1373
1640
|
}
|
|
1641
|
+
if (req.method === 'GET' && path === '/api/block-studio/dbt-status') {
|
|
1642
|
+
try {
|
|
1643
|
+
const status = buildDbtStatus(projectRoot, projectConfig, semanticLastSyncTime);
|
|
1644
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1645
|
+
res.end(serializeJSON(status));
|
|
1646
|
+
}
|
|
1647
|
+
catch (error) {
|
|
1648
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1649
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
1650
|
+
}
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1374
1653
|
if (req.method === 'GET' && path === '/api/block-studio/open') {
|
|
1375
1654
|
try {
|
|
1376
1655
|
const relativePath = url.searchParams.get('path');
|
|
@@ -3438,7 +3717,7 @@ function openBlockStudioDocument(projectRoot, relativePath, semanticLayer) {
|
|
|
3438
3717
|
description: parsedMetadata.description || companion?.description || '',
|
|
3439
3718
|
owner: parsedMetadata.owner || companion?.owner || '',
|
|
3440
3719
|
tags: parsedMetadata.tags.length > 0 ? parsedMetadata.tags : companion?.tags ?? [],
|
|
3441
|
-
reviewStatus: companion?.reviewStatus,
|
|
3720
|
+
reviewStatus: parsedMetadata.status || companion?.reviewStatus || 'draft',
|
|
3442
3721
|
};
|
|
3443
3722
|
return {
|
|
3444
3723
|
path: normalizedPath,
|
|
@@ -3862,8 +4141,225 @@ function parseBlockSourceMetadata(source) {
|
|
|
3862
4141
|
description: extractString('description'),
|
|
3863
4142
|
owner: extractString('owner'),
|
|
3864
4143
|
tags: tags ? (tags[1].match(/"([^"]*)"/g) ?? []).map((value) => value.slice(1, -1)) : [],
|
|
4144
|
+
status: extractString('status') || 'draft',
|
|
4145
|
+
blockType: extractString('type') || 'custom',
|
|
4146
|
+
};
|
|
4147
|
+
}
|
|
4148
|
+
function compareBlockStudioValues(actual, operator, expected) {
|
|
4149
|
+
const expectedValue = normalizeBlockStudioExpected(expected);
|
|
4150
|
+
if (operator === '==' || operator === '=')
|
|
4151
|
+
return String(actual) === String(expectedValue);
|
|
4152
|
+
if (operator === '!=')
|
|
4153
|
+
return String(actual) !== String(expectedValue);
|
|
4154
|
+
const actualNumber = Number(actual);
|
|
4155
|
+
const expectedNumber = Number(expectedValue);
|
|
4156
|
+
switch (operator) {
|
|
4157
|
+
case '>': return actualNumber > expectedNumber;
|
|
4158
|
+
case '>=': return actualNumber >= expectedNumber;
|
|
4159
|
+
case '<': return actualNumber < expectedNumber;
|
|
4160
|
+
case '<=': return actualNumber <= expectedNumber;
|
|
4161
|
+
default: return false;
|
|
4162
|
+
}
|
|
4163
|
+
}
|
|
4164
|
+
function normalizeBlockStudioExpected(expected) {
|
|
4165
|
+
if (expected && typeof expected === 'object' && Object.prototype.hasOwnProperty.call(expected, 'value')) {
|
|
4166
|
+
return expected.value;
|
|
4167
|
+
}
|
|
4168
|
+
if (expected && typeof expected === 'object' && Object.prototype.hasOwnProperty.call(expected, 'name')) {
|
|
4169
|
+
return expected.name;
|
|
4170
|
+
}
|
|
4171
|
+
return expected;
|
|
4172
|
+
}
|
|
4173
|
+
function formatBlockStudioExpected(expected) {
|
|
4174
|
+
const normalized = normalizeBlockStudioExpected(expected);
|
|
4175
|
+
if (normalized === null || normalized === undefined)
|
|
4176
|
+
return 'null';
|
|
4177
|
+
if (typeof normalized === 'string' || typeof normalized === 'number' || typeof normalized === 'boolean')
|
|
4178
|
+
return String(normalized);
|
|
4179
|
+
return JSON.stringify(normalized);
|
|
4180
|
+
}
|
|
4181
|
+
function buildBlockStudioCertificationChecklist(input) {
|
|
4182
|
+
const parsed = parseBlockSourceMetadata(input.source);
|
|
4183
|
+
const sql = extractBlockStudioSql(input.source) ?? '';
|
|
4184
|
+
const blockers = new Set();
|
|
4185
|
+
for (const diagnostic of input.validation.diagnostics) {
|
|
4186
|
+
if (diagnostic.severity === 'error')
|
|
4187
|
+
blockers.add(diagnostic.message);
|
|
4188
|
+
}
|
|
4189
|
+
for (const error of input.certificationErrors)
|
|
4190
|
+
blockers.add(`${error.rule}: ${error.message}`);
|
|
4191
|
+
for (const blocker of input.extraBlockers ?? [])
|
|
4192
|
+
blockers.add(blocker);
|
|
4193
|
+
if (!parsed.domain.trim())
|
|
4194
|
+
blockers.add('Missing domain');
|
|
4195
|
+
if (!parsed.owner.trim())
|
|
4196
|
+
blockers.add('Missing owner');
|
|
4197
|
+
if (!parsed.description.trim())
|
|
4198
|
+
blockers.add('Missing description');
|
|
4199
|
+
if (!input.previewSucceeded)
|
|
4200
|
+
blockers.add('Block has not run successfully');
|
|
4201
|
+
if (!input.testResults || input.testResults.failed > 0)
|
|
4202
|
+
blockers.add('Tests must pass before certification');
|
|
4203
|
+
if (!input.testResults || input.testResults.assertions.length === 0)
|
|
4204
|
+
blockers.add('At least one test assertion is required before certification');
|
|
4205
|
+
if (!input.validation.chartConfig?.chart)
|
|
4206
|
+
blockers.add('Visualization config is missing');
|
|
4207
|
+
return {
|
|
4208
|
+
metadata: Boolean(parsed.domain.trim() && parsed.owner.trim() && parsed.description.trim()),
|
|
4209
|
+
validation: input.validation.diagnostics.every((diagnostic) => diagnostic.severity !== 'error'),
|
|
4210
|
+
run: input.previewSucceeded,
|
|
4211
|
+
tests: Boolean(input.testResults && input.testResults.failed === 0 && input.testResults.assertions.length > 0),
|
|
4212
|
+
chart: Boolean(input.validation.chartConfig?.chart),
|
|
4213
|
+
lineage: extractSqlTablesLight(sql).length > 0 || input.validation.semanticRefs.metrics.length > 0,
|
|
4214
|
+
aiReviewed: true,
|
|
4215
|
+
blockers: Array.from(blockers),
|
|
4216
|
+
checkedAt: new Date().toISOString(),
|
|
3865
4217
|
};
|
|
3866
4218
|
}
|
|
4219
|
+
function extractSqlTablesLight(sql) {
|
|
4220
|
+
const tables = new Set();
|
|
4221
|
+
const cleaned = sql.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n\r]*/g, ' ');
|
|
4222
|
+
const regex = /\b(?:from|join|update|into)\s+([`"[]?[A-Za-z0-9_./:-]+(?:\.[A-Za-z0-9_./:-]+)*[`"\]]?)/gi;
|
|
4223
|
+
let match;
|
|
4224
|
+
while ((match = regex.exec(cleaned))) {
|
|
4225
|
+
const raw = match[1].replace(/^[`"[]|[`"\]]$/g, '');
|
|
4226
|
+
if (raw && !raw.startsWith('(') && !/^(select|values|unnest|lateral)$/i.test(raw))
|
|
4227
|
+
tables.add(raw);
|
|
4228
|
+
}
|
|
4229
|
+
return Array.from(tables);
|
|
4230
|
+
}
|
|
4231
|
+
function setBlockStudioStatus(projectRoot, blockPath, newStatus) {
|
|
4232
|
+
const normalizedPath = normalize(blockPath).replace(/^\/+/, '');
|
|
4233
|
+
if (!normalizedPath.startsWith('blocks/'))
|
|
4234
|
+
throw new Error('Invalid block path');
|
|
4235
|
+
const absPath = join(projectRoot, normalizedPath);
|
|
4236
|
+
if (!existsSync(absPath))
|
|
4237
|
+
throw new Error('Block file not found');
|
|
4238
|
+
let source = readFileSync(absPath, 'utf-8');
|
|
4239
|
+
if (/status\s*=\s*"[^"]*"/.test(source)) {
|
|
4240
|
+
source = source.replace(/status\s*=\s*"[^"]*"/, `status = "${newStatus}"`);
|
|
4241
|
+
}
|
|
4242
|
+
else {
|
|
4243
|
+
source = source.replace(/block\s+"[^"]*"\s*\{/, (match) => `${match}\n status = "${newStatus}"`);
|
|
4244
|
+
}
|
|
4245
|
+
writeFileSync(absPath, source, 'utf-8');
|
|
4246
|
+
const companionPath = blockCompanionRelativePath(normalizedPath);
|
|
4247
|
+
if (!companionPath)
|
|
4248
|
+
return;
|
|
4249
|
+
const absCompanionPath = join(projectRoot, companionPath);
|
|
4250
|
+
if (!existsSync(absCompanionPath))
|
|
4251
|
+
return;
|
|
4252
|
+
let companion = readFileSync(absCompanionPath, 'utf-8');
|
|
4253
|
+
if (/^reviewStatus:\s*.+$/m.test(companion)) {
|
|
4254
|
+
companion = companion.replace(/^reviewStatus:\s*.+$/m, `reviewStatus: ${newStatus}`);
|
|
4255
|
+
}
|
|
4256
|
+
else {
|
|
4257
|
+
companion = `${companion.trimEnd()}\nreviewStatus: ${newStatus}\n`;
|
|
4258
|
+
}
|
|
4259
|
+
writeFileSync(absCompanionPath, companion, 'utf-8');
|
|
4260
|
+
}
|
|
4261
|
+
async function buildBlockStudioAiAssistSummary(projectRoot, action, candidate, validation, requestedProvider) {
|
|
4262
|
+
const fallback = buildDeterministicAiAssistSummary(action, candidate, validation);
|
|
4263
|
+
const provider = await createBlockStudioAssistProvider(projectRoot, requestedProvider);
|
|
4264
|
+
if (!provider)
|
|
4265
|
+
return { summary: fallback, provider: 'review-gated-local' };
|
|
4266
|
+
const messages = [
|
|
4267
|
+
{
|
|
4268
|
+
role: 'system',
|
|
4269
|
+
content: [
|
|
4270
|
+
'You are DQL Block Studio AI Assist.',
|
|
4271
|
+
'Return concise review notes only. Do not claim the block is certified.',
|
|
4272
|
+
'Do not rewrite source unless the user explicitly applies a later patch.',
|
|
4273
|
+
'Focus on DQL custom block structure, metadata, tests, chart hints, and validation errors.',
|
|
4274
|
+
].join('\n'),
|
|
4275
|
+
},
|
|
4276
|
+
{
|
|
4277
|
+
role: 'user',
|
|
4278
|
+
content: JSON.stringify({
|
|
4279
|
+
action,
|
|
4280
|
+
candidate: {
|
|
4281
|
+
name: candidate.name,
|
|
4282
|
+
domain: candidate.domain,
|
|
4283
|
+
description: candidate.description,
|
|
4284
|
+
owner: candidate.owner,
|
|
4285
|
+
tags: candidate.tags,
|
|
4286
|
+
sourcePath: candidate.sourcePath,
|
|
4287
|
+
sql: candidate.sql,
|
|
4288
|
+
dqlSource: candidate.dqlSource,
|
|
4289
|
+
detectedTables: candidate.lineage.sourceTables,
|
|
4290
|
+
parameters: candidate.lineage.parameters,
|
|
4291
|
+
warnings: candidate.warnings ?? candidate.lineage.warnings,
|
|
4292
|
+
},
|
|
4293
|
+
validation: {
|
|
4294
|
+
valid: validation.valid,
|
|
4295
|
+
diagnostics: validation.diagnostics,
|
|
4296
|
+
chartConfig: validation.chartConfig,
|
|
4297
|
+
semanticRefs: validation.semanticRefs,
|
|
4298
|
+
},
|
|
4299
|
+
}, null, 2),
|
|
4300
|
+
},
|
|
4301
|
+
];
|
|
4302
|
+
try {
|
|
4303
|
+
const summary = await provider.generate(messages, { maxTokens: 700, temperature: 0.1 });
|
|
4304
|
+
return {
|
|
4305
|
+
summary: summary.trim() || fallback,
|
|
4306
|
+
provider: provider.name,
|
|
4307
|
+
};
|
|
4308
|
+
}
|
|
4309
|
+
catch (error) {
|
|
4310
|
+
return {
|
|
4311
|
+
summary: `${fallback}\n\nConfigured provider failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
4312
|
+
provider: provider.name,
|
|
4313
|
+
};
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
async function createBlockStudioAssistProvider(projectRoot, requestedProvider) {
|
|
4317
|
+
const settings = listProviderSettings(projectRoot);
|
|
4318
|
+
const selected = requestedProvider
|
|
4319
|
+
? settings.find((provider) => provider.id === requestedProvider && provider.enabled && provider.hasApiKey)
|
|
4320
|
+
: settings.find((provider) => provider.enabled && provider.hasApiKey);
|
|
4321
|
+
if (!selected)
|
|
4322
|
+
return null;
|
|
4323
|
+
const config = getEffectiveProviderConfig(projectRoot, selected.id);
|
|
4324
|
+
let provider;
|
|
4325
|
+
switch (selected.id) {
|
|
4326
|
+
case 'anthropic':
|
|
4327
|
+
provider = new ClaudeProvider({ apiKey: config.apiKey, model: config.model });
|
|
4328
|
+
break;
|
|
4329
|
+
case 'openai':
|
|
4330
|
+
provider = new OpenAIProvider({ apiKey: config.apiKey, baseUrl: config.baseUrl, model: config.model });
|
|
4331
|
+
break;
|
|
4332
|
+
case 'gemini':
|
|
4333
|
+
provider = new GeminiProvider({ apiKey: config.apiKey, model: config.model });
|
|
4334
|
+
break;
|
|
4335
|
+
case 'ollama':
|
|
4336
|
+
provider = new OllamaProvider({ baseUrl: config.baseUrl, model: config.model });
|
|
4337
|
+
break;
|
|
4338
|
+
case 'custom-openai':
|
|
4339
|
+
provider = new OpenAIProvider({ apiKey: config.apiKey, baseUrl: config.baseUrl, model: config.model, allowNoApiKey: true });
|
|
4340
|
+
break;
|
|
4341
|
+
default:
|
|
4342
|
+
return null;
|
|
4343
|
+
}
|
|
4344
|
+
return await provider.available() ? provider : null;
|
|
4345
|
+
}
|
|
4346
|
+
function buildDeterministicAiAssistSummary(action, candidate, validation) {
|
|
4347
|
+
const tables = candidate.lineage.sourceTables.length > 0 ? candidate.lineage.sourceTables.join(', ') : 'no source tables detected';
|
|
4348
|
+
const params = candidate.lineage.parameters.length > 0 ? candidate.lineage.parameters.join(', ') : 'no parameters detected';
|
|
4349
|
+
const errors = validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error').map((diagnostic) => diagnostic.message);
|
|
4350
|
+
if (action === 'fix-validation') {
|
|
4351
|
+
return errors.length > 0
|
|
4352
|
+
? `Review-gated AI assist would focus on these validation errors: ${errors.join(' | ')}. No source was changed automatically.`
|
|
4353
|
+
: 'No validation errors were found. No source was changed automatically.';
|
|
4354
|
+
}
|
|
4355
|
+
if (action === 'infer-chart') {
|
|
4356
|
+
return `Candidate uses ${tables}. Keep table as the safe default, then choose a chart after previewing result columns. No chart was changed automatically.`;
|
|
4357
|
+
}
|
|
4358
|
+
if (action === 'propose-tests') {
|
|
4359
|
+
return `Default test is row_count > 0. Consider adding assertions for key measures after previewing this candidate. Parameters: ${params}.`;
|
|
4360
|
+
}
|
|
4361
|
+
return `This is a deterministic review note for ${candidate.name}. Tables: ${tables}. Parameters: ${params}. DQL wraps the SQL into a custom block and defaults visualization to table.`;
|
|
4362
|
+
}
|
|
3867
4363
|
function extractBlockStudioChartConfig(source) {
|
|
3868
4364
|
const vizMatch = source.match(/visualization\s*\{([^}]+)\}/is);
|
|
3869
4365
|
if (!vizMatch)
|
|
@@ -3983,17 +4479,25 @@ export function createBlockArtifacts(projectRoot, options) {
|
|
|
3983
4479
|
? listBlockTemplates().find((template) => template.id === options.template)?.content
|
|
3984
4480
|
: undefined;
|
|
3985
4481
|
const relativePath = safeDomain ? `blocks/${safeDomain}/${slug}.dql` : `blocks/${slug}.dql`;
|
|
3986
|
-
const fileContent = canonicalizeSafe(
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
4482
|
+
const fileContent = canonicalizeSafe(options.blockType === 'semantic' && !options.content?.trim() && !templateContent
|
|
4483
|
+
? buildBlankSemanticBlockContent({
|
|
4484
|
+
name: options.name,
|
|
4485
|
+
domain: safeDomain || 'uncategorized',
|
|
4486
|
+
owner: options.owner,
|
|
4487
|
+
description: options.description,
|
|
4488
|
+
tags: options.tags,
|
|
4489
|
+
})
|
|
4490
|
+
: normalizeBlockStudioContent({
|
|
4491
|
+
name: options.name,
|
|
4492
|
+
domain: safeDomain || 'uncategorized',
|
|
4493
|
+
owner: options.owner,
|
|
4494
|
+
description: options.description,
|
|
4495
|
+
tags: options.tags,
|
|
4496
|
+
llmContext: options.llmContext,
|
|
4497
|
+
examples: options.examples,
|
|
4498
|
+
invariants: options.invariants,
|
|
4499
|
+
content: options.content?.trim() || templateContent,
|
|
4500
|
+
}));
|
|
3997
4501
|
writeFileSync(blockPath, fileContent, 'utf-8');
|
|
3998
4502
|
const companionPath = writeBlockCompanionFile(projectRoot, {
|
|
3999
4503
|
slug,
|
|
@@ -4294,6 +4798,25 @@ function buildBlankBlockContent(options) {
|
|
|
4294
4798
|
lines.push('}');
|
|
4295
4799
|
return lines.join('\n') + '\n';
|
|
4296
4800
|
}
|
|
4801
|
+
function buildBlankSemanticBlockContent(options) {
|
|
4802
|
+
const lines = [
|
|
4803
|
+
`block "${escapeDqlString(options.name)}" {`,
|
|
4804
|
+
` domain = "${escapeDqlString(options.domain)}"`,
|
|
4805
|
+
' type = "semantic"',
|
|
4806
|
+
' status = "draft"',
|
|
4807
|
+
` description = "${escapeDqlString(options.description?.trim() || options.name)}"`,
|
|
4808
|
+
` owner = "${escapeDqlString(options.owner?.trim() ?? '')}"`,
|
|
4809
|
+
` tags = [${(options.tags ?? []).map((tag) => `"${escapeDqlString(tag)}"`).join(', ')}]`,
|
|
4810
|
+
' metric = ""',
|
|
4811
|
+
' dimensions = []',
|
|
4812
|
+
'',
|
|
4813
|
+
' visualization {',
|
|
4814
|
+
' chart = "table"',
|
|
4815
|
+
' }',
|
|
4816
|
+
'}',
|
|
4817
|
+
];
|
|
4818
|
+
return lines.join('\n') + '\n';
|
|
4819
|
+
}
|
|
4297
4820
|
function parseYamlScalar(value) {
|
|
4298
4821
|
const trimmed = value.trim();
|
|
4299
4822
|
if (!trimmed)
|
|
@@ -4422,6 +4945,101 @@ function resolveDbtManifestPath(projectRoot) {
|
|
|
4422
4945
|
const candidate = join(projectRoot, 'target', 'manifest.json');
|
|
4423
4946
|
return existsSync(candidate) ? candidate : undefined;
|
|
4424
4947
|
}
|
|
4948
|
+
export function buildDbtStatus(projectRoot, projectConfig, lastSyncTime) {
|
|
4949
|
+
const configuredDbtDir = projectConfig.dbt?.projectDir
|
|
4950
|
+
? resolve(projectRoot, projectConfig.dbt.projectDir)
|
|
4951
|
+
: undefined;
|
|
4952
|
+
const semanticDbtDir = projectConfig.semanticLayer?.provider === 'dbt' && projectConfig.semanticLayer.projectPath
|
|
4953
|
+
? resolve(projectRoot, projectConfig.semanticLayer.projectPath)
|
|
4954
|
+
: undefined;
|
|
4955
|
+
const candidateDirs = [
|
|
4956
|
+
configuredDbtDir,
|
|
4957
|
+
semanticDbtDir,
|
|
4958
|
+
projectRoot,
|
|
4959
|
+
resolve(projectRoot, '..'),
|
|
4960
|
+
resolve(projectRoot, '../dbt'),
|
|
4961
|
+
resolve(projectRoot, '../../dbt'),
|
|
4962
|
+
].filter((value) => Boolean(value));
|
|
4963
|
+
const dbtProjectPath = candidateDirs.find((dir, index, list) => list.indexOf(dir) === index && existsSync(join(dir, 'dbt_project.yml'))) ?? configuredDbtDir ?? semanticDbtDir ?? projectRoot;
|
|
4964
|
+
const configuredManifest = projectConfig.dbt?.manifestPath ?? 'target/manifest.json';
|
|
4965
|
+
const manifestPath = resolve(dbtProjectPath, configuredManifest);
|
|
4966
|
+
const catalogPath = resolve(dbtProjectPath, 'target/catalog.json');
|
|
4967
|
+
const semanticManifestPath = resolve(dbtProjectPath, 'target/semantic_manifest.json');
|
|
4968
|
+
const runResultsPath = resolve(dbtProjectPath, 'target/run_results.json');
|
|
4969
|
+
const manifest = readJsonFile(manifestPath);
|
|
4970
|
+
const semanticManifest = readJsonFile(semanticManifestPath);
|
|
4971
|
+
const projectName = typeof manifest?.metadata?.project_name === 'string'
|
|
4972
|
+
? manifest.metadata.project_name
|
|
4973
|
+
: null;
|
|
4974
|
+
const nodes = manifest && typeof manifest === 'object' && manifest.nodes && typeof manifest.nodes === 'object'
|
|
4975
|
+
? Object.values(manifest.nodes)
|
|
4976
|
+
: [];
|
|
4977
|
+
const modelCount = nodes.filter((node) => node?.resource_type === 'model').length;
|
|
4978
|
+
const sourceCount = manifest?.sources && typeof manifest.sources === 'object'
|
|
4979
|
+
? Object.keys(manifest.sources).length
|
|
4980
|
+
: 0;
|
|
4981
|
+
const manifestMetricCount = manifest?.metrics && typeof manifest.metrics === 'object'
|
|
4982
|
+
? Object.keys(manifest.metrics).length
|
|
4983
|
+
: 0;
|
|
4984
|
+
const semanticMetricCount = Array.isArray(semanticManifest?.metrics)
|
|
4985
|
+
? semanticManifest.metrics.length
|
|
4986
|
+
: manifestMetricCount;
|
|
4987
|
+
const semanticModelCount = Array.isArray(semanticManifest?.semantic_models)
|
|
4988
|
+
? semanticManifest.semantic_models.length
|
|
4989
|
+
: 0;
|
|
4990
|
+
const savedQueryCount = Array.isArray(semanticManifest?.saved_queries)
|
|
4991
|
+
? semanticManifest.saved_queries.length
|
|
4992
|
+
: 0;
|
|
4993
|
+
const configured = existsSync(join(dbtProjectPath, 'dbt_project.yml')) || Boolean(configuredDbtDir || semanticDbtDir);
|
|
4994
|
+
const manifestExists = existsSync(manifestPath);
|
|
4995
|
+
const semanticExists = existsSync(semanticManifestPath);
|
|
4996
|
+
const setupHint = !configured
|
|
4997
|
+
? 'No dbt project detected. Start without dbt or run DQL from a repo with dbt_project.yml.'
|
|
4998
|
+
: !manifestExists
|
|
4999
|
+
? 'Run `dbt parse`, `dbt compile`, or `dbt build`, then run `dql sync dbt`.'
|
|
5000
|
+
: !semanticExists
|
|
5001
|
+
? 'dbt manifest is ready. Run `dbt parse` or `dbt build` if you use dbt Semantic Layer metrics.'
|
|
5002
|
+
: 'dbt artifacts are ready. Build SQL blocks from models or semantic blocks from metrics.';
|
|
5003
|
+
return {
|
|
5004
|
+
configured,
|
|
5005
|
+
provider: projectConfig.semanticLayer?.provider ?? null,
|
|
5006
|
+
projectPath: dbtProjectPath,
|
|
5007
|
+
projectName,
|
|
5008
|
+
artifacts: {
|
|
5009
|
+
manifest: describeArtifact(manifestPath, modelCount + sourceCount, manifest?.metadata?.generated_at),
|
|
5010
|
+
catalog: describeArtifact(catalogPath),
|
|
5011
|
+
semanticManifest: describeArtifact(semanticManifestPath, semanticMetricCount + semanticModelCount + savedQueryCount, semanticManifest?.metadata?.generated_at),
|
|
5012
|
+
runResults: describeArtifact(runResultsPath),
|
|
5013
|
+
},
|
|
5014
|
+
counts: {
|
|
5015
|
+
models: modelCount,
|
|
5016
|
+
sources: sourceCount,
|
|
5017
|
+
metrics: semanticMetricCount,
|
|
5018
|
+
semanticModels: semanticModelCount,
|
|
5019
|
+
savedQueries: savedQueryCount,
|
|
5020
|
+
},
|
|
5021
|
+
lastSyncTime,
|
|
5022
|
+
setupHint,
|
|
5023
|
+
};
|
|
5024
|
+
}
|
|
5025
|
+
function describeArtifact(path, count, generatedAt) {
|
|
5026
|
+
return {
|
|
5027
|
+
path,
|
|
5028
|
+
exists: existsSync(path),
|
|
5029
|
+
count,
|
|
5030
|
+
generatedAt: generatedAt ?? null,
|
|
5031
|
+
};
|
|
5032
|
+
}
|
|
5033
|
+
function readJsonFile(path) {
|
|
5034
|
+
if (!existsSync(path))
|
|
5035
|
+
return null;
|
|
5036
|
+
try {
|
|
5037
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
5038
|
+
}
|
|
5039
|
+
catch {
|
|
5040
|
+
return null;
|
|
5041
|
+
}
|
|
5042
|
+
}
|
|
4425
5043
|
function resolveLineageNode(graph, rawNodeId) {
|
|
4426
5044
|
if (graph.getNode(rawNodeId))
|
|
4427
5045
|
return graph.getNode(rawNodeId);
|