@duckcodeailabs/dql-cli 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/apps-api.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * dispatcher — returns `true` if the request was handled, `false` otherwise.
5
5
  */
6
6
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
7
- import { join, dirname, relative } from 'node:path';
7
+ import { join, dirname, relative, basename } from 'node:path';
8
8
  import { loadAppDocument, findAppDocuments, loadDashboardDocument, findDashboardsForApp, parseAppDocument, parseDashboardDocument, suggestAppId, } from '@duckcodeailabs/dql-core';
9
9
  import { defaultPersonaRegistry, defaultLocalAppsDbPath, LocalAppStorage, personaFromMember, } from '@duckcodeailabs/dql-project';
10
10
  export async function handleAppsApi(ctx) {
@@ -83,6 +83,163 @@ export async function handleAppsApi(ctx) {
83
83
  }
84
84
  return true;
85
85
  }
86
+ m = path.match(/^\/api\/apps\/([^/]+)\/notebook-candidates$/);
87
+ if (m && req.method === 'GET') {
88
+ const appId = decodeURIComponent(m[1]);
89
+ const loaded = loadAppById(projectRoot, appId);
90
+ if (!loaded) {
91
+ sendJson(res, 404, { error: `App "${appId}" not found` });
92
+ return true;
93
+ }
94
+ sendJson(res, 200, { notebooks: listNotebookCandidates(projectRoot, loaded.app, join(projectRoot, 'apps', appId)) });
95
+ return true;
96
+ }
97
+ m = path.match(/^\/api\/apps\/([^/]+)\/notebooks\/create$/);
98
+ if (m && req.method === 'POST') {
99
+ const appId = decodeURIComponent(m[1]);
100
+ try {
101
+ const body = await readJson(req);
102
+ const result = createNotebookForApp(projectRoot, appId, body);
103
+ if (!result.ok) {
104
+ sendJson(res, 400, { error: result.error });
105
+ return true;
106
+ }
107
+ sendJson(res, 201, result);
108
+ }
109
+ catch (err) {
110
+ sendJson(res, 500, { error: err.message });
111
+ }
112
+ return true;
113
+ }
114
+ m = path.match(/^\/api\/apps\/([^/]+)\/notebooks\/preview$/);
115
+ if (m && req.method === 'GET') {
116
+ const appId = decodeURIComponent(m[1]);
117
+ const notebookPath = ctx.url.searchParams.get('path') ?? '';
118
+ const result = previewNotebookForApp(projectRoot, appId, notebookPath);
119
+ if (!result.ok) {
120
+ sendJson(res, result.status, { error: result.error });
121
+ return true;
122
+ }
123
+ sendJson(res, 200, result.preview);
124
+ return true;
125
+ }
126
+ m = path.match(/^\/api\/apps\/([^/]+)\/notebooks\/run$/);
127
+ if (m && req.method === 'POST') {
128
+ const appId = decodeURIComponent(m[1]);
129
+ try {
130
+ const body = await readJson(req);
131
+ const notebookPath = cleanString(body.path);
132
+ if (!notebookPath) {
133
+ sendJson(res, 400, { error: 'path is required' });
134
+ return true;
135
+ }
136
+ if (!ctx.runNotebook) {
137
+ sendJson(res, 400, { error: 'Notebook run is unavailable in this host.' });
138
+ return true;
139
+ }
140
+ await ctx.runNotebook(appId, notebookPath);
141
+ const preview = previewNotebookForApp(projectRoot, appId, notebookPath);
142
+ if (!preview.ok) {
143
+ sendJson(res, preview.status, { error: preview.error });
144
+ return true;
145
+ }
146
+ sendJson(res, 200, { ok: true, preview: preview.preview });
147
+ }
148
+ catch (err) {
149
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
150
+ }
151
+ return true;
152
+ }
153
+ m = path.match(/^\/api\/apps\/([^/]+)\/notebooks$/);
154
+ if (m && req.method === 'POST') {
155
+ const appId = decodeURIComponent(m[1]);
156
+ try {
157
+ const body = await readJson(req);
158
+ const result = attachNotebookToApp(projectRoot, appId, body);
159
+ if (!result.ok) {
160
+ sendJson(res, 400, { error: result.error });
161
+ return true;
162
+ }
163
+ sendJson(res, 200, loadAppById(projectRoot, appId) ?? result);
164
+ }
165
+ catch (err) {
166
+ sendJson(res, 500, { error: err.message });
167
+ }
168
+ return true;
169
+ }
170
+ m = path.match(/^\/api\/apps\/([^/]+)\/conversations$/);
171
+ if (m) {
172
+ const appId = decodeURIComponent(m[1]);
173
+ if (!loadAppById(projectRoot, appId)) {
174
+ sendJson(res, 404, { error: `App "${appId}" not found` });
175
+ return true;
176
+ }
177
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
178
+ try {
179
+ if (req.method === 'GET') {
180
+ sendJson(res, 200, { conversations: storage.listAppConversations(appId) });
181
+ return true;
182
+ }
183
+ if (req.method === 'POST') {
184
+ const body = await readJson(req);
185
+ const conversation = storage.createAppConversation({
186
+ appId,
187
+ title: body.title,
188
+ dashboardId: body.dashboardId,
189
+ notebookPath: body.notebookPath,
190
+ messages: normalizeConversationMessages(body.messages),
191
+ });
192
+ sendJson(res, 201, { ok: true, conversation });
193
+ return true;
194
+ }
195
+ }
196
+ catch (err) {
197
+ sendJson(res, 500, { error: err.message });
198
+ return true;
199
+ }
200
+ finally {
201
+ storage.close();
202
+ }
203
+ }
204
+ m = path.match(/^\/api\/apps\/([^/]+)\/conversations\/([^/]+)$/);
205
+ if (m) {
206
+ const appId = decodeURIComponent(m[1]);
207
+ const conversationId = decodeURIComponent(m[2]);
208
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
209
+ try {
210
+ const conversation = storage.getAppConversation(conversationId);
211
+ if (!conversation || conversation.appId !== appId) {
212
+ sendJson(res, 404, { error: `Conversation "${conversationId}" not found` });
213
+ return true;
214
+ }
215
+ if (req.method === 'GET') {
216
+ sendJson(res, 200, { conversation });
217
+ return true;
218
+ }
219
+ if (req.method === 'PATCH') {
220
+ const body = await readJson(req);
221
+ const updated = storage.updateAppConversation(conversationId, {
222
+ title: body.title,
223
+ dashboardId: body.dashboardId,
224
+ notebookPath: body.notebookPath,
225
+ messages: body.messages ? normalizeConversationMessages(body.messages) : undefined,
226
+ });
227
+ sendJson(res, 200, { ok: true, conversation: updated });
228
+ return true;
229
+ }
230
+ if (req.method === 'DELETE') {
231
+ sendJson(res, 200, { ok: storage.deleteAppConversation(conversationId) });
232
+ return true;
233
+ }
234
+ }
235
+ catch (err) {
236
+ sendJson(res, 500, { error: err.message });
237
+ return true;
238
+ }
239
+ finally {
240
+ storage.close();
241
+ }
242
+ }
86
243
  m = path.match(/^\/api\/apps\/([^/]+)\/ai-pins$/);
87
244
  if (m) {
88
245
  const appId = decodeURIComponent(m[1]);
@@ -270,7 +427,6 @@ export async function handleAppsApi(ctx) {
270
427
  }
271
428
  return false;
272
429
  }
273
- // ---- Helpers ----
274
430
  function collectAppsList(projectRoot) {
275
431
  const out = [];
276
432
  for (const p of findAppDocuments(projectRoot)) {
@@ -288,10 +444,15 @@ function collectAppsList(projectRoot) {
288
444
  id: document.id,
289
445
  name: document.name,
290
446
  domain: document.domain,
447
+ subdomain: document.subdomain,
448
+ groups: document.groups ?? [],
291
449
  description: document.description,
292
- audience: audienceFromTags(document.tags ?? []),
293
- status: dashboards.length > 0 ? 'ready' : 'empty',
294
- storage: 'shared',
450
+ audience: document.audience ?? audienceFromTags(document.tags ?? []),
451
+ lifecycle: document.lifecycle ?? 'draft',
452
+ certification: document.lifecycle === 'certified' ? 'certified' : 'uncertified',
453
+ status: document.lifecycle === 'review' ? 'review' : dashboards.length > 0 ? 'ready' : 'empty',
454
+ storage: document.visibility === 'private' ? 'mine' : document.visibility === 'template' ? 'template' : 'shared',
455
+ visibility: document.visibility ?? 'shared',
295
456
  owners: document.owners,
296
457
  tags: document.tags ?? [],
297
458
  members: document.members.length,
@@ -299,6 +460,9 @@ function collectAppsList(projectRoot) {
299
460
  policies: document.policies.length,
300
461
  schedules: (document.schedules ?? []).length,
301
462
  dashboards,
463
+ notebooks: listAppNotebookRefs(projectRoot, document, appDir),
464
+ drafts: listAppDrafts(projectRoot, appDir),
465
+ aiPins: countAiPins(projectRoot, document.id),
302
466
  homepage: document.homepage,
303
467
  });
304
468
  }
@@ -364,8 +528,16 @@ export function createAppPackage(projectRoot, input) {
364
528
  const appDir = join(projectRoot, 'apps', id);
365
529
  if (existsSync(appDir))
366
530
  return { ok: false, error: `App already exists: ${id}` };
531
+ const dashboardTitle = cleanString(input.dashboardTitle) || 'Overview';
532
+ const dashboardId = slugify(dashboardTitle) || 'overview';
367
533
  const owner = cleanString(input.owners?.[0]) || `${process.env.USER ?? 'owner'}@local`;
368
534
  const audience = cleanString(input.audience);
535
+ const subdomain = cleanString(input.subdomain);
536
+ const groups = normalizeTags(input.groups ?? []);
537
+ const visibility = input.visibility === 'private' || input.visibility === 'template' ? input.visibility : 'shared';
538
+ const lifecycle = input.lifecycle === 'certified' || input.lifecycle === 'review' || input.lifecycle === 'deprecated'
539
+ ? input.lifecycle
540
+ : 'draft';
369
541
  const tags = normalizeTags([...(input.tags ?? []), audience ? `audience:${slugify(audience)}` : '']);
370
542
  const selectedIds = Array.from(new Set((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean)));
371
543
  const blocks = collectBlockCandidates(projectRoot);
@@ -377,7 +549,12 @@ export function createAppPackage(projectRoot, input) {
377
549
  id,
378
550
  name,
379
551
  description: cleanString(input.purpose) || `${name} consumption surface for ${domain}`,
552
+ visibility,
380
553
  domain,
554
+ subdomain: subdomain || undefined,
555
+ groups,
556
+ audience: audience || undefined,
557
+ lifecycle,
381
558
  owners: [owner],
382
559
  tags,
383
560
  members: [
@@ -416,15 +593,20 @@ export function createAppPackage(projectRoot, input) {
416
593
  ],
417
594
  rlsBindings: [],
418
595
  schedules: [],
419
- homepage: { type: 'dashboard', id: 'overview' },
596
+ homepage: { type: 'dashboard', id: dashboardId },
420
597
  };
421
598
  const dashboard = {
422
599
  version: 1,
423
- id: 'overview',
600
+ id: dashboardId,
424
601
  metadata: {
425
- title: `${name} Overview`,
602
+ title: dashboardTitle,
426
603
  description: cleanString(input.purpose) || `Starter dashboard for ${name}`,
427
604
  domain,
605
+ subdomain: subdomain || undefined,
606
+ groups,
607
+ audience: audience || undefined,
608
+ visibility,
609
+ lifecycle,
428
610
  tags,
429
611
  },
430
612
  layout: {
@@ -437,7 +619,7 @@ export function createAppPackage(projectRoot, input) {
437
619
  const paths = [
438
620
  join(appDir, 'dql.app.json'),
439
621
  join(appDir, 'README.md'),
440
- join(appDir, 'dashboards', 'overview.dqld'),
622
+ join(appDir, 'dashboards', `${dashboardId}.dqld`),
441
623
  join(appDir, 'notebooks'),
442
624
  join(appDir, 'drafts'),
443
625
  ];
@@ -445,8 +627,8 @@ export function createAppPackage(projectRoot, input) {
445
627
  mkdirSync(join(appDir, 'notebooks'), { recursive: true });
446
628
  mkdirSync(join(appDir, 'drafts'), { recursive: true });
447
629
  writeFileSync(join(appDir, 'dql.app.json'), JSON.stringify(app, null, 2) + '\n', 'utf-8');
448
- writeFileSync(join(appDir, 'dashboards', 'overview.dqld'), JSON.stringify(dashboard, null, 2) + '\n', 'utf-8');
449
- writeFileSync(join(appDir, 'README.md'), appReadme(app, audience, selectedBlocks), 'utf-8');
630
+ writeFileSync(join(appDir, 'dashboards', `${dashboardId}.dqld`), JSON.stringify(dashboard, null, 2) + '\n', 'utf-8');
631
+ writeFileSync(join(appDir, 'README.md'), appReadme(app, audience, selectedBlocks, dashboardId), 'utf-8');
450
632
  const created = collectAppsList(projectRoot).find((entry) => entry.id === id);
451
633
  if (!created)
452
634
  return { ok: false, error: `App was written but could not be reloaded: ${id}` };
@@ -454,7 +636,7 @@ export function createAppPackage(projectRoot, input) {
454
636
  ok: true,
455
637
  app: created,
456
638
  paths: paths.map((path) => path.startsWith(projectRoot) ? path.slice(projectRoot.length + 1) : path),
457
- dashboardId: 'overview',
639
+ dashboardId,
458
640
  };
459
641
  }
460
642
  function buildDashboardItems(blocks) {
@@ -516,10 +698,26 @@ function normalizeVizType(chartType) {
516
698
  return 'line';
517
699
  if (normalized === 'bar')
518
700
  return 'bar';
701
+ if (normalized === 'grouped_bar')
702
+ return 'grouped_bar';
703
+ if (normalized === 'stacked_bar')
704
+ return 'stacked_bar';
519
705
  if (normalized === 'area')
520
706
  return 'area';
521
707
  if (normalized === 'pie')
522
708
  return 'pie';
709
+ if (normalized === 'donut')
710
+ return 'donut';
711
+ if (normalized === 'scatter')
712
+ return 'scatter';
713
+ if (normalized === 'heatmap')
714
+ return 'heatmap';
715
+ if (normalized === 'histogram')
716
+ return 'histogram';
717
+ if (normalized === 'waterfall')
718
+ return 'waterfall';
719
+ if (normalized === 'gauge')
720
+ return 'gauge';
523
721
  if (normalized === 'pivot')
524
722
  return 'pivot';
525
723
  if (normalized === 'map')
@@ -528,16 +726,22 @@ function normalizeVizType(chartType) {
528
726
  return 'funnel';
529
727
  return 'table';
530
728
  }
531
- function appReadme(app, audience, blocks) {
729
+ function appReadme(app, audience, blocks, dashboardId = 'overview') {
532
730
  return [
533
731
  `# ${app.name}`,
534
732
  '',
535
733
  app.description ?? '',
536
734
  '',
537
735
  `- Domain: ${app.domain}`,
736
+ ...(app.subdomain ? [`- Subdomain: ${app.subdomain}`] : []),
737
+ ...(app.groups?.length ? [`- Groups: ${app.groups.join(', ')}`] : []),
538
738
  `- Audience: ${audience || 'not specified'}`,
739
+ `- Visibility: ${app.visibility}`,
740
+ `- Lifecycle: ${app.lifecycle}`,
539
741
  `- Owners: ${app.owners.join(', ')}`,
540
- `- Starter dashboard: dashboards/overview.dqld`,
742
+ `- Starter dashboard: dashboards/${dashboardId}.dqld`,
743
+ `- Supporting notebooks: notebooks/`,
744
+ `- Draft blocks: drafts/`,
541
745
  '',
542
746
  '## Selected Certified Blocks',
543
747
  '',
@@ -622,6 +826,17 @@ function matchArray(source, regex) {
622
826
  .map((item) => item.trim().replace(/^"|"$/g, ''))
623
827
  .filter(Boolean);
624
828
  }
829
+ function normalizeConversationMessages(messages) {
830
+ return (messages ?? [])
831
+ .map((message) => ({
832
+ id: cleanString(message.id) || undefined,
833
+ role: message.role === 'assistant' ? 'assistant' : 'user',
834
+ content: cleanString(message.content),
835
+ events: Array.isArray(message.events) ? message.events : [],
836
+ createdAt: cleanString(message.createdAt) || undefined,
837
+ }))
838
+ .filter((message) => message.content.length > 0);
839
+ }
625
840
  function cleanString(value) {
626
841
  return typeof value === 'string' ? value.trim() : '';
627
842
  }
@@ -637,6 +852,14 @@ function slugify(value) {
637
852
  .replace(/[^a-z0-9]+/g, '-')
638
853
  .replace(/^-+|-+$/g, '');
639
854
  }
855
+ function titleFromPath(path) {
856
+ return basename(path)
857
+ .replace(/\.(dqlnb|dql)$/i, '')
858
+ .replace(/[_-]+/g, ' ')
859
+ .replace(/\s+/g, ' ')
860
+ .trim()
861
+ .replace(/\b\w/g, (char) => char.toUpperCase()) || path;
862
+ }
640
863
  function audienceFromTags(tags) {
641
864
  const tag = tags.find((value) => value.startsWith('audience:'));
642
865
  if (!tag)
@@ -656,8 +879,8 @@ function createDashboardForApp(projectRoot, appId, input) {
656
879
  const loaded = loadAppById(projectRoot, appId);
657
880
  if (!loaded)
658
881
  return { ok: false, error: `App "${appId}" not found` };
659
- const title = cleanString(input.title) || 'New tab';
660
- const id = slugify(cleanString(input.id) || title) || `tab-${Date.now()}`;
882
+ const title = cleanString(input.title) || 'New page';
883
+ const id = slugify(cleanString(input.id) || title) || `page-${Date.now()}`;
661
884
  if (!/^[a-z0-9][a-z0-9_-]*$/i.test(id))
662
885
  return { ok: false, error: 'dashboard id must be folder-safe' };
663
886
  const appDir = join(projectRoot, 'apps', appId);
@@ -669,8 +892,13 @@ function createDashboardForApp(projectRoot, appId, input) {
669
892
  id,
670
893
  metadata: {
671
894
  title,
672
- description: cleanString(input.description) || `${title} dashboard tab`,
895
+ description: cleanString(input.description) || `${title} dashboard page`,
673
896
  domain: loaded.app.domain,
897
+ subdomain: loaded.app.subdomain,
898
+ groups: loaded.app.groups ?? [],
899
+ audience: loaded.app.audience,
900
+ visibility: loaded.app.visibility ?? 'shared',
901
+ lifecycle: loaded.app.lifecycle ?? 'draft',
674
902
  tags: loaded.app.tags ?? [],
675
903
  },
676
904
  layout: {
@@ -812,7 +1040,9 @@ function nextTileId(dashboard, base) {
812
1040
  function normalizeVizTypeFromChart(chartConfig) {
813
1041
  const chart = String(chartConfig?.chart ?? '').toLowerCase().replace(/-/g, '_');
814
1042
  if (chart === 'single_value' || chart === 'kpi' || chart === 'line' || chart === 'bar' || chart === 'area'
815
- || chart === 'pie' || chart === 'pivot' || chart === 'map' || chart === 'funnel') {
1043
+ || chart === 'grouped_bar' || chart === 'stacked_bar' || chart === 'pie' || chart === 'donut'
1044
+ || chart === 'scatter' || chart === 'heatmap' || chart === 'histogram' || chart === 'waterfall'
1045
+ || chart === 'gauge' || chart === 'pivot' || chart === 'map' || chart === 'funnel') {
816
1046
  return chart;
817
1047
  }
818
1048
  return 'table';
@@ -838,10 +1068,301 @@ function loadAppById(projectRoot, id) {
838
1068
  });
839
1069
  }
840
1070
  }
841
- return { app: document, dashboards };
1071
+ return {
1072
+ app: document,
1073
+ dashboards,
1074
+ notebooks: listAppNotebookRefs(projectRoot, document, appDir),
1075
+ drafts: listAppDrafts(projectRoot, appDir),
1076
+ aiPins: listAiPins(projectRoot, document.id),
1077
+ };
1078
+ }
1079
+ return null;
1080
+ }
1081
+ export function listNotebookCandidates(projectRoot, app, appDir) {
1082
+ const attached = new Map(listAppNotebookRefs(projectRoot, app, appDir).map((notebook) => [notebook.path, notebook]));
1083
+ const files = new Map();
1084
+ for (const root of ['notebooks', 'workbooks', 'apps']) {
1085
+ for (const file of scanFiles(join(projectRoot, root), '.dqlnb')) {
1086
+ const rel = relative(projectRoot, file).replaceAll('\\', '/');
1087
+ files.set(rel, file);
1088
+ }
1089
+ }
1090
+ for (const notebook of attached.values()) {
1091
+ const abs = join(projectRoot, notebook.path);
1092
+ if (existsSync(abs))
1093
+ files.set(notebook.path, abs);
1094
+ }
1095
+ return Array.from(files.entries())
1096
+ .map(([path, abs]) => {
1097
+ const ref = attached.get(path);
1098
+ const stat = statSyncSafe(abs);
1099
+ return {
1100
+ path,
1101
+ title: ref?.title ?? notebookTitleFromFile(abs) ?? titleFromPath(path),
1102
+ attached: Boolean(ref),
1103
+ role: ref?.role,
1104
+ visibility: ref?.visibility,
1105
+ lastModified: stat?.mtime.toISOString(),
1106
+ };
1107
+ })
1108
+ .sort((a, b) => Number(b.attached) - Number(a.attached) || a.title.localeCompare(b.title));
1109
+ }
1110
+ export function createNotebookForApp(projectRoot, appId, input) {
1111
+ const loaded = loadAppById(projectRoot, appId);
1112
+ if (!loaded)
1113
+ return { ok: false, error: `App "${appId}" not found` };
1114
+ const title = cleanString(input.title) || cleanString(input.name) || 'App analysis';
1115
+ const slug = slugify(cleanString(input.name) || title) || `notebook-${Date.now()}`;
1116
+ const appDir = join(projectRoot, 'apps', appId);
1117
+ const relPath = `apps/${appId}/notebooks/${slug}.dqlnb`;
1118
+ const absPath = join(projectRoot, relPath);
1119
+ if (existsSync(absPath))
1120
+ return { ok: false, error: `Notebook already exists: ${relPath}` };
1121
+ mkdirSync(dirname(absPath), { recursive: true });
1122
+ writeFileSync(absPath, buildAppNotebookTemplate(title, loaded.app, input.template), 'utf-8');
1123
+ const attached = attachNotebookToApp(projectRoot, appId, {
1124
+ path: relPath,
1125
+ title,
1126
+ role: normalizeNotebookRole(input.role),
1127
+ visibility: input.visibility,
1128
+ });
1129
+ if (!attached.ok)
1130
+ return { ok: false, error: attached.error };
1131
+ const preview = previewNotebookForApp(projectRoot, appId, relPath);
1132
+ return {
1133
+ ok: true,
1134
+ path: relPath,
1135
+ app: loadAppById(projectRoot, appId),
1136
+ preview: preview.ok ? preview.preview : null,
1137
+ };
1138
+ }
1139
+ export function previewNotebookForApp(projectRoot, appId, notebookPath) {
1140
+ if (!loadAppById(projectRoot, appId))
1141
+ return { ok: false, status: 404, error: `App "${appId}" not found` };
1142
+ const rel = cleanString(notebookPath).replaceAll('\\', '/');
1143
+ if (!rel || rel.startsWith('/') || rel.includes('..') || !rel.endsWith('.dqlnb')) {
1144
+ return { ok: false, status: 400, error: 'notebook path must be a project-relative .dqlnb path' };
1145
+ }
1146
+ const abs = join(projectRoot, rel);
1147
+ if (!existsSync(abs))
1148
+ return { ok: false, status: 404, error: `Notebook not found: ${rel}` };
1149
+ try {
1150
+ const raw = readFileSync(abs, 'utf-8');
1151
+ const parsed = JSON.parse(raw);
1152
+ const snapshot = readNotebookRunSnapshot(abs);
1153
+ const snapshotByCell = new Map();
1154
+ for (const entry of snapshot?.cells ?? []) {
1155
+ if (entry && typeof entry === 'object' && typeof entry.cellId === 'string') {
1156
+ snapshotByCell.set(String(entry.cellId), entry);
1157
+ }
1158
+ }
1159
+ const cells = (parsed.cells ?? []).map((cell, index) => {
1160
+ const id = typeof cell.id === 'string' ? cell.id : `cell-${index + 1}`;
1161
+ const snap = snapshotByCell.get(id);
1162
+ return {
1163
+ id,
1164
+ type: typeof cell.type === 'string' ? cell.type : 'sql',
1165
+ name: typeof cell.name === 'string' ? cell.name : typeof cell.title === 'string' ? cell.title : undefined,
1166
+ content: typeof cell.content === 'string' ? cell.content : typeof cell.source === 'string' ? cell.source : '',
1167
+ upstream: typeof cell.upstream === 'string' ? cell.upstream : undefined,
1168
+ chartConfig: cell.chartConfig ?? cell.config,
1169
+ tableConfig: cell.tableConfig,
1170
+ singleValueConfig: cell.singleValueConfig,
1171
+ pivotConfig: cell.pivotConfig,
1172
+ status: snap?.status ?? 'idle',
1173
+ result: snap?.result,
1174
+ error: snap?.error,
1175
+ executionCount: snap?.executionCount,
1176
+ executedAt: snap?.executedAt,
1177
+ };
1178
+ });
1179
+ return {
1180
+ ok: true,
1181
+ preview: {
1182
+ path: rel,
1183
+ title: parsed.title ?? parsed.metadata?.title ?? titleFromPath(rel),
1184
+ metadata: parsed.metadata ?? {},
1185
+ cells,
1186
+ snapshotFound: Boolean(snapshot),
1187
+ capturedAt: typeof snapshot?.capturedAt === 'string' ? snapshot.capturedAt : undefined,
1188
+ },
1189
+ };
1190
+ }
1191
+ catch (err) {
1192
+ return { ok: false, status: 400, error: err instanceof Error ? err.message : String(err) };
1193
+ }
1194
+ }
1195
+ function attachNotebookToApp(projectRoot, appId, input) {
1196
+ const notebookPath = cleanString(input.path).replaceAll('\\', '/');
1197
+ if (!notebookPath)
1198
+ return { ok: false, error: 'path is required' };
1199
+ if (notebookPath.startsWith('/') || notebookPath.includes('..')) {
1200
+ return { ok: false, error: 'notebook path must be project-relative' };
1201
+ }
1202
+ if (!existsSync(join(projectRoot, notebookPath))) {
1203
+ return { ok: false, error: `Notebook not found: ${notebookPath}` };
1204
+ }
1205
+ for (const appJsonPath of findAppDocuments(projectRoot)) {
1206
+ const { document } = loadAppDocument(appJsonPath);
1207
+ if (!document || document.id !== appId)
1208
+ continue;
1209
+ const role = input.role === 'source' || input.role === 'analysis' ? input.role : 'supporting';
1210
+ const visibility = input.visibility === 'private' || input.visibility === 'template' ? input.visibility : 'shared';
1211
+ const next = {
1212
+ ...document,
1213
+ notebooks: [
1214
+ ...(document.notebooks ?? []).filter((notebook) => notebook.path !== notebookPath),
1215
+ {
1216
+ path: notebookPath,
1217
+ title: cleanString(input.title) || titleFromPath(notebookPath),
1218
+ role,
1219
+ visibility,
1220
+ },
1221
+ ],
1222
+ };
1223
+ const { document: validated, errors } = parseAppDocument(JSON.stringify(next), appJsonPath);
1224
+ if (!validated)
1225
+ return { ok: false, error: errors.map((e) => e.message).join('; ') };
1226
+ writeFileSync(appJsonPath, JSON.stringify(validated, null, 2) + '\n', 'utf-8');
1227
+ return { ok: true, path: relative(projectRoot, appJsonPath) };
1228
+ }
1229
+ return { ok: false, error: `App "${appId}" not found` };
1230
+ }
1231
+ function listAppNotebookRefs(projectRoot, app, appDir) {
1232
+ const byPath = new Map();
1233
+ for (const notebook of app.notebooks ?? []) {
1234
+ byPath.set(notebook.path, {
1235
+ path: notebook.path,
1236
+ title: notebook.title,
1237
+ role: notebook.role,
1238
+ visibility: notebook.visibility ?? 'shared',
1239
+ });
1240
+ }
1241
+ const notebooksDir = join(appDir, 'notebooks');
1242
+ for (const file of scanFiles(notebooksDir, '.dqlnb')) {
1243
+ const rel = relative(projectRoot, file).replaceAll('\\', '/');
1244
+ if (byPath.has(rel))
1245
+ continue;
1246
+ byPath.set(rel, {
1247
+ path: rel,
1248
+ title: titleFromPath(rel),
1249
+ role: 'supporting',
1250
+ visibility: app.visibility ?? 'shared',
1251
+ });
1252
+ }
1253
+ return Array.from(byPath.values()).sort((a, b) => (a.title ?? a.path).localeCompare(b.title ?? b.path));
1254
+ }
1255
+ function buildAppNotebookTemplate(title, app, template) {
1256
+ const normalizedTemplate = cleanString(template) || 'blank';
1257
+ const cellId = (base) => `${slugify(base) || 'cell'}_${Math.random().toString(36).slice(2, 8)}`;
1258
+ const intro = [
1259
+ `# ${title}`,
1260
+ '',
1261
+ `App: ${app.name}`,
1262
+ `Domain: ${[app.domain, app.subdomain, ...(app.groups ?? [])].filter(Boolean).join(' / ')}`,
1263
+ '',
1264
+ 'Use this notebook for analysis that supports the App dashboard pages.',
1265
+ ].join('\n');
1266
+ const cells = [
1267
+ {
1268
+ id: cellId('intro'),
1269
+ type: 'markdown',
1270
+ content: intro,
1271
+ },
1272
+ {
1273
+ id: cellId('starter-sql'),
1274
+ type: 'sql',
1275
+ name: 'starter_query',
1276
+ content: '-- Write supporting SQL for this App here\nSELECT 1 AS value;',
1277
+ },
1278
+ ];
1279
+ if (normalizedTemplate === 'summary') {
1280
+ cells.push({
1281
+ id: cellId('summary'),
1282
+ type: 'markdown',
1283
+ content: '## Notes\n\nAdd observations, assumptions, and follow-up questions here.',
1284
+ });
1285
+ }
1286
+ return JSON.stringify({
1287
+ dqlnbVersion: 1,
1288
+ version: 1,
1289
+ title,
1290
+ metadata: {
1291
+ description: `Supporting notebook for ${app.name}`,
1292
+ status: 'draft',
1293
+ categories: [app.domain, app.subdomain, ...(app.groups ?? [])].filter(Boolean),
1294
+ createdAt: new Date().toISOString(),
1295
+ modifiedAt: new Date().toISOString(),
1296
+ },
1297
+ cells,
1298
+ }, null, 2) + '\n';
1299
+ }
1300
+ function notebookTitleFromFile(absPath) {
1301
+ try {
1302
+ const parsed = JSON.parse(readFileSync(absPath, 'utf-8'));
1303
+ if (typeof parsed.title === 'string' && parsed.title.trim())
1304
+ return parsed.title.trim();
1305
+ if (typeof parsed.metadata?.title === 'string' && parsed.metadata.title.trim())
1306
+ return parsed.metadata.title.trim();
1307
+ }
1308
+ catch {
1309
+ // fall back to path-derived title
842
1310
  }
843
1311
  return null;
844
1312
  }
1313
+ function readNotebookRunSnapshot(absNotebookPath) {
1314
+ const snapshotPath = absNotebookPath.replace(/\.dqlnb$/i, '.run.json');
1315
+ if (!existsSync(snapshotPath))
1316
+ return null;
1317
+ try {
1318
+ return JSON.parse(readFileSync(snapshotPath, 'utf-8'));
1319
+ }
1320
+ catch {
1321
+ return null;
1322
+ }
1323
+ }
1324
+ function normalizeNotebookRole(value) {
1325
+ return value === 'source' || value === 'analysis' ? value : 'supporting';
1326
+ }
1327
+ function listAppDrafts(projectRoot, appDir) {
1328
+ return scanFiles(join(appDir, 'drafts'), '.dql').map((file) => {
1329
+ const source = readFileSync(file, 'utf-8');
1330
+ const path = relative(projectRoot, file).replaceAll('\\', '/');
1331
+ return {
1332
+ path,
1333
+ name: matchString(source, /block\s+"([^"]+)"/) ?? titleFromPath(path),
1334
+ reviewStatus: matchString(source, /status\s*=\s*"([^"]+)"/) ?? 'review',
1335
+ };
1336
+ }).sort((a, b) => a.name.localeCompare(b.name));
1337
+ }
1338
+ function scanFiles(root, extension) {
1339
+ if (!existsSync(root))
1340
+ return [];
1341
+ const out = [];
1342
+ for (const entry of readdirSyncSafe(root)) {
1343
+ const full = join(root, entry.name);
1344
+ if (entry.isDirectory())
1345
+ out.push(...scanFiles(full, extension));
1346
+ else if (entry.isFile() && entry.name.endsWith(extension))
1347
+ out.push(full);
1348
+ }
1349
+ return out.sort();
1350
+ }
1351
+ function countAiPins(projectRoot, appId) {
1352
+ return listAiPins(projectRoot, appId).length;
1353
+ }
1354
+ function listAiPins(projectRoot, appId) {
1355
+ const dbPath = defaultLocalAppsDbPath(projectRoot);
1356
+ if (!existsSync(dbPath))
1357
+ return [];
1358
+ const storage = new LocalAppStorage(dbPath);
1359
+ try {
1360
+ return storage.listAiPins(appId);
1361
+ }
1362
+ finally {
1363
+ storage.close();
1364
+ }
1365
+ }
845
1366
  function listDashboardsFor(projectRoot, id) {
846
1367
  const result = loadAppById(projectRoot, id);
847
1368
  return result?.dashboards ?? null;