Flight recorder for Databricks: every query, script and guardrail block Claude makes, in a band above the prompt and a per-session HTML trace with deep links

A flight recorder for what Claude does on Databricks. Every query, Python script and guardrail block from a session shows up inside Claude Code as it happens, in a panel beside the conversation. Each session also gets one self-contained HTML page, with deep links into the workspace.
/dbx-trace): opens itself on a session's first Databricks action. It shows:Enter on any action opens its full SQL, error or block reason, tables, query-profile link and, for a script, the statements inferred from its run. k / j step through actions and b goes back.
rcm_prod are red, writes to rcm_dev green, and blocks amber./dbx-trace page): the same trace as one HTML file with no external assets: totals, a timeline, guardrails, a sortable and filterable actions table, a tables-touched graph and a reports list. It opens offline and can be attached to a PR.system.query.history and fills in server duration, bytes read and rows.Nothing is added to your SQL. Statements reach Databricks exactly as Claude wrote them. They are tied back to the session by who ran them and when: executed_by (your Entra email) plus each call's time window.
/dbx-trace | Open the panel |
/dbx-trace page | Write the HTML page and open it in the browser |
/dbx-trace refresh | Read query history now and update the panel and page |
/dbx-trace path | Print where the trace and page are |
/dbx-trace hide | Close the panel and hide the band (/dbx-trace brings them back) |
/dbx-trace clear | Empty this session's trace |
| In the panel | ctrl+x tab to focus it, then ↑↓ and Enter to open an action; b back, k / j previous / next, r refresh history, o page |
| In the band | Enter on an action opens it in the panel; p panel, o page, m minimize |
The panel opens by itself only in an interactive session. On a terminal narrower than 144 columns it waits, and the band covers in the meantime. Asking for it with /dbx-trace opens it at any width. In fullscreen (/tui fullscreen) it docks beside the conversation; otherwise it sits above the prompt. If dbt-runs is also showing, the two bands stack.
| Captured | How it is detected | How it is tied to query history |
|---|---|---|
query: SQL through the Databricks MCP | Tool name matches mcp_tool_pattern and carries SQL in one of sql_arg_names | By statement_id when the MCP returns one, otherwise by identical statement text within ±2 min |
script: Python through Bash | A python / uv run / poetry run script whose source imports databricks, or inline -c code that does | Every statement you ran during the script's run (±5 s), marked as inferred, with client_application shown |
cli: the databricks CLI | databricks <subcommand> at a command position | Same window match as scripts |
blocked | Your whitelist PreToolUse hook (or any plugin) denied the call | Not run, so there is nothing in history; the page shows the rule and what Claude did next |
Each action is one line in <project>/.claude/dbx-trace/<session>.jsonl. The file is append-only: a running line is written when the call starts and a settled line when it ends. Query-history results are cached beside it in <session>.enrich.json, and the page is <session>.html. A .gitignore containing * is written into that folder, so traces can't be committed by accident.
PHI. Only SQL text, ids and counts are kept. Result rows, samples and cell values never reach the trace, the page or the history cache. Row counts are read from the response's count fields or its row-array length, never from the values.
A report generator registers its pages in <session>.reports.json as [{ "title": "...", "path": "...", "kind": "profile" }]. The trace page lists them under Reports.
Set the options in /config (or under pluginConfigs["dbx-trace"].options in settings.json):
| Option | Default | |
|---|---|---|
workspace_host | (blank) | e.g. https://adb-1234567890123456.7.azuredatabricks.net; deep links are left out until this is set |
workspace_id | (blank) | Azure workspace id; links get ?o=<id> |
user_email | (blank: current_user()) | The identity system.query.history records as executed_by |
mcp_tool_pattern | ^mcp__.*databricks.*__ | Regex matching the MCP tools that run SQL |
sql_arg_names | statement,query,sql,sql_query | Argument names that carry the SQL |
enrich_tool | (blank: the last SQL tool used) | Full tool name, e.g. mcp__databricks__execute_sql, for reading history in a session that only ran scripts |
prod_catalogs / dev_catalogs | rcm_prod / rcm_dev | Catalogs drawn red and green |
enrich | true | Read query history at the end of each turn |
The history read runs through the same MCP tool, as you (on behalf of you), so Unity Catalog grants apply. No tokens are stored anywhere.
These were built against the test kit and could not be verified against a live workspace:
/mcp and confirm the Databricks SQL tool matches mcp_tool_pattern and its SQL argument is listed in sql_arg_names.statement_id? (Check statement_id in the .jsonl after one query.) If not, matching falls back to statement text, which still works.PENDING and needs a second poll tool, the row count may be missing from the trace (the history read still fills it in).SELECT * FROM system.query.history LIMIT 1 works for you. If not, the band and page show "query history unavailable: …" until the DW team grants SELECT.statement_id, execution_status, total_duration_ms, read_bytes, produced_rows, compute.warehouse_id, error_message, client_application, start_time, end_time, statement_text, executed_by./dbx-trace refresh forces a read./explore/data/<c>/<s>/<t>, query history /sql/history?queryId=<id>, warehouse /sql/warehouses/<id>. The patterns live in hooks/links.ts.One tool.call hook sees each Databricks call before and after it runs. Times come from the clock, ids and counts from the result, and tables from the SQL (a regex reader in hooks/sql.ts: reads from FROM/JOIN/USING, writes from INSERT/MERGE/UPDATE/DELETE/CREATE/COPY/ALTER/DROP, with CTEs excluded). A classic.PreToolUse hook sits beneath it and reads your whitelist's verdict, so a block is recorded as blocked and not as a failure. At the end of each main-loop turn, outside the turn's own time, the mod reads query history once for the whole session window (its own query carries a /* dbx-trace enrichment */ marker to exclude itself) and redraws the page.
To try it without a workspace, ../tools/fake-dbx/try.sh starts a session against a local fake Databricks (MCP warehouse with mock RCM data, a fake databricks.sql, a stand-in whitelist hook); --demo runs a scripted session and opens the page.
claude plugin validate .
claude plugin test .
claude --plugin-dir . # load this working copy for one sessionhooks/register.tsx 857 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { DbxEnrichment, DbxEvent, DbxPaneView } from '../types'
5import { ENRICH_MARK, buildQuery, emptyEnrichment, matchRows, parseHistory, pendingEvents, rowsFor } from './enrich'
6import type { McpLikeResult } from './enrich'
7import {
8 classifyBash,
9 commandDir,
10 formatBytes,
11 formatDuration,
12 formatTime,
13 keptArgs,
14 latestPerId,
15 resolvePath,
16 resultFacts,
17 splitMcpName,
18 sqlArgOf,
19 tracePaths,
20 truncate,
21} from './events'
22import { kindOf, renderPage, totals } from './html'
23import type { Kind, Report } from './html'
24import type { LinkConfig } from './links'
25import { queryLink, tableLink } from './links'
26import { KIND_COLOR, tableGroups, timelineCells, timelineSvg } from './pane'
27import { classify, extractTables, preview, splitList } from './sql'
28
29type $ = EngineInterface
30
31const events = atom({ plugin: 'dbx-trace', key: 'events' } as const, [] as DbxEvent[])
32const enrichment = atom({ plugin: 'dbx-trace', key: 'enrichment' } as const, emptyEnrichment())
33const isShown = atom({ plugin: 'dbx-trace', key: 'isShown' } as const, false)
34const pagePath = atom({ plugin: 'dbx-trace', key: 'pagePath' } as const, '')
35const paneView = atom({ plugin: 'dbx-trace', key: 'paneView' } as const, { kind: 'list' } as DbxPaneView)
36const isPaneOpen = atom({ plugin: 'dbx-trace', key: 'isPaneOpen' } as const, false)
37
38const PANE = 'dbx-trace'
39const PANE_TITLE = 'Databricks trace'
40const PANE_ROWS = 28
41
42const BAND_ROWS = 6
43
44const GLYPH: Record<Kind | 'running', { glyph: string; color: string }> = {
45 read: { glyph: '◇', color: 'inactive' },
46 write: { glyph: '✎', color: 'success' },
47 'prod-write': { glyph: '✎', color: 'error' },
48 script: { glyph: '▸', color: 'suggestion' },
49 failed: { glyph: '✗', color: 'error' },
50 blocked: { glyph: '⊘', color: 'warning' },
51 running: { glyph: '●', color: 'warning' },
52}
53
54type Config = {
55 links: LinkConfig
56 user: string
57 toolPattern: RegExp
58 sqlArgs: string[]
59 enrichTool: string
60 prod: string[]
61 dev: string[]
62 enrich: boolean
63}
64
65const readConfig = (options: PluginOptions): Config => {
66 const s = (key: string, fallback: string) => (typeof options[key] === 'string' ? (options[key] as string) : fallback)
67 let toolPattern: RegExp
68 try {
69 toolPattern = new RegExp(s('mcp_tool_pattern', '^mcp__.*databricks.*__'), 'i')
70 } catch {
71 toolPattern = /^mcp__.*databricks.*__/i
72 }
73 return {
74 links: { host: s('workspace_host', ''), workspaceId: s('workspace_id', '') },
75 user: s('user_email', ''),
76 toolPattern,
77 sqlArgs: s('sql_arg_names', 'statement,query,sql,sql_query')
78 .split(',')
79 .map(x => x.trim())
80 .filter(x => x !== ''),
81 enrichTool: s('enrich_tool', ''),
82 prod: splitList(s('prod_catalogs', 'rcm_prod')),
83 dev: splitList(s('dev_catalogs', 'rcm_dev')),
84 enrich: options.enrich !== false,
85 }
86}
87
88const readText = async ($: $, path: string) => {
89 try {
90 return await $.fs.read(path)
91 } catch {
92 return undefined
93 }
94}
95
96const iso = (ms: number) => new Date(ms).toISOString()
97
98// Module state: a reload starts it over, which is fine (the files and $.state keep the record).
99let cfg: Config = readConfig({})
100// Writes to one file go one at a time.
101let writes: Promise<unknown> = Promise.resolve()
102let isEnriching = false
103let isDirty = false
104// A headless run (`claude -p`, the SDK) exits right after its last turn: it reads history inside the turn.
105let isInteractive = true
106// The panel opens itself once, on a session's first Databricks action.
107let hasAutoOpened = false
108let branch: string | undefined
109// tool_use_id -> the whitelist's deny, seen at classic.PreToolUse beneath our tool.call hook.
110const denials = new Map<string, string>()
111
112
113// The project root, not the shell's cwd: a `cd` in a Bash call must not scatter one session's trace.
114const paths = async ($: $) => tracePaths(await $.session.root(), await $.session.id())
115
116const queue = (work: () => Promise<void>) => {
117 const run = writes.then(work, work)
118 writes = run.catch(() => undefined)
119 return run
120}
121
122/** Appends the event's line (never rewrites earlier ones) and updates the band. */
123const record = async ($: $, event: DbxEvent) => {
124 isDirty = true
125 await update($, events, list => {
126 const at = list.findIndex(x => x.id === event.id)
127 return at < 0 ? [...list, event] : list.map((x, i) => (i === at ? event : x))
128 })
129 const p = await paths($)
130 await queue(async () => {
131 if ((await readText($, p.gitignore)) === undefined) await $.fs.write(p.gitignore, '*\n')
132 const before = (await readText($, p.events)) ?? ''
133 await $.fs.write(p.events, `${before}${JSON.stringify(event)}\n`)
134 })
135}
136
137const loadSession = async ($: $) => {
138 const p = await paths($)
139 const list = latestPerId((await readText($, p.events)) ?? '')
140 let saved: DbxEnrichment = emptyEnrichment()
141 try {
142 const text = await readText($, p.enrichment)
143 if (text !== undefined) saved = { ...emptyEnrichment(), ...(JSON.parse(text) as DbxEnrichment) }
144 } catch {
145 // A torn file: read history again.
146 }
147 await update($, events, () => list)
148 await update($, enrichment, () => saved)
149 if (list.length > 0) await update($, pagePath, () => p.page)
150}
151
152/** The MCP server and tool that read history: configured, else the last SQL tool Claude used. */
153const enrichTarget = (list: readonly DbxEvent[]) => {
154 const lastQuery = [...list].reverse().find(e => e.event_type === 'query' && e.sql_arg !== undefined)
155 const name = cfg.enrichTool !== '' ? cfg.enrichTool : lastQuery?.tool
156 const split = name === undefined ? undefined : splitMcpName(name)
157 if (split === undefined) return undefined
158 const arg = lastQuery?.sql_arg ?? cfg.sqlArgs[0] ?? 'statement'
159 return { ...split, arg, extra: lastQuery?.call_args ?? {} }
160}
161
162type EnrichTarget = NonNullable<ReturnType<typeof enrichTarget>>
163
164/**
165 * Runs the history read on the MCP directly; where no session is bound for that (a headless
166 * `claude -p`), through the same tool as a tool call, which the whitelist sees like any other.
167 */
168const runHistoryQuery = async ($: $, target: EnrichTarget, query: string): Promise<McpLikeResult> => {
169 const args = { ...target.extra, [target.arg]: query }
170 try {
171 return (await $.mcp.call(target.server, target.tool, args)) as McpLikeResult
172 } catch (err) {
173 if (!/not available/i.test(err instanceof Error ? err.message : String(err))) throw err
174 }
175 const ran = await $.tool.call({ tool: `mcp__${target.server}__${target.tool}`, ...args } as never)
176 if (ran.deny !== undefined) return { content: [{ type: 'text', text: ran.deny }], isError: true }
177 return { content: [{ type: 'text', text: ran.text ?? JSON.stringify(ran.result) }], isError: ran.isError === true }
178}
179
180const enrichNow = async ($: $, force: boolean) => {
181 if (!cfg.enrich || isEnriching) return
182 const list = await read($, events)
183 const current = await read($, enrichment)
184 const now = await $.clock.now()
185 if (!force && pendingEvents(list, current, now).length === 0) return
186 const target = enrichTarget(list)
187 const query = buildQuery(list, cfg.user, now)
188 if (query === undefined) return
189 if (target === undefined) {
190 await saveEnrichment($, {
191 ...current,
192 error: 'no Databricks SQL tool used yet this session; set enrich_tool to read history for scripts',
193 })
194 return
195 }
196 isEnriching = true
197 try {
198 const result = await runHistoryQuery($, target, query)
199 const parsed = parseHistory(result)
200 if ('error' in parsed) {
201 await saveEnrichment($, { ...current, error: parsed.error })
202 } else {
203 await saveEnrichment($, { fetched_at: now, rows: parsed.rows, matches: matchRows(list, parsed.rows, now) })
204 }
205 } catch (err) {
206 await saveEnrichment($, { ...current, error: err instanceof Error ? err.message : String(err) })
207 } finally {
208 isEnriching = false
209 }
210}
211
212const saveEnrichment = async ($: $, value: DbxEnrichment) => {
213 isDirty = true
214 await update($, enrichment, () => value)
215 const p = await paths($)
216 await queue(() => $.fs.write(p.enrichment, JSON.stringify(value)))
217}
218
219const readReports = async ($: $, path: string): Promise<Report[]> => {
220 try {
221 const parsed = JSON.parse((await readText($, path)) ?? '[]') as unknown
222 return Array.isArray(parsed) ? parsed.filter((r): r is Report => typeof r?.title === 'string' && typeof r?.path === 'string') : []
223 } catch {
224 return []
225 }
226}
227
228const writePage = async ($: $) => {
229 const list = await read($, events)
230 if (list.length === 0) return undefined
231 const p = await paths($)
232 const cwd = await $.session.root()
233 const html = renderPage({
234 sessionId: await $.session.id(),
235 branch,
236 cwd,
237 generatedAt: await $.clock.now(),
238 events: list,
239 enrichment: await read($, enrichment),
240 reports: await readReports($, p.reports),
241 links: cfg.links,
242 prod: cfg.prod,
243 dev: cfg.dev,
244 })
245 await queue(() => $.fs.write(p.page, html))
246 isDirty = false
247 await update($, pagePath, () => p.page)
248 return p.page
249}
250
251const openPage = async ($: $, path: string) => {
252 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
253 const tries: string[][] = isWindows ? [['cmd', '/c', 'start', '', path]] : [['open', path], ['xdg-open', path]]
254 for (const argv of tries) {
255 const ran = await $.process.run(argv, { timeoutMs: 10000 }).catch(() => undefined)
256 if (ran?.exitCode === 0) return true
257 }
258 return false
259}
260
261/** Opens the panel; asked (a command, a press) it seats at any width, unasked from 144 columns. */
262const openPane = async ($: $) => {
263 // Inline (the default layout) it asks for room to show the actions; docked it takes the column.
264 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE, rows: PANE_ROWS })
265 await update($, isPaneOpen, () => opened.isPlaced)
266 return opened.isPlaced
267}
268
269const showAction = async ($: $, id: string) => {
270 await update($, paneView, (): DbxPaneView => ({ kind: 'detail', id }))
271 await openPane($)
272}
273
274/** A Databricks action started: the band shows, and the session's first one opens the panel. */
275const announce = async ($: $) => {
276 await update($, isShown, () => true)
277 if (isInteractive && !hasAutoOpened) {
278 hasAutoOpened = true
279 await openPane($).catch(() => undefined)
280 }
281}
282
283/** End of a turn: read history for what is still unmatched, then redraw the page. */
284const afterTurn = async ($: $) => {
285 await enrichNow($, false)
286 if (isDirty) await writePage($)
287}
288
289/** Deep links for the model to quote after a query: the statement, then the tables. */
290const linkNote = (event: DbxEvent): string | undefined => {
291 if (event.status !== 'succeeded') return undefined
292 const statement = event.statement_id === undefined ? undefined : queryLink(cfg.links, event.statement_id)
293 const tables = [...event.tables_written, ...event.tables_read.filter(t => !event.tables_written.includes(t))]
294 .slice(0, 6)
295 .flatMap(t => {
296 const href = tableLink(cfg.links, t)
297 return href === undefined ? [] : [`${t}: ${href}`]
298 })
299 if (statement === undefined && tables.length === 0) return undefined
300 return [
301 'Databricks links for this call (include the relevant ones when you report on it):',
302 ...(statement !== undefined ? [`query profile: ${statement}`] : []),
303 ...tables,
304 ].join('\n')
305}
306
307const settle = async (
308 $: $,
309 event: DbxEvent,
310 ran: { deny?: string; isError?: boolean; text?: string },
311 toolUseId: string,
312): Promise<DbxEvent> => {
313 const ended = await $.clock.now()
314 const duration_ms = ended - Date.parse(event.ts)
315 // The engine prefixes a settings hook's reason (`PreToolUse:<tool> hook error: `); keep the rule's own words.
316 const denied = (ran.deny ?? denials.get(toolUseId))?.replace(/^PreToolUse:\S+ hook(?: blocking)? error:\s*/, '')
317 denials.delete(toolUseId)
318 if (denied !== undefined) {
319 const blocked: DbxEvent = { ...event, event_type: 'blocked', status: 'blocked', block_reason: denied, ended_ts: iso(ended), duration_ms }
320 await record($, blocked)
321 return blocked
322 }
323 const settled: DbxEvent = {
324 ...event,
325 status: ran.isError === true ? 'failed' : 'succeeded',
326 ended_ts: iso(ended),
327 duration_ms,
328 ...(ran.isError === true ? { error: (ran.text ?? '').trim().split('\n')[0]?.slice(0, 500) } : {}),
329 }
330 await record($, settled)
331 return settled
332}
333
334export const register: Register = (on, options) => {
335 cfg = readConfig(options)
336
337 on('session.start', async ($, e, next) => {
338 isInteractive = e.isInteractive
339 const started = await next(e)
340 await $.command.register({
341 name: 'dbx-trace',
342 description: 'Databricks trace panel (args: page | refresh | path | hide | clear)',
343 argumentHint: '[page|refresh|path|hide|clear]',
344 })
345 await loadSession($)
346 // A resumed session already had its first action; its panel opens on asking.
347 if ((await read($, events)).length > 0) {
348 hasAutoOpened = true
349 await update($, isShown, () => true)
350 }
351 const git = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: e.cwd, timeoutMs: 5000 }).catch(() => undefined)
352 branch = git?.exitCode === 0 ? git.stdout.trim() : undefined
353 return started
354 })
355
356 // Beneath every tool.call hook: the whitelist's verdict, so a block is not mistaken for a failure.
357 on('classic.PreToolUse', async ($, e, next) => {
358 const verdict = await next(e)
359 if (typeof verdict.deny === 'string') denials.set(e.tool_use_id, verdict.deny)
360 return verdict
361 }).catch(($, e, next) => next(e))
362
363 on('tool.call', async ($, e, next) => {
364 const tool = String(e.tool)
365 const input = e as unknown as Record<string, unknown>
366 const sessionId = await $.session.id()
367 const startedAt = await $.clock.now()
368
369 if (tool === 'Bash') {
370 const command = typeof input.command === 'string' ? input.command : ''
371 const cwd = await $.session.cwd()
372 const home = await $.env.get('HOME')
373 const dir = commandDir(command, cwd, home)
374 const kind = await classifyBash(command, path => readText($, resolvePath(path, dir, home)))
375 if (kind === undefined) return next(e)
376 const tables = extractTables(command)
377 const event: DbxEvent = {
378 id: e.tool_use_id,
379 ts: iso(startedAt),
380 session_id: sessionId,
381 event_type: kind,
382 tool: kind === 'cli' ? 'databricks CLI' : 'Bash (python)',
383 command,
384 status: 'running',
385 tables_read: tables.read,
386 tables_written: tables.written,
387 ...(input.run_in_background === true ? { background: true } : {}),
388 }
389 await record($, event)
390 await announce($)
391 const ran = await next(e)
392 await settle($, event, ran, e.tool_use_id)
393 return ran
394 }
395
396 if (!cfg.toolPattern.test(tool)) return next(e)
397 const found = sqlArgOf(input, cfg.sqlArgs)
398 // Not a SQL call on this server (listing warehouses, polling): pass, unrecorded.
399 if (found === undefined || found.sql.includes(ENRICH_MARK)) return next(e)
400 const tables = extractTables(found.sql)
401 const event: DbxEvent = {
402 id: e.tool_use_id,
403 ts: iso(startedAt),
404 session_id: sessionId,
405 event_type: 'query',
406 tool,
407 sql_text: found.sql,
408 sql_arg: found.arg,
409 call_args: keptArgs(input),
410 status: 'running',
411 tables_read: tables.read,
412 tables_written: tables.written,
413 }
414 const warehouse = Object.entries(event.call_args ?? {}).find(([k]) => /warehouse/i.test(k))?.[1]
415 if (typeof warehouse === 'string') event.warehouse_id = warehouse
416 await record($, event)
417 await announce($)
418
419 const ran = await next(e)
420 const facts = ran.deny === undefined && ran.isError !== true ? resultFacts(ran.text) : {}
421 const settled = await settle(
422 $,
423 {
424 ...event,
425 ...(facts.statementId !== undefined ? { statement_id: facts.statementId } : {}),
426 ...(facts.warehouseId !== undefined ? { warehouse_id: facts.warehouseId } : {}),
427 ...(facts.rowCount !== undefined ? { row_count: facts.rowCount } : {}),
428 },
429 ran,
430 e.tool_use_id,
431 )
432 if (ran.deny !== undefined || settled.status === 'blocked') return ran
433 const note = linkNote(settled)
434 return note === undefined ? ran : { ...ran, context: [...(ran.context ?? []), note] }
435 }).catch(($, e, next) => next(e))
436
437 on('turn.complete', async ($, e, next) => {
438 const done = await next(e)
439 // The main loop's turns only; history and the page catch up off the turn's clock, or, with
440 // nobody at the prompt, before the process can exit.
441 if (e.agentId === undefined && (await read($, events)).length > 0) {
442 if (isInteractive) $.clock.after(50, () => void afterTurn($).catch(() => undefined))
443 else await afterTurn($).catch(() => undefined)
444 }
445 return done
446 })
447
448 // A last read and redraw as the session ends; a headless run exits before the turn-end timer fires.
449 on('session.end', async ($, e, next) => {
450 if ((await read($, events)).length > 0) await afterTurn($).catch(() => undefined)
451 return next(e)
452 })
453
454 on('command.run', { command: 'dbx-trace' }, async ($, e) => {
455 const arg = e.args.trim()
456 if (arg === 'hide') {
457 await update($, isShown, () => false)
458 await $.ui.close({ id: PANE })
459 return { text: 'Databricks trace hidden; /dbx-trace brings it back.' }
460 }
461 if (arg === 'clear') {
462 const p = await paths($)
463 await queue(async () => {
464 await $.fs.write(p.events, '')
465 await $.fs.write(p.enrichment, JSON.stringify(emptyEnrichment()))
466 })
467 await update($, events, () => [])
468 await update($, enrichment, () => emptyEnrichment())
469 await update($, paneView, (): DbxPaneView => ({ kind: 'list' }))
470 return { text: 'Cleared this session’s Databricks trace.' }
471 }
472 if (arg === 'path') {
473 const p = await paths($)
474 return { text: `Trace: ${p.events}\nPage: ${p.page}` }
475 }
476 if (arg === 'refresh') {
477 await enrichNow($, true)
478 const page = await writePage($)
479 const err = (await read($, enrichment)).error
480 if (page === undefined) return { text: 'No Databricks actions in this session yet.' }
481 return { text: err === undefined ? `Query history read; panel and page updated.` : `Query history unavailable (${err}).` }
482 }
483 if (arg === 'page') {
484 const page = await writePage($)
485 if (page === undefined) return { text: 'No Databricks actions in this session yet.' }
486 const opened = await openPage($, page)
487 return { text: opened ? `Opened ${page}` : `Trace page: ${page}` }
488 }
489 await update($, paneView, (): DbxPaneView => ({ kind: 'list' }))
490 await update($, isShown, () => true)
491 const placed = await openPane($)
492 return { text: placed ? 'Databricks trace panel opened.' : 'Databricks trace panel is waiting for room; the band shows meanwhile.' }
493 })
494
495 // The person (or a plugin) closed the panel: the band comes back.
496 on('ui.close', { id: PANE }, async ($, e, next) => {
497 const closed = await next(e)
498 await update($, isPaneOpen, () => false)
499 return closed
500 })
501
502 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
503 const below = await next(e)
504 if (e.props.hasSurvey || !(await read($, isShown)) || (await read($, isPaneOpen))) return below
505 const list = await read($, events)
506 if (list.length === 0) return below
507
508 const { Box, Text, Button } = $.ui.resolve(e)
509 const width = Math.max(30, e.props.bodyColumns)
510 const enriched = await read($, enrichment)
511 const now = await $.clock.now()
512 const t = totals(list, enriched, cfg.prod)
513 const run = (work: () => Promise<unknown>) => () => void work().catch(() => undefined)
514
515 const rows = [...list].reverse().slice(0, BAND_ROWS)
516 const band = (
517 <Box flexDirection="column">
518 <Box flexDirection="row" gap={1}>
519 <Text>
520 <Text bold>Databricks</Text>
521 <Text dimColor>
522 {' '}
523 · {t.actions} action{t.actions === 1 ? '' : 's'}
524 </Text>
525 {t.failed > 0 && <Text color="error"> · {t.failed} failed</Text>}
526 {t.blocked > 0 && <Text color="warning"> · {t.blocked} blocked</Text>}
527 {t.prodWrites > 0 && (
528 <Text color="error" bold>
529 {' '}
530 · {t.prodWrites} PROD WRITE{t.prodWrites === 1 ? '' : 'S'}
531 </Text>
532 )}
533 {t.readBytes !== undefined && <Text dimColor> · {formatBytes(t.readBytes)} read</Text>}
534 </Text>
535 <Button key="panel" hotkey="p" onPress={run(async () => { await update($, paneView, (): DbxPaneView => ({ kind: 'list' })); await openPane($) })}>
536 Panel
537 </Button>
538 <Button key="open" hotkey="o" dimColor onPress={run(async () => { const p = await writePage($); if (p !== undefined) await openPage($, p) })}>
539 Page
540 </Button>
541 <Button key="hide" hotkey="m" dimColor onPress={run(() => update($, isShown, () => false))}>
542 Minimize
543 </Button>
544 </Box>
545 {rows.map(ev => {
546 const kind = kindOf(ev, cfg.prod)
547 const { glyph, color } = ev.status === 'running' ? GLYPH.running : GLYPH[kind]
548 const tables = ev.tables_written.length > 0 ? ev.tables_written : ev.tables_read
549 const tableText = tables.length === 0 ? '' : `${tables[0]}${tables.length > 1 ? ` +${tables.length - 1}` : ''}`
550 const stat = ev.status === 'running' ? 'running' : formatDuration(ev.duration_ms)
551 const head = `${formatTime(Date.parse(ev.ts), now)} ${stat.padStart(6)} `
552 const room = Math.max(10, width - head.length - tableText.length - 6)
553 return (
554 <Box key={`ev-${ev.id}`} flexDirection="row">
555 <Text color={color}>{glyph} </Text>
556 <Button
557 key={`band-open-${ev.id}`}
558 plain
559 onPress={run(() => showAction($, ev.id))}
560 label={`${head}${truncate(preview(ev.sql_text ?? ev.command ?? '', room), room)}`}
561 />
562 {tableText !== '' && <Text {...(ev.tables_written.length > 0 ? { color: classify(tables[0]!, cfg.prod, cfg.dev) === 'prod' ? 'error' : 'success' } : { dimColor: true })}> {tableText}</Text>}
563 </Box>
564 )
565 })}
566 {list.length > BAND_ROWS && <Text dimColor>… {list.length - BAND_ROWS} earlier in the panel</Text>}
567 {enriched.error !== undefined && (
568 <Text color="warning" wrap="truncate-end">
569 query history: {enriched.error}
570 </Text>
571 )}
572 <Text dimColor>ctrl+x tab to focus · Enter opens an action · p panel · o page · m minimize</Text>
573 </Box>
574 )
575 return (
576 <Box flexDirection="column">
577 {band}
578 {below}
579 </Box>
580 )
581 })
582
583 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
584 const { Box, Text, Button, Code, Link } = $.ui.resolve(e)
585 const width = Math.max(40, e.props.bodyColumns)
586 const list = await read($, events)
587 const enriched = await read($, enrichment)
588 const view = await read($, paneView)
589 const now = await $.clock.now()
590 const run = (work: () => Promise<unknown>) => () => void work().catch(() => undefined)
591 const toList = run(() => update($, paneView, (): DbxPaneView => ({ kind: 'list' })))
592 const openPageNow = run(async () => {
593 const p = await writePage($)
594 if (p !== undefined) await openPage($, p)
595 })
596 const refresh = run(async () => {
597 await enrichNow($, true)
598 await writePage($)
599 })
600 const link = (href: string | undefined, text: string, color?: string) =>
601 href === undefined ? <Text {...(color !== undefined ? { color } : {})}>{text}</Text> : <Link href={href}>{color !== undefined ? <Text color={color}>{text}</Text> : text}</Link>
602 const tableColor = (t: string, written: boolean) => {
603 const cls = classify(t, cfg.prod, cfg.dev)
604 return cls === 'prod' ? 'error' : cls === 'dev' ? 'success' : written ? undefined : 'inactive'
605 }
606 const glyphOf = (ev: DbxEvent) => (ev.status === 'running' ? GLYPH.running : GLYPH[kindOf(ev, cfg.prod)])
607
608 if (list.length === 0) {
609 return (
610 <Box flexDirection="column">
611 <Text bold>Databricks trace</Text>
612 <Text dimColor>No Databricks actions in this session yet. They appear here as Claude runs queries and scripts.</Text>
613 </Box>
614 )
615 }
616
617 // --- One action ---------------------------------------------------------------------
618 const detail = view.kind === 'detail' ? list.find(x => x.id === view.id) : undefined
619 if (detail !== undefined) {
620 const kind = kindOf(detail, cfg.prod)
621 const { glyph, color } = glyphOf(detail)
622 const history = rowsFor(enriched, detail.id)
623 const own = detail.event_type === 'query' && history.length === 1 ? history[0] : undefined
624 const statementId = detail.statement_id ?? own?.statement_id
625 const text = detail.sql_text ?? detail.command ?? ''
626 const at = list.indexOf(detail)
627 const next = list[at + 1]
628 const facts = [
629 detail.status,
630 formatTime(Date.parse(detail.ts), now),
631 `client ${formatDuration(detail.duration_ms)}`,
632 ...(own?.total_duration_ms !== undefined ? [`server ${formatDuration(own.total_duration_ms)}`] : []),
633 ...(own?.read_bytes !== undefined ? [`${formatBytes(own.read_bytes)} read`] : []),
634 ...((own?.produced_rows ?? detail.row_count) !== undefined ? [`${(own?.produced_rows ?? detail.row_count)!.toLocaleString('en-US')} rows`] : []),
635 ].join(' · ')
636 return (
637 <Box flexDirection="column" gap={1}>
638 <Box flexDirection="row" gap={1}>
639 <Button key="back" hotkey="b" autoFocus onPress={toList}>
640 Back
641 </Button>
642 {at > 0 && (
643 <Button key="prev" hotkey="k" dimColor onPress={run(() => update($, paneView, (): DbxPaneView => ({ kind: 'detail', id: list[at - 1]!.id })))}>
644 Prev
645 </Button>
646 )}
647 {next !== undefined && (
648 <Button key="next" hotkey="j" dimColor onPress={run(() => update($, paneView, (): DbxPaneView => ({ kind: 'detail', id: next.id })))}>
649 Next
650 </Button>
651 )}
652 <Button key="page" hotkey="o" dimColor onPress={openPageNow}>
653 Page
654 </Button>
655 </Box>
656 <Box flexDirection="column">
657 <Text>
658 <Text color={color} bold>
659 {glyph} {kind === 'prod-write' ? 'PROD WRITE' : kind}
660 </Text>
661 <Text dimColor> · {facts}</Text>
662 </Text>
663 <Text dimColor wrap="truncate-end">
664 {detail.tool}
665 {statementId !== undefined ? ` · statement ${statementId}` : ''}
666 </Text>
667 {statementId !== undefined && queryLink(cfg.links, statementId) !== undefined && link(queryLink(cfg.links, statementId), 'Open query profile ↗')}
668 </Box>
669 <Code source={text} language={detail.event_type === 'query' || detail.event_type === 'blocked' ? 'sql' : 'bash'} wrap="wrap" />
670 {detail.block_reason !== undefined && <Text color="warning">⊘ {detail.block_reason}</Text>}
671 {(own?.error_message ?? detail.error) !== undefined && <Text color="error">✗ {own?.error_message ?? detail.error}</Text>}
672 {detail.tables_read.length + detail.tables_written.length > 0 && (
673 <Box flexDirection="column">
674 <Text bold>Tables</Text>
675 {detail.tables_written.map(t => (
676 <Text>
677 <Text color={tableColor(t, true)}>✎ </Text>
678 {link(tableLink(cfg.links, t), t, tableColor(t, true))}
679 <Text dimColor>{detail.status === 'blocked' ? ' (attempted)' : ' written'}</Text>
680 </Text>
681 ))}
682 {detail.tables_read
683 .filter(t => !detail.tables_written.includes(t))
684 .map(t => (
685 <Text>
686 <Text dimColor>◇ </Text>
687 {link(tableLink(cfg.links, t), t, tableColor(t, false))}
688 <Text dimColor> read</Text>
689 </Text>
690 ))}
691 </Box>
692 )}
693 {detail.event_type !== 'query' && history.length > 0 && (
694 <Box flexDirection="column">
695 <Text bold>
696 {history.length} statement{history.length === 1 ? '' : 's'} inferred from this run’s window
697 </Text>
698 {history.map(r => (
699 <Box flexDirection="column">
700 <Text wrap="truncate-end">
701 <Text color={r.execution_status === 'FAILED' ? 'error' : 'success'}>{r.execution_status === 'FAILED' ? '✗' : '✓'} </Text>
702 {truncate(preview(r.statement_text ?? r.statement_id, width - 4), width - 4)}
703 </Text>
704 <Text dimColor>
705 {' '}
706 {formatDuration(r.total_duration_ms)} · {formatBytes(r.read_bytes)} · {r.produced_rows ?? '–'} rows · {r.client_application ?? 'unknown client'}
707 </Text>
708 </Box>
709 ))}
710 </Box>
711 )}
712 {detail.status === 'blocked' && (
713 <Text dimColor wrap="truncate-end">
714 Next: {next === undefined ? 'nothing on Databricks' : `${kindOf(next, cfg.prod)} · ${preview(next.sql_text ?? next.command ?? '', width - 20)}`}
715 </Text>
716 )}
717 <Text dimColor>b back · k/j previous/next · o page · Esc to the prompt</Text>
718 </Box>
719 )
720 }
721
722 // --- The whole trace ----------------------------------------------------------------
723 const t = totals(list, enriched, cfg.prod)
724 const sessionId = await $.session.id()
725 const guards = list.filter(x => x.status === 'blocked' || kindOf(x, cfg.prod) === 'prod-write')
726 const groups = tableGroups(list, cfg.prod, cfg.dev)
727 const newest = [...list].reverse()
728 const catalogWidth = Math.max(8, ...groups.map(g => g.catalog.length)) + 1
729 const nameWidth = Math.min(48, Math.max(12, ...groups.flatMap(g => g.tables.map(u => u.name.length - g.catalog.length - 1))))
730 const timeline =
731 e.surface === 'terminal' ? (
732 (() => {
733 const { Raster } = $.ui.resolve(e)
734 return <Raster key="timeline" columns={Math.min(512, width)} rows={1} cells={timelineCells(list, cfg.prod, Math.min(512, width))} />
735 })()
736 ) : (
737 (() => {
738 const ui = $.ui.resolve(e)
739 return 'Svg' in ui ? (
740 <ui.Svg source={timelineSvg(list, cfg.prod, width * 8)} alt={`Timeline of ${list.length} Databricks actions`} />
741 ) : (
742 <Text dimColor>{list.length} actions over time</Text>
743 )
744 })()
745 )
746 const historyLine =
747 enriched.error !== undefined
748 ? { color: 'warning', text: `query history unavailable: ${enriched.error}` }
749 : enriched.fetched_at === 0
750 ? { color: undefined, text: 'query history: not read yet (end of turn, or r)' }
751 : { color: undefined, text: `query history read ${formatTime(enriched.fetched_at, now)} · ${t.statements} statements matched` }
752
753 const columnsHead = 'time · client · server · read'
754 return (
755 <Box flexDirection="column">
756 <Text wrap="truncate-end">
757 <Text bold>Databricks</Text>
758 <Text dimColor>
759 {' '}
760 · {t.actions} action{t.actions === 1 ? '' : 's'} · {t.queries} quer{t.queries === 1 ? 'y' : 'ies'}
761 </Text>
762 {t.failed > 0 && <Text color="error"> · {t.failed} failed</Text>}
763 {t.blocked > 0 && <Text color="warning"> · {t.blocked} blocked</Text>}
764 {t.prodWrites > 0 ? (
765 <Text color="error" bold>
766 {' '}
767 · {t.prodWrites} PROD WRITE{t.prodWrites === 1 ? '' : 'S'}
768 </Text>
769 ) : (
770 <Text dimColor> · no prod writes</Text>
771 )}
772 {t.readBytes !== undefined && <Text dimColor> · {formatBytes(t.readBytes)} read</Text>}
773 {t.serverMs !== undefined && <Text dimColor> · {formatDuration(t.serverMs)} server</Text>}
774 </Text>
775 <Box flexDirection="row" gap={1}>
776 <Text wrap="truncate-end" {...(historyLine.color !== undefined ? { color: historyLine.color } : { dimColor: true })}>
777 session {sessionId.slice(0, 8)}
778 {branch !== undefined ? ` · ${branch}` : ''} · {historyLine.text}
779 </Text>
780 <Button key="refresh" hotkey="r" dimColor onPress={refresh}>
781 Refresh
782 </Button>
783 <Button key="page" hotkey="o" dimColor onPress={openPageNow}>
784 Page
785 </Button>
786 </Box>
787 {timeline}
788 <Text>
789 <Text color={KIND_COLOR.read}>■ read </Text>
790 <Text color={KIND_COLOR.write}>■ write </Text>
791 <Text color={KIND_COLOR['prod-write']}>■ prod write </Text>
792 <Text color={KIND_COLOR.script}>■ script </Text>
793 <Text color={KIND_COLOR.failed}>■ failed </Text>
794 <Text color={KIND_COLOR.blocked}>■ blocked</Text>
795 </Text>
796 {guards.length > 0 && <Text bold>Guardrails</Text>}
797 {guards.map(g => (
798 <Button
799 key={`guard-${g.id}`}
800 plain
801 onPress={run(() => showAction($, g.id))}
802 label={truncate(
803 `${g.status === 'blocked' ? '⊘ blocked' : '✎ PROD WRITE'} ${formatTime(Date.parse(g.ts), now)} ${preview(g.sql_text ?? g.command ?? '', width)}${g.block_reason !== undefined ? ` — ${g.block_reason}` : ''}`,
804 width,
805 )}
806 />
807 ))}
808 <Text>
809 <Text bold>Actions</Text>
810 <Text dimColor> newest first · {columnsHead}</Text>
811 </Text>
812 {newest.map((ev, i) => {
813 const { glyph, color } = glyphOf(ev)
814 const history = rowsFor(enriched, ev.id)
815 const server = history.length === 0 ? undefined : history.reduce((n, r) => n + (r.total_duration_ms ?? 0), 0)
816 const bytes = history.length === 0 ? undefined : history.reduce((n, r) => n + (r.read_bytes ?? 0), 0)
817 const stats = `${(ev.status === 'running' ? 'running' : formatDuration(ev.duration_ms)).padStart(7)} ${formatDuration(server).padStart(7)} ${formatBytes(bytes).padStart(9)}`
818 const head = `${formatTime(Date.parse(ev.ts), now).padEnd(8)} ${stats} `
819 const room = Math.max(10, width - head.length - 3)
820 return (
821 <Box key={`row-${ev.id}`} flexDirection="row">
822 <Text color={color}>{glyph} </Text>
823 <Button
824 key={`open-${ev.id}`}
825 plain
826 {...(i === 0 ? { autoFocus: true as const } : {})}
827 onPress={run(() => showAction($, ev.id))}
828 label={`${head}${truncate(preview(ev.sql_text ?? ev.command ?? '', room), room)}`}
829 />
830 </Box>
831 )
832 })}
833 {groups.length > 0 && <Text bold>Tables touched</Text>}
834 {groups.flatMap(g =>
835 g.tables.map((u, i) => {
836 const short = u.name.slice(g.catalog.length + 1) || u.name
837 return (
838 <Text>
839 <Text color={g.cls === 'prod' ? 'error' : g.cls === 'dev' ? 'success' : 'inactive'} bold>
840 {(i === 0 ? g.catalog : '').padEnd(catalogWidth)}
841 </Text>
842 <Text {...(u.writes > 0 ? { color: g.cls === 'prod' ? 'error' : 'success' } : { dimColor: true })}>{u.writes > 0 ? ' ✎ ' : ' ◇ '}</Text>
843 {link(tableLink(cfg.links, u.name), short.padEnd(nameWidth))}
844 <Text dimColor>
845 {u.reads > 0 ? ` read ×${u.reads}` : ''}
846 {u.writes > 0 ? ` written ×${u.writes}` : ''}
847 </Text>
848 </Text>
849 )
850 }),
851 )}
852 <Text dimColor>ctrl+x tab to focus · ↑↓ Enter open an action · r refresh history · o page</Text>
853 </Box>
854 )
855 })
856}
857hooks/enrich.ts 214 lines1import type { DbxEnrichment, DbxEvent, DbxHistoryRow, DbxMatch } from '../types'
2import { normalizeSql, sqlString } from './sql'
3
4// Marks the enrichment query so it never shows up as one of the session's own statements.
5// The whole comment, so SQL that merely mentions these words is not mistaken for the mod's own read.
6export const ENRICH_MARK = '/* dbx-trace enrichment */'
7
8// Clock skew between this machine and the warehouse, plus queueing before start_time.
9const TEXT_SLACK_MS = 2 * 60 * 1000
10const WINDOW_SLACK_MS = 5 * 1000
11// Past this, an event that never matched is taken as never going to.
12// system.query.history ran ~15 min behind on a live workspace; give rows an hour to land.
13const PENDING_MS = 60 * 60 * 1000
14
15export const emptyEnrichment = (): DbxEnrichment => ({ fetched_at: 0, rows: [], matches: {} })
16
17const startOf = (e: DbxEvent) => Date.parse(e.ts)
18// A background run never reports its end here; its window is capped.
19const BACKGROUND_MS = 30 * 60 * 1000
20const endOf = (e: DbxEvent, now: number) =>
21 e.ended_ts !== undefined && e.background !== true
22 ? Date.parse(e.ended_ts)
23 : Math.min(now, Date.parse(e.ts) + BACKGROUND_MS)
24
25/** Events a history read could still tell more about. */
26export const pendingEvents = (events: readonly DbxEvent[], enrichment: DbxEnrichment, now: number): DbxEvent[] =>
27 events.filter(
28 e =>
29 e.status !== 'blocked' &&
30 e.status !== 'running' &&
31 (enrichment.matches[e.id]?.length ?? 0) === 0 &&
32 now - endOf(e, now) < PENDING_MS,
33 )
34
35/**
36 * One read of system.query.history covering every event's window, by identity: no tag in
37 * the statements themselves. `user` blank means the identity the MCP runs as.
38 */
39export const buildQuery = (events: readonly DbxEvent[], user: string, now: number): string | undefined => {
40 const live = events.filter(e => e.status !== 'blocked')
41 if (live.length === 0) return undefined
42 const from = Math.min(...live.map(startOf)) - TEXT_SLACK_MS
43 const to = Math.max(...live.map(e => endOf(e, now))) + TEXT_SLACK_MS
44 const who = user.trim() === '' ? 'current_user()' : sqlString(user.trim())
45 return [
46 ENRICH_MARK,
47 'SELECT statement_id, execution_status, total_duration_ms, read_bytes, produced_rows,',
48 ' compute.warehouse_id AS warehouse_id, error_message, client_application,',
49 ' unix_millis(start_time) AS start_ms, unix_millis(end_time) AS end_ms, statement_text',
50 'FROM system.query.history',
51 `WHERE executed_by = ${who}`,
52 ` AND start_time BETWEEN timestamp_millis(${from}) AND timestamp_millis(${to})`,
53 ` AND statement_text NOT LIKE '%${ENRICH_MARK}%'`,
54 'ORDER BY start_time',
55 'LIMIT 2000',
56 ].join('\n')
57}
58
59// --- Reading the rows back, whatever shape the MCP answers in --------------------------
60
61type Obj = Record<string, unknown>
62const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v)
63
64const columnNames = (cols: unknown): string[] | undefined =>
65 Array.isArray(cols) ? cols.map(c => (isObj(c) ? String(c.name ?? c.column_name ?? '') : String(c))) : undefined
66
67/** Finds a table in a parsed result: an array of objects, or columns beside an array of arrays. */
68/**
69 * One row's cells: a plain array, or the DBSQL statement API's `{ values: [{ string_value }] }`
70 * (a null cell is `{ null_value }` or `{}`). Anything else is not a row.
71 */
72const cellsOf = (row: unknown): unknown[] | undefined => {
73 if (Array.isArray(row)) return row
74 if (!isObj(row) || !Array.isArray(row.values)) return undefined
75 return row.values.map(cell =>
76 isObj(cell) ? (cell.string_value ?? cell.number_value ?? cell.bool_value ?? null) : cell,
77 )
78}
79
80const findTable = (value: unknown, depth = 0): Obj[] | undefined => {
81 if (depth > 6) return undefined
82 if (Array.isArray(value)) {
83 if (value.length > 0 && value.every(isObj)) return value
84 return undefined
85 }
86 if (!isObj(value)) return undefined
87 const schema = isObj(value.manifest) && isObj(value.manifest.schema) ? value.manifest.schema.columns : undefined
88 const names = columnNames(value.columns ?? schema)
89 const data = isObj(value.result) ? value.result.data_array : (value.data_array ?? value.rows ?? value.data)
90 const rows = Array.isArray(data) ? data.map(cellsOf) : undefined
91 if (names !== undefined && rows !== undefined && rows.every(r => r !== undefined)) {
92 return rows.map(row => Object.fromEntries(names.map((n, i) => [n, row![i]])))
93 }
94 for (const child of Object.values(value)) {
95 const found = findTable(child, depth + 1)
96 if (found !== undefined) return found
97 }
98 return undefined
99}
100
101const num = (v: unknown): number | undefined => {
102 if (v === null || v === undefined || v === '') return undefined
103 const n = Number(v)
104 return Number.isFinite(n) ? n : undefined
105}
106const str = (v: unknown): string | undefined => (v === null || v === undefined || v === '' ? undefined : String(v))
107
108const toRow = (raw: Obj): DbxHistoryRow | undefined => {
109 const lower = Object.fromEntries(Object.entries(raw).map(([k, v]) => [k.toLowerCase(), v]))
110 const id = str(lower.statement_id)
111 if (id === undefined) return undefined
112 return {
113 statement_id: id,
114 execution_status: str(lower.execution_status),
115 total_duration_ms: num(lower.total_duration_ms),
116 read_bytes: num(lower.read_bytes),
117 produced_rows: num(lower.produced_rows),
118 warehouse_id: str(lower.warehouse_id),
119 error_message: str(lower.error_message),
120 client_application: str(lower.client_application),
121 start_ms: num(lower.start_ms),
122 end_ms: num(lower.end_ms),
123 statement_text: str(lower.statement_text),
124 }
125}
126
127export type McpLikeResult = { content?: readonly { type: string; text?: string }[]; isError?: boolean; structuredContent?: unknown }
128
129/** History rows from an MCP result, or the reason none could be read. */
130export const parseHistory = (result: McpLikeResult): { rows: DbxHistoryRow[] } | { error: string } => {
131 const text = (result.content ?? [])
132 .filter(b => b.type === 'text' && typeof b.text === 'string')
133 .map(b => b.text)
134 .join('\n')
135 if (result.isError === true) return { error: firstLine(text) || 'the query failed' }
136 let table = findTable(result.structuredContent)
137 if (table === undefined) {
138 try {
139 table = findTable(JSON.parse(text))
140 } catch {
141 table = undefined
142 }
143 }
144 if (table === undefined) {
145 if (/no rows|empty|\[\s*\]/i.test(text) || text.trim() === '') return { rows: [] }
146 return { error: `could not read the history result: ${firstLine(text)}` }
147 }
148 return { rows: table.flatMap(r => toRow(r) ?? []) }
149}
150
151const firstLine = (text: string) => text.trim().split('\n')[0]?.slice(0, 300) ?? ''
152
153// --- Tying rows to events -----------------------------------------------------------------
154
155/**
156 * Matches rows to events. A query matches by statement id, else by identical text started
157 * inside its window; a script or CLI call claims every unclaimed statement inside its run.
158 */
159export const matchRows = (
160 events: readonly DbxEvent[],
161 rows: readonly DbxHistoryRow[],
162 now: number,
163): Record<string, { row: string; how: DbxMatch }[]> => {
164 const matches: Record<string, { row: string; how: DbxMatch }[]> = {}
165 const claimed = new Set<string>()
166 const add = (event: DbxEvent, row: DbxHistoryRow, how: DbxMatch) => {
167 claimed.add(row.statement_id)
168 ;(matches[event.id] ??= []).push({ row: row.statement_id, how })
169 }
170
171 const queries = events.filter(e => e.event_type === 'query' && e.status !== 'blocked')
172 for (const event of queries) {
173 const byId = event.statement_id === undefined ? undefined : rows.find(r => r.statement_id === event.statement_id)
174 if (byId !== undefined) add(event, byId, 'statement_id')
175 }
176 for (const event of queries) {
177 if (matches[event.id] !== undefined || event.sql_text === undefined) continue
178 const want = normalizeSql(event.sql_text)
179 const from = startOf(event) - TEXT_SLACK_MS
180 const to = endOf(event, now) + TEXT_SLACK_MS
181 const candidates = rows.filter(
182 r =>
183 !claimed.has(r.statement_id) &&
184 r.statement_text !== undefined &&
185 normalizeSql(r.statement_text) === want &&
186 (r.start_ms === undefined || (r.start_ms >= from && r.start_ms <= to)),
187 )
188 const nearest = candidates.sort(
189 (a, b) => Math.abs((a.start_ms ?? 0) - startOf(event)) - Math.abs((b.start_ms ?? 0) - startOf(event)),
190 )[0]
191 if (nearest !== undefined) add(event, nearest, 'text')
192 }
193 for (const event of events.filter(e => e.event_type === 'script' || e.event_type === 'cli')) {
194 const from = startOf(event) - WINDOW_SLACK_MS
195 const to = endOf(event, now) + WINDOW_SLACK_MS
196 for (const row of rows) {
197 if (claimed.has(row.statement_id) || row.start_ms === undefined) continue
198 if (row.start_ms >= from && row.start_ms <= to) add(event, row, 'window')
199 }
200 }
201 return matches
202}
203
204/** Rows for one event, in start order. */
205export const rowsFor = (enrichment: DbxEnrichment, eventId: string): (DbxHistoryRow & { how: DbxMatch })[] => {
206 const byId = new Map(enrichment.rows.map(r => [r.statement_id, r]))
207 return (enrichment.matches[eventId] ?? [])
208 .flatMap(m => {
209 const row = byId.get(m.row)
210 return row === undefined ? [] : [{ ...row, how: m.how }]
211 })
212 .sort((a, b) => (a.start_ms ?? 0) - (b.start_ms ?? 0))
213}
214hooks/events.ts 192 lines1import type { DbxEvent } from '../types'
2
3// --- Where a session's trace lives --------------------------------------------------------
4
5export const traceDir = (cwd: string): string => `${cwd.replace(/[\\/]+$/, '')}/.claude/dbx-trace`
6export const tracePaths = (cwd: string, sessionId: string) => {
7 const dir = traceDir(cwd)
8 return {
9 dir,
10 gitignore: `${dir}/.gitignore`,
11 events: `${dir}/${sessionId}.jsonl`,
12 enrichment: `${dir}/${sessionId}.enrich.json`,
13 page: `${dir}/${sessionId}.html`,
14 reports: `${dir}/${sessionId}.reports.json`,
15 }
16}
17
18/** The JSONL is append-only; a call's later line supersedes its earlier one. Order is first appearance. */
19export const latestPerId = (jsonl: string): DbxEvent[] => {
20 const byId = new Map<string, DbxEvent>()
21 for (const line of jsonl.split('\n')) {
22 if (line.trim() === '') continue
23 try {
24 const event = JSON.parse(line) as DbxEvent
25 if (typeof event.id === 'string') byId.set(event.id, event)
26 } catch {
27 // A torn last line from a crash: skip it.
28 }
29 }
30 return [...byId.values()]
31}
32
33// --- Which tool calls are Databricks --------------------------------------------------------
34
35/** `mcp__<server>__<tool>` split at its first `__` after the prefix. */
36export const splitMcpName = (name: string): { server: string; tool: string } | undefined => {
37 const m = name.match(/^mcp__(.+?)__(.+)$/)
38 return m === null ? undefined : { server: m[1]!, tool: m[2]! }
39}
40
41export const sqlArgOf = (
42 input: Record<string, unknown>,
43 names: readonly string[],
44): { arg: string; sql: string } | undefined => {
45 for (const arg of names) {
46 const value = input[arg]
47 if (typeof value === 'string' && value.trim() !== '') return { arg, sql: value }
48 }
49 return undefined
50}
51
52/** Non-SQL arguments worth keeping and reusing for enrichment: warehouse, catalog, schema. */
53export const keptArgs = (input: Record<string, unknown>): Record<string, string | number | boolean> => {
54 const kept: Record<string, string | number | boolean> = {}
55 for (const [key, value] of Object.entries(input)) {
56 if (!/warehouse|catalog|schema/i.test(key)) continue
57 if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') kept[key] = value
58 }
59 return kept
60}
61
62const PY_SCRIPT =
63 /(?:^|[\s;&|(])(?:python(?:3(?:\.\d+)?)?|uv\s+run(?:\s+python3?)?|poetry\s+run\s+python3?|pipenv\s+run\s+python3?)\s+(?:-[A-Za-z]+\s+)*(["']?)([^\s;&|"']+\.py)\1/g
64// The databricks CLI at a command position, not the word in an argument (`grep databricks x`).
65const DBX_CLI = /(?:^|[;&|(]\s*|\n\s*)databricks\s+[a-z]/
66const DBX_IMPORT = /^\s*(?:from|import)\s+databricks\b/m
67// Inline code (`-c '...'`, a heredoc) has its import after a quote or a separator.
68const DBX_IMPORT_INLINE = /(?:^|[\s'";])(?:from|import)\s+databricks\b/
69
70export const scriptPaths = (command: string): string[] => [...command.matchAll(PY_SCRIPT)].map(m => m[2]!)
71
72const isAbsolute = (path: string) => /^(?:\/|~|[A-Za-z]:[\\/])/.test(path)
73
74/** The folder a command's scripts resolve against: its last `cd`, else the session's. */
75export const commandDir = (command: string, cwd: string, home: string | undefined): string => {
76 const dirs = [...command.matchAll(/(?:^|[\s;&|(])cd\s+(["']?)([^\s;&|"')]+)\1/g)]
77 const dir = dirs.at(-1)?.[2]
78 if (dir === undefined) return cwd
79 const expanded = dir.startsWith('~') && home !== undefined ? home + dir.slice(1) : dir
80 return isAbsolute(expanded) ? expanded : `${cwd}/${expanded}`
81}
82
83export const resolvePath = (path: string, dir: string, home: string | undefined): string => {
84 const expanded = path.startsWith('~') && home !== undefined ? home + path.slice(1) : path
85 return isAbsolute(expanded) ? expanded : `${dir}/${expanded}`
86}
87
88/**
89 * What a Bash command is on the Databricks side: `cli` for the databricks CLI, `script` for
90 * Python whose source (a script file, `-c`, a heredoc) imports databricks, else undefined.
91 */
92export const classifyBash = async (
93 command: string,
94 readScript: (path: string) => Promise<string | undefined>,
95): Promise<'script' | 'cli' | undefined> => {
96 if (DBX_CLI.test(command)) return 'cli'
97 if (!/\bpython|\buv\s+run|\bpoetry\s+run|\bpipenv\s+run/.test(command)) return undefined
98 if (DBX_IMPORT_INLINE.test(command)) return 'script'
99 for (const path of scriptPaths(command)) {
100 const source = await readScript(path)
101 if (source !== undefined && DBX_IMPORT.test(source)) return 'script'
102 }
103 return undefined
104}
105
106// --- What a Databricks result says (counts and ids only, never values) ------------------
107
108export type ResultFacts = { statementId?: string; warehouseId?: string; rowCount?: number }
109
110const ID_KEYS = ['statement_id', 'statementId', 'query_id', 'queryId']
111const WAREHOUSE_KEYS = ['warehouse_id', 'warehouseId']
112const COUNT_KEYS = ['row_count', 'rowCount', 'total_row_count', 'totalRowCount', 'num_rows', 'numRows']
113const ROW_ARRAYS = ['data_array', 'dataArray', 'rows', 'data', 'results']
114
115const walk = (value: unknown, facts: ResultFacts, depth: number) => {
116 if (depth > 6 || typeof value !== 'object' || value === null) return
117 if (Array.isArray(value)) {
118 for (const item of value.slice(0, 20)) walk(item, facts, depth + 1)
119 return
120 }
121 const obj = value as Record<string, unknown>
122 for (const key of ID_KEYS) {
123 if (facts.statementId === undefined && typeof obj[key] === 'string') facts.statementId = obj[key]
124 }
125 for (const key of WAREHOUSE_KEYS) {
126 if (facts.warehouseId === undefined && typeof obj[key] === 'string') facts.warehouseId = obj[key]
127 }
128 for (const key of COUNT_KEYS) {
129 const n = Number(obj[key])
130 if (facts.rowCount === undefined && obj[key] !== undefined && Number.isFinite(n)) facts.rowCount = n
131 }
132 if (facts.rowCount === undefined) {
133 for (const key of ROW_ARRAYS) {
134 const rows = obj[key]
135 if (Array.isArray(rows) && rows.every(r => typeof r === 'object' && r !== null)) {
136 facts.rowCount = rows.length
137 break
138 }
139 }
140 }
141 for (const child of Object.values(obj)) walk(child, facts, depth + 1)
142}
143
144/** Ids and a row count from a tool's result text (JSON or prose); the values themselves are never kept. */
145export const resultFacts = (text: string | undefined): ResultFacts => {
146 const facts: ResultFacts = {}
147 if (text === undefined) return facts
148 const trimmed = text.trim()
149 if (/^[[{]/.test(trimmed)) {
150 try {
151 walk(JSON.parse(trimmed), facts, 0)
152 } catch {
153 // Not JSON after all.
154 }
155 }
156 facts.statementId ??= text.match(/statement[_ ]?id["'\s:=]+([0-9a-f]{8}-[0-9a-f-]{20,})/i)?.[1]
157 facts.warehouseId ??= text.match(/warehouse[_ ]?id["'\s:=]+([0-9a-f]{12,})/i)?.[1]
158 return facts
159}
160
161// --- Formatting (from dbt-runs) ------------------------------------------------------------
162
163const pad = (n: number) => String(n).padStart(2, '0')
164
165export const formatTime = (ms: number, now: number): string => {
166 const d = new Date(ms)
167 const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
168 const sameDay = new Date(now).toDateString() === d.toDateString()
169 return sameDay ? time : `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${time}`
170}
171
172export const formatDuration = (ms: number | undefined): string => {
173 if (ms === undefined) return '–'
174 if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`
175 const s = Math.round(ms / 100) / 10
176 if (s < 60) return `${s.toFixed(1)}s`
177 const m = Math.floor(s / 60)
178 if (m < 60) return `${m}m${pad(Math.round(s % 60))}s`
179 return `${Math.floor(m / 60)}h${pad(m % 60)}m`
180}
181
182export const formatBytes = (bytes: number | undefined): string => {
183 if (bytes === undefined) return '–'
184 if (bytes < 1024) return `${bytes} B`
185 if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`
186 if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
187 return `${(bytes / 1024 ** 3).toFixed(2)} GB`
188}
189
190export const truncate = (text: string, width: number): string =>
191 text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`
192hooks/html.ts 425 lines1import type { DbxEnrichment, DbxEvent } from '../types'
2import { rowsFor } from './enrich'
3import { formatBytes, formatDuration } from './events'
4import type { LinkConfig } from './links'
5import { queryLink, tableLink, warehouseLink } from './links'
6import { catalogOf, classify, preview } from './sql'
7
8export type Report = { title: string; path: string; kind?: string }
9
10export type PageInput = {
11 sessionId: string
12 branch?: string
13 cwd: string
14 generatedAt: number
15 events: readonly DbxEvent[]
16 enrichment: DbxEnrichment
17 reports: readonly Report[]
18 links: LinkConfig
19 prod: readonly string[]
20 dev: readonly string[]
21}
22
23export type Kind = 'read' | 'write' | 'prod-write' | 'script' | 'blocked' | 'failed'
24
25/** What an event looks like everywhere: prod writes outrank everything but a block. */
26export const kindOf = (e: DbxEvent, prod: readonly string[]): Kind => {
27 if (e.status === 'blocked') return 'blocked'
28 if (e.tables_written.some(t => classify(t, prod, []) === 'prod')) return 'prod-write'
29 if (e.status === 'failed') return 'failed'
30 if (e.event_type === 'script' || e.event_type === 'cli') return 'script'
31 return e.tables_written.length > 0 ? 'write' : 'read'
32}
33
34const KIND_LABEL: Record<Kind, string> = {
35 read: 'read',
36 write: 'write',
37 'prod-write': 'PROD WRITE',
38 script: 'script',
39 blocked: 'blocked',
40 failed: 'failed',
41}
42
43const esc = (s: string): string =>
44 s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
45
46const a = (href: string | undefined, text: string, cls = ''): string =>
47 href === undefined
48 ? `<span class="${cls}">${esc(text)}</span>`
49 : `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`
50
51// The catalog is the chip's color; the label is schema.table, the full name on hover.
52const tableChip = (input: PageInput, table: string, written: boolean): string => {
53 const cls = classify(table, input.prod, input.dev)
54 const label = table.split('.').length === 3 ? table.slice(table.indexOf('.') + 1) : table
55 const href = tableLink(input.links, table)
56 const attrs = `class="chip ${cls}${written ? ' w' : ''}" title="${esc(table)}"`
57 return href === undefined ? `<span ${attrs}>${esc(label)}</span>` : `<a ${attrs} href="${esc(href)}" target="_blank" rel="noopener">${esc(label)}</a>`
58}
59
60/** Totals the header and the band share. */
61export const totals = (events: readonly DbxEvent[], enrichment: DbxEnrichment, prod: readonly string[]) => {
62 const rows = events.flatMap(e => rowsFor(enrichment, e.id))
63 const sum = (f: (r: (typeof rows)[number]) => number | undefined) =>
64 rows.length === 0 ? undefined : rows.reduce((n, r) => n + (f(r) ?? 0), 0)
65 return {
66 actions: events.length,
67 queries: events.filter(e => e.event_type === 'query' && e.status !== 'blocked').length,
68 statements: rows.length,
69 failed: events.filter(e => e.status === 'failed').length,
70 blocked: events.filter(e => e.status === 'blocked').length,
71 prodWrites: events.filter(e => kindOf(e, prod) === 'prod-write').length,
72 readBytes: sum(r => r.read_bytes),
73 serverMs: sum(r => r.total_duration_ms),
74 }
75}
76
77const timeOf = (iso: string) => {
78 const d = new Date(iso)
79 return d.toLocaleTimeString('en-US', { hour12: false })
80}
81
82// --- Panels ------------------------------------------------------------------------------
83
84const header = (input: PageInput): string => {
85 const t = totals(input.events, input.enrichment, input.prod)
86 const catalogs = [
87 ...new Set(input.events.flatMap(e => [...e.tables_read, ...e.tables_written]).flatMap(n => catalogOf(n) ?? [])),
88 ].sort()
89 const warehouses = [
90 ...new Set([
91 ...input.events.flatMap(e => e.warehouse_id ?? []),
92 ...input.enrichment.rows.flatMap(r => r.warehouse_id ?? []),
93 ]),
94 ]
95 const tile = (label: string, value: string, cls = '') =>
96 `<div class="tile ${cls}"><div class="v">${esc(value)}</div><div class="l">${esc(label)}</div></div>`
97 const e = input.enrichment
98 const enrichLine =
99 e.error !== undefined
100 ? `<p class="note warn">Query history unavailable: ${esc(e.error)}</p>`
101 : e.fetched_at === 0
102 ? `<p class="note">Query history not read yet; server-side numbers fill in at the end of a turn.</p>`
103 : `<p class="note">Query history read ${esc(new Date(e.fetched_at).toLocaleTimeString('en-US', { hour12: false }))} · ${t.statements} statements matched.</p>`
104 return `
105<header>
106 <div class="title">
107 <h1>Databricks trace</h1>
108 <div class="meta">
109 <span>session <code>${esc(input.sessionId)}</code></span>
110 ${input.branch ? `<span>branch <code>${esc(input.branch)}</code></span>` : ''}
111 <span>${esc(input.cwd)}</span>
112 <span>updated ${esc(new Date(input.generatedAt).toLocaleString('en-US', { hour12: false }))}</span>
113 </div>
114 <div class="meta">
115 ${catalogs.map(c => `<span class="chip ${classify(`${c}.x.y`, input.prod, input.dev)}">${esc(c)}</span>`).join('')}
116 ${warehouses.map(w => a(warehouseLink(input.links, w), `warehouse ${w}`, 'chip other')).join('')}
117 </div>
118 </div>
119 <div class="tiles">
120 ${tile('actions', String(t.actions))}
121 ${tile('queries', String(t.queries))}
122 ${tile('failed', String(t.failed), t.failed > 0 ? 'bad' : '')}
123 ${tile('blocked', String(t.blocked), t.blocked > 0 ? 'warnt' : '')}
124 ${tile('prod writes', String(t.prodWrites), t.prodWrites > 0 ? 'prod' : '')}
125 ${tile('bytes read', formatBytes(t.readBytes))}
126 ${tile('server time', formatDuration(t.serverMs))}
127 </div>
128 ${enrichLine}
129</header>`
130}
131
132const timeline = (input: PageInput): string => {
133 if (input.events.length === 0) return ''
134 const times = input.events.map(e => Date.parse(e.ts))
135 const lo = Math.min(...times)
136 const hi = Math.max(...input.events.map(e => Date.parse(e.ended_ts ?? e.ts)))
137 const span = Math.max(1, hi - lo)
138 const x = (ms: number) => 10 + ((ms - lo) / span) * 980
139 const marks = input.events
140 .map(e => {
141 const kind = kindOf(e, input.prod)
142 const x0 = x(Date.parse(e.ts))
143 const x1 = Math.max(x0 + 4, x(Date.parse(e.ended_ts ?? e.ts)))
144 const tip = `${timeOf(e.ts)} ${KIND_LABEL[kind]} ${preview(e.sql_text ?? e.command ?? '', 80)}`
145 return `<a href="#ev-${esc(e.id)}"><rect class="k-${kind}" x="${x0.toFixed(1)}" y="8" width="${(x1 - x0).toFixed(1)}" height="24" rx="2"><title>${esc(tip)}</title></rect></a>`
146 })
147 .join('')
148 return `
149<section>
150 <h2>Timeline</h2>
151 <svg class="timeline" viewBox="0 0 1000 40" preserveAspectRatio="none" role="img" aria-label="Actions over time">
152 <line x1="10" y1="20" x2="990" y2="20" class="axis"/>${marks}
153 </svg>
154 <div class="axis-labels"><span>${esc(timeOf(new Date(lo).toISOString()))}</span><span>${esc(timeOf(new Date(hi).toISOString()))}</span></div>
155 <div class="legend">${(['read', 'write', 'prod-write', 'script', 'failed', 'blocked'] as const)
156 .map(k => `<span><i class="sw k-${k}"></i>${KIND_LABEL[k]}</span>`)
157 .join('')}</div>
158</section>`
159}
160
161const queryTable = (input: PageInput): string => {
162 const catalogs = new Set<string>()
163 const rows = input.events
164 .map((e, i) => {
165 const kind = kindOf(e, input.prod)
166 const history = rowsFor(input.enrichment, e.id)
167 const own = history.length === 1 && history[0]!.how !== 'window' ? history[0] : undefined
168 const tables = [...e.tables_written.map(t => tableChip(input, t, true)), ...e.tables_read.filter(t => !e.tables_written.includes(t)).map(t => tableChip(input, t, false))]
169 const rowCatalogs = [...e.tables_read, ...e.tables_written].flatMap(t => catalogOf(t) ?? [])
170 for (const c of rowCatalogs) catalogs.add(c)
171 const statementId = e.statement_id ?? own?.statement_id
172 const text = e.sql_text ?? e.command ?? ''
173 const inferred =
174 e.event_type === 'query' || history.length === 0
175 ? ''
176 : `<div class="inferred"><div class="sub">${history.length} statement${history.length === 1 ? '' : 's'} inferred from this run's window</div>
177 <table class="mini"><tbody>${history
178 .map(
179 r => `<tr><td>${a(queryLink(input.links, r.statement_id), preview(r.statement_text ?? r.statement_id, 90), 'mono')}
180 ${r.client_application ? `<div class="sub">${esc(r.client_application)}</div>` : ''}</td>
181 <td>${esc(r.execution_status ?? '')}</td><td class="num">${esc(formatDuration(r.total_duration_ms))}</td>
182 <td class="num">${esc(r.produced_rows === undefined ? '–' : String(r.produced_rows))}</td><td class="num">${esc(formatBytes(r.read_bytes))}</td></tr>`,
183 )
184 .join('')}</tbody></table></div>`
185 const serverMs = own?.total_duration_ms ?? (history.length > 0 ? history.reduce((n, r) => n + (r.total_duration_ms ?? 0), 0) : undefined)
186 const rowsOut = own?.produced_rows ?? e.row_count
187 const bytes = history.length > 0 ? history.reduce((n, r) => n + (r.read_bytes ?? 0), 0) : undefined
188 const status = e.status === 'running' ? 'running' : e.status
189 const error = own?.error_message ?? e.error
190 return `
191<tr id="ev-${esc(e.id)}" class="r-${kind}" data-status="${status}" data-kind="${kind}" data-catalogs="${esc(rowCatalogs.join(' '))}"
192 data-i="${i}" data-dur="${e.duration_ms ?? -1}" data-server="${serverMs ?? -1}" data-rows="${rowsOut ?? -1}" data-bytes="${bytes ?? -1}" data-text="${esc(text.toLowerCase())}">
193 <td class="num">${i + 1}</td>
194 <td class="mono">${esc(timeOf(e.ts))}</td>
195 <td><span class="badge k-${kind}">${KIND_LABEL[kind]}</span></td>
196 <td class="status s-${status}">${esc(status)}</td>
197 <td class="sql"><details><summary class="mono">${esc(preview(text, 110) || '(empty)')}</summary><pre>${esc(text)}</pre>
198 ${e.block_reason !== undefined ? `<div class="reason">${esc(e.block_reason)}</div>` : ''}
199 ${error !== undefined && e.status !== 'blocked' ? `<div class="reason">${esc(error)}</div>` : ''}
200 <div class="sub">${esc(e.tool)}</div></details>${inferred}</td>
201 <td class="tables">${tables.join(' ')}</td>
202 <td class="num">${esc(formatDuration(e.duration_ms))}</td>
203 <td class="num">${esc(formatDuration(serverMs))}</td>
204 <td class="num">${esc(rowsOut === undefined ? '–' : rowsOut.toLocaleString('en-US'))}</td>
205 <td class="num">${esc(formatBytes(bytes))}</td>
206 <td>${statementId !== undefined ? a(queryLink(input.links, statementId), 'profile', 'link') : ''}</td>
207</tr>`
208 })
209 .join('')
210 const options = (values: readonly string[]) => values.map(v => `<option value="${esc(v)}">${esc(v)}</option>`).join('')
211 return `
212<section>
213 <h2>Actions</h2>
214 <div class="filters">
215 <input id="f-text" type="search" placeholder="Filter SQL or command" aria-label="Filter text">
216 <select id="f-status" aria-label="Status"><option value="">any status</option>${options(['succeeded', 'failed', 'blocked', 'running'])}</select>
217 <select id="f-kind" aria-label="Kind"><option value="">any kind</option>${options(['read', 'write', 'prod-write', 'script', 'failed', 'blocked'])}</select>
218 <select id="f-catalog" aria-label="Catalog"><option value="">any catalog</option>${options([...catalogs].sort())}</select>
219 <label>min <input id="f-dur" type="number" min="0" step="0.5" value="0" aria-label="Minimum seconds"> s</label>
220 <span id="f-count" class="dim"></span>
221 </div>
222 <div class="scroll">
223 <table id="actions">
224 <thead><tr>
225 <th data-sort="i" class="num">#</th><th data-sort="i">time</th><th data-sort="kind">kind</th><th data-sort="status">status</th>
226 <th>statement</th><th>tables</th><th data-sort="dur" class="num">client</th><th data-sort="server" class="num">server</th>
227 <th data-sort="rows" class="num">rows</th><th data-sort="bytes" class="num">read</th><th></th>
228 </tr></thead>
229 <tbody>${rows || '<tr><td colspan="11" class="empty">No Databricks actions yet.</td></tr>'}</tbody>
230 </table>
231 </div>
232</section>`
233}
234
235const graph = (input: PageInput): string => {
236 // A blocked statement touched nothing: its tables stay off the graph (the guardrail panel has it).
237 const ran = input.events.filter(e => e.status !== 'blocked')
238 const written = new Set(ran.flatMap(e => e.tables_written))
239 const readOnly = [...new Set(ran.flatMap(e => e.tables_read))].filter(t => !written.has(t))
240 const order = (list: string[]) => list.sort((p, q) => (catalogOf(p) ?? '~').localeCompare(catalogOf(q) ?? '~') || p.localeCompare(q))
241 const left = order(readOnly)
242 const right = order([...written])
243 if (left.length === 0 && right.length === 0) return ''
244 const ROW = 34
245 const height = Math.max(left.length, right.length, 1) * ROW + 30
246 const center = height / 2
247 const node = (t: string, x: number, y: number, isWrite: boolean) => {
248 const cls = classify(t, input.prod, input.dev)
249 const label = t.length > 40 ? `…${t.slice(-39)}` : t
250 const href = tableLink(input.links, t)
251 const body = `<rect class="node ${cls}${isWrite ? ' w' : ''}" x="${x}" y="${y - 12}" width="300" height="24" rx="4"/><text x="${x + 10}" y="${y + 4}">${esc(label)}</text><title>${esc(t)}</title>`
252 return href === undefined ? `<g>${body}</g>` : `<a href="${esc(href)}" target="_blank" rel="noopener">${body}</a>`
253 }
254 const y = (i: number, n: number) => center - ((n - 1) * ROW) / 2 + i * ROW
255 const edges = [
256 ...left.map((t, i) => `<path class="edge read" d="M310 ${y(i, left.length)} C 420 ${y(i, left.length)}, 420 ${center}, 480 ${center}"/>`),
257 ...right.map(
258 (t, i) =>
259 `<path class="edge ${classify(t, input.prod, input.dev)} w" d="M600 ${center} C 660 ${center}, 660 ${y(i, right.length)}, 770 ${y(i, right.length)}" marker-end="url(#arrow-${classify(t, input.prod, input.dev)})"/>`,
260 ),
261 ].join('')
262 const markers = (['prod', 'dev', 'other'] as const)
263 .map(c => `<marker id="arrow-${c}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10 z" class="ah ${c}"/></marker>`)
264 .join('')
265 return `
266<section>
267 <h2>Tables touched</h2>
268 <div class="scroll">
269 <svg class="graph" viewBox="0 0 1080 ${height}" width="1080" height="${height}" role="img" aria-label="Tables read and written">
270 <defs>${markers}</defs>${edges}
271 ${left.map((t, i) => node(t, 10, y(i, left.length), false)).join('')}
272 <rect class="session" x="480" y="${center - 16}" width="120" height="32" rx="16"/><text class="session-t" x="540" y="${center + 5}" text-anchor="middle">this session</text>
273 ${right.map((t, i) => node(t, 770, y(i, right.length), true)).join('')}
274 ${left.length > 0 ? `<text class="col" x="10" y="14">read</text>` : ''}${right.length > 0 ? `<text class="col" x="770" y="14">written</text>` : ''}
275 </svg>
276 </div>
277 <div class="legend"><span><i class="sw node-prod"></i>${esc(input.prod.join(', ') || 'prod')}</span><span><i class="sw node-dev"></i>${esc(input.dev.join(', ') || 'dev')}</span><span><i class="sw node-other"></i>other</span></div>
278</section>`
279}
280
281const guardrails = (input: PageInput): string => {
282 const blocked = input.events.filter(e => e.status === 'blocked')
283 const prodWrites = input.events.filter(e => e.status !== 'blocked' && kindOf(e, input.prod) === 'prod-write')
284 if (blocked.length === 0 && prodWrites.length === 0) {
285 return `<section><h2>Guardrails</h2><p class="note">Nothing blocked, no writes to ${esc(input.prod.join(', ') || 'prod')}.</p></section>`
286 }
287 const after = (e: DbxEvent) => input.events.slice(input.events.indexOf(e) + 1)[0]
288 const item = (e: DbxEvent, title: string, cls: string) => {
289 const next = after(e)
290 return `<li class="${cls}"><div class="gtitle">${esc(title)} · <a href="#ev-${esc(e.id)}">${esc(timeOf(e.ts))}</a></div>
291 <pre>${esc(e.sql_text ?? e.command ?? '')}</pre>
292 ${e.block_reason !== undefined ? `<div class="reason">${esc(e.block_reason)}</div>` : ''}
293 <div class="sub">Next: ${next === undefined ? 'nothing on Databricks' : `<a href="#ev-${esc(next.id)}">${esc(KIND_LABEL[kindOf(next, input.prod)])} · ${esc(preview(next.sql_text ?? next.command ?? '', 90))}</a>`}</div></li>`
294 }
295 return `
296<section>
297 <h2>Guardrails</h2>
298 <ul class="guard">${[...blocked.map(e => item(e, 'Blocked', 'blocked')), ...prodWrites.map(e => item(e, `Write to ${e.tables_written.filter(t => classify(t, input.prod, []) === 'prod').join(', ')}`, 'prod'))].join('')}</ul>
299</section>`
300}
301
302const reports = (input: PageInput): string => `
303<section>
304 <h2>Reports</h2>
305 ${
306 input.reports.length === 0
307 ? '<p class="note">No reports for this session yet.</p>'
308 : `<ul class="reports">${input.reports.map(r => `<li>${a(r.path, r.title, 'link')}${r.kind ? ` <span class="dim">${esc(r.kind)}</span>` : ''}</li>`).join('')}</ul>`
309 }
310</section>`
311
312// --- The page ------------------------------------------------------------------------------
313
314const CSS = `
315:root{--bg:#fafaf9;--panel:#fff;--ink:#1c1917;--dim:#78716c;--line:#e7e5e4;--accent:#2563eb;
316--read:#a8a29e;--write:#16a34a;--prod:#dc2626;--prod-bg:#fef2f2;--script:#2563eb;--blocked:#d97706;--failed:#7c3aed;--code:#f5f5f4}
317@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#0c0a09;--panel:#1c1917;--ink:#f5f5f4;--dim:#a8a29e;--line:#292524;--accent:#60a5fa;
318--read:#78716c;--write:#22c55e;--prod:#f87171;--prod-bg:#3b0d0d;--script:#60a5fa;--blocked:#fbbf24;--failed:#a78bfa;--code:#292524}}
319:root[data-theme="dark"]{--bg:#0c0a09;--panel:#1c1917;--ink:#f5f5f4;--dim:#a8a29e;--line:#292524;--accent:#60a5fa;
320--read:#78716c;--write:#22c55e;--prod:#f87171;--prod-bg:#3b0d0d;--script:#60a5fa;--blocked:#fbbf24;--failed:#a78bfa;--code:#292524}
321*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
322main{max-width:1440px;margin:0 auto;padding:24px 16px 64px}
323header .meta,ul.guard,.reason,.note{overflow-wrap:anywhere}section,header{min-width:0}
324h1{font-size:22px;margin:0 0 6px}h2{font-size:15px;margin:0 0 12px;letter-spacing:.02em;text-transform:uppercase;color:var(--dim)}
325section,header{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px;margin-bottom:16px}
326code,.mono,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px}
327pre{background:var(--code);padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-word;margin:8px 0}
328a{color:var(--accent)}.dim,.sub{color:var(--dim)}.sub{font-size:12px}
329.meta{display:flex;flex-wrap:wrap;gap:6px 14px;color:var(--dim);margin-bottom:6px}
330.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px;margin:12px 0 4px}
331.tile{border:1px solid var(--line);border-radius:8px;padding:8px 10px}.tile .v{font-size:20px;font-weight:600}.tile .l{color:var(--dim);font-size:12px}
332.tile.bad .v{color:var(--failed)}.tile.warnt .v{color:var(--blocked)}.tile.prod{background:var(--prod-bg);border-color:var(--prod)}.tile.prod .v{color:var(--prod)}
333.note{color:var(--dim);margin:8px 0 0}.note.warn{color:var(--blocked)}
334.chip{display:inline-block;border:1px solid var(--line);border-radius:999px;padding:0 8px;font:12px ui-monospace,Menlo,monospace;margin:1px 0;text-decoration:none;color:var(--ink)}
335.chip.prod{border-color:var(--prod);color:var(--prod)}.chip.prod.w{background:var(--prod);color:#fff;font-weight:600}
336.chip.dev{border-color:var(--write);color:var(--write)}.chip.dev.w{background:var(--write);color:#fff}
337.chip.other.w{border-color:var(--ink)}
338.timeline{width:100%;height:40px;display:block}.axis{stroke:var(--line);stroke-width:1}
339.axis-labels{display:flex;justify-content:space-between;color:var(--dim);font-size:12px}
340.legend{display:flex;flex-wrap:wrap;gap:12px;color:var(--dim);font-size:12px;margin-top:8px}.sw{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:-1px}
341.k-read{fill:var(--read);background:var(--read)}.k-write{fill:var(--write);background:var(--write)}.k-prod-write{fill:var(--prod);background:var(--prod)}
342.k-script{fill:var(--script);background:var(--script)}.k-blocked{fill:var(--blocked);background:var(--blocked)}.k-failed{fill:var(--failed);background:var(--failed)}
343.badge{color:#fff;border-radius:4px;padding:1px 6px;font-size:11.5px;font-weight:600;white-space:nowrap}.badge.k-read{color:var(--panel)}
344.filters{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:10px}
345.filters input,.filters select{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font:inherit}
346.filters input[type=number]{width:64px}.filters input[type=search]{min-width:220px;flex:1}
347.scroll{overflow-x:auto}table{border-collapse:collapse;width:100%}
348th,td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
349th{font-size:12px;color:var(--dim);font-weight:600;white-space:nowrap;position:sticky;top:0;background:var(--panel)}
350th[data-sort]{cursor:pointer}th[data-sort]:hover{color:var(--ink)}th.asc::after{content:" ▲"}th.desc::after{content:" ▼"}
351td.num,th.num{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}
352td.sql{min-width:260px;max-width:420px}td.sql summary{cursor:pointer;word-break:break-word}
353td.tables{min-width:180px}td.tables .chip{white-space:nowrap}
354tr.r-prod-write{background:var(--prod-bg)}tr.r-prod-write td:first-child{box-shadow:inset 3px 0 var(--prod)}
355tr.r-blocked td:first-child{box-shadow:inset 3px 0 var(--blocked)}
356.s-failed{color:var(--failed);font-weight:600}.s-blocked{color:var(--blocked);font-weight:600}.s-running{color:var(--script)}
357.reason{color:var(--blocked);font-size:12.5px;margin:4px 0}
358.inferred{margin-top:6px}.mini td{padding:3px 6px;font-size:12px;border-bottom:1px dashed var(--line)}
359.empty{color:var(--dim);text-align:center;padding:24px}
360.graph text{font:12px ui-monospace,Menlo,monospace;fill:var(--ink)}.graph .col{fill:var(--dim);font:600 11px system-ui,sans-serif;text-transform:uppercase}
361.node{fill:var(--panel);stroke:var(--read);stroke-width:1.5}.node.dev{stroke:var(--write)}.node.prod{stroke:var(--prod)}
362.node.prod.w{fill:var(--prod-bg);stroke-width:2.5}
363.edge{fill:none;stroke-width:1.5}.edge.read{stroke:var(--read)}.edge.dev{stroke:var(--write)}.edge.prod{stroke:var(--prod);stroke-width:2.5}.edge.other{stroke:var(--ink)}
364.ah.dev{fill:var(--write)}.ah.prod{fill:var(--prod)}.ah.other{fill:var(--ink)}
365.session{fill:var(--accent)}.graph .session-t{fill:#fff;font:600 12px system-ui,sans-serif}
366.node-prod{background:var(--prod)}.node-dev{background:var(--write)}.node-other{background:var(--read)}
367ul.guard,ul.reports{list-style:none;margin:0;padding:0}ul.guard li{border-left:3px solid var(--blocked);padding:4px 0 8px 12px;margin-bottom:10px}
368ul.guard li.prod{border-color:var(--prod)}.gtitle{font-weight:600}
369@media (max-width:640px){main{padding:16px 16px 48px}td.sql{min-width:240px}}
370`
371
372const SCRIPT = `
373(() => {
374 const tbody = document.querySelector('#actions tbody'); if (!tbody) return;
375 const rows = [...tbody.querySelectorAll('tr[data-i]')];
376 const $ = id => document.getElementById(id);
377 const apply = () => {
378 const text = $('f-text').value.toLowerCase(), status = $('f-status').value, kind = $('f-kind').value,
379 cat = $('f-catalog').value, min = parseFloat($('f-dur').value || '0') * 1000;
380 let shown = 0;
381 for (const r of rows) {
382 const ok = (!text || r.dataset.text.includes(text)) && (!status || r.dataset.status === status) &&
383 (!kind || r.dataset.kind === kind) && (!cat || r.dataset.catalogs.split(' ').includes(cat)) &&
384 (!min || Math.max(+r.dataset.dur, +r.dataset.server) >= min);
385 r.hidden = !ok; if (ok) shown++;
386 }
387 $('f-count').textContent = shown + ' of ' + rows.length;
388 };
389 ['f-text','f-status','f-kind','f-catalog','f-dur'].forEach(id => $(id).addEventListener('input', apply));
390 let key = 'i', dir = 1;
391 document.querySelectorAll('th[data-sort]').forEach(th => th.addEventListener('click', () => {
392 const k = th.dataset.sort; dir = k === key ? -dir : (k === 'i' ? 1 : -1); key = k;
393 document.querySelectorAll('th').forEach(h => h.classList.remove('asc','desc'));
394 th.classList.add(dir > 0 ? 'asc' : 'desc');
395 const val = r => ['kind','status'].includes(k) ? r.dataset[k] : +r.dataset[k];
396 rows.sort((a, b) => { const x = val(a), y = val(b); return (x < y ? -1 : x > y ? 1 : 0) * dir; });
397 rows.forEach(r => tbody.appendChild(r));
398 }));
399 apply();
400})();
401`
402
403/** The whole page: one file, no external assets, so it opens offline and attaches to a PR. */
404export const renderPage = (input: PageInput): string => `<!doctype html>
405<html lang="en">
406<head>
407<meta charset="utf-8">
408<meta name="viewport" content="width=device-width, initial-scale=1">
409<title>Databricks trace</title>
410<style>${CSS}</style>
411</head>
412<body>
413<main>
414${header(input)}
415${timeline(input)}
416${guardrails(input)}
417${queryTable(input)}
418${graph(input)}
419${reports(input)}
420</main>
421<script>${SCRIPT}</script>
422</body>
423</html>
424`
425hooks/links.ts 36 lines1// Every Databricks deep link comes from here, so a URL pattern is fixed in one place.
2// Patterns to verify against the workspace are listed in the README.
3
4export type LinkConfig = { host: string; workspaceId: string }
5
6const withWorkspace = (url: string, cfg: LinkConfig): string =>
7 cfg.workspaceId === '' ? url : `${url}${url.includes('?') ? '&' : '?'}o=${encodeURIComponent(cfg.workspaceId)}`
8
9const base = (cfg: LinkConfig): string | undefined => {
10 const host = cfg.host.trim().replace(/\/+$/, '')
11 if (host === '') return undefined
12 return /^https?:\/\//.test(host) ? host : `https://${host}`
13}
14
15const make = (cfg: LinkConfig, path: string): string | undefined => {
16 const root = base(cfg)
17 return root === undefined ? undefined : withWorkspace(`${root}${path}`, cfg)
18}
19
20/** Catalog Explorer for a fully qualified table; undefined for a name without catalog and schema. */
21export const tableLink = (cfg: LinkConfig, table: string): string | undefined => {
22 const parts = table.split('.')
23 if (parts.length !== 3) return undefined
24 return make(cfg, `/explore/data/${parts.map(encodeURIComponent).join('/')}`)
25}
26
27/** Query history, opened on the statement (its profile is a click from there). */
28export const queryLink = (cfg: LinkConfig, statementId: string): string | undefined =>
29 make(cfg, `/sql/history?queryId=${encodeURIComponent(statementId)}`)
30
31export const warehouseLink = (cfg: LinkConfig, warehouseId: string): string | undefined =>
32 make(cfg, `/sql/warehouses/${encodeURIComponent(warehouseId)}`)
33
34export const jobRunLink = (cfg: LinkConfig, jobId: string, runId: string): string | undefined =>
35 make(cfg, `/jobs/${encodeURIComponent(jobId)}/runs/${encodeURIComponent(runId)}`)
36hooks/pane.ts 108 lines1// Pure pieces of the in-Claude-Code panel: the timeline (terminal cells or desktop SVG) and
2// the tables-touched groups. Drawing with $ stays in register.tsx.
3import type { DbxEvent } from '../types'
4import type { Kind } from './html'
5import { kindOf } from './html'
6import { catalogOf, classify } from './sql'
7import type { CatalogClass } from './sql'
8
9export const KIND_RGB: Record<Kind, number> = {
10 read: 0x9ca3af,
11 write: 0x22c55e,
12 'prod-write': 0xef4444,
13 script: 0x60a5fa,
14 failed: 0xa78bfa,
15 blocked: 0xf59e0b,
16}
17const AXIS_RGB = 0x57534e
18const DEFAULT_COLOR = 0x01000000
19
20/** Theme color names for Text, per kind. */
21export const KIND_COLOR: Record<Kind, string> = {
22 read: 'inactive',
23 write: 'success',
24 'prod-write': 'error',
25 script: 'suggestion',
26 failed: 'error',
27 blocked: 'warning',
28}
29
30type Span = { kind: Kind; from: number; to: number }
31
32const spans = (events: readonly DbxEvent[], prod: readonly string[], width: number): Span[] => {
33 if (events.length === 0 || width < 2) return []
34 const lo = Math.min(...events.map(e => Date.parse(e.ts)))
35 const hi = Math.max(...events.map(e => Date.parse(e.ended_ts ?? e.ts)))
36 const span = Math.max(1, hi - lo)
37 const at = (ms: number) => Math.min(width - 1, Math.max(0, Math.round(((ms - lo) / span) * (width - 1))))
38 return events.map(e => {
39 const from = at(Date.parse(e.ts))
40 return { kind: kindOf(e, prod), from, to: Math.max(from, at(Date.parse(e.ended_ts ?? e.ts))) }
41 })
42}
43
44// Later kinds paint over earlier ones where cells collide: a block or prod write is never hidden.
45const PAINT_ORDER: Kind[] = ['read', 'script', 'write', 'failed', 'blocked', 'prod-write']
46
47/** One row of terminal cells: an axis with each action painted where it ran. */
48export const timelineCells = (events: readonly DbxEvent[], prod: readonly string[], columns: number): string => {
49 const width = Math.max(1, Math.min(512, columns))
50 const words = new Uint32Array(width * 3)
51 for (let i = 0; i < width; i++) words.set([0x2500, AXIS_RGB, DEFAULT_COLOR], i * 3)
52 const ordered = [...spans(events, prod, width)].sort((a, b) => PAINT_ORDER.indexOf(a.kind) - PAINT_ORDER.indexOf(b.kind))
53 for (const s of ordered) for (let i = s.from; i <= s.to; i++) words.set([0x2588, KIND_RGB[s.kind], DEFAULT_COLOR], i * 3)
54 return toBase64(new Uint8Array(words.buffer))
55}
56
57const hex = (rgb: number) => `#${rgb.toString(16).padStart(6, '0')}`
58
59/** The same timeline as SVG, for surfaces that draw SVG (the desktop app). */
60export const timelineSvg = (events: readonly DbxEvent[], prod: readonly string[], width: number): string => {
61 const w = Math.max(100, Math.round(width))
62 const marks = spans(events, prod, w - 8)
63 .map(s => `<rect x="${s.from + 4}" y="4" width="${Math.max(3, s.to - s.from)}" height="16" rx="2" fill="${hex(KIND_RGB[s.kind])}"/>`)
64 .join('')
65 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} 24" width="${w}" height="24"><line x1="4" y1="12" x2="${w - 4}" y2="12" stroke="${hex(AXIS_RGB)}" stroke-width="1"/>${marks}</svg>`
66}
67
68export type TableUse = { name: string; reads: number; writes: number }
69export type CatalogGroup = { catalog: string; cls: CatalogClass; tables: TableUse[] }
70
71/** Tables the session read or wrote (blocked statements left out), grouped by catalog, prod first. */
72export const tableGroups = (events: readonly DbxEvent[], prod: readonly string[], dev: readonly string[]): CatalogGroup[] => {
73 const uses = new Map<string, TableUse>()
74 const use = (name: string) => uses.get(name) ?? uses.set(name, { name, reads: 0, writes: 0 }).get(name)!
75 for (const e of events) {
76 if (e.status === 'blocked') continue
77 for (const t of e.tables_read) use(t).reads++
78 for (const t of e.tables_written) use(t).writes++
79 }
80 const groups = new Map<string, CatalogGroup>()
81 for (const u of uses.values()) {
82 const catalog = catalogOf(u.name) ?? '(unqualified)'
83 const group = groups.get(catalog) ?? groups.set(catalog, { catalog, cls: classify(u.name, prod, dev), tables: [] }).get(catalog)!
84 group.tables.push(u)
85 }
86 const rank: Record<CatalogClass, number> = { prod: 0, dev: 1, other: 2 }
87 return [...groups.values()]
88 .sort((a, b) => rank[a.cls] - rank[b.cls] || a.catalog.localeCompare(b.catalog))
89 .map(g => ({ ...g, tables: g.tables.sort((a, b) => b.writes - a.writes || a.name.localeCompare(b.name)) }))
90}
91
92const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
93
94/** Standard padded base64; written out so it does not lean on a runtime's Uint8Array.toBase64. */
95export const toBase64 = (bytes: Uint8Array): string => {
96 let out = ''
97 for (let i = 0; i < bytes.length; i += 3) {
98 const a = bytes[i]!
99 const b = bytes[i + 1]
100 const c = bytes[i + 2]
101 out += B64[a >> 2]
102 out += B64[((a & 3) << 4) | ((b ?? 0) >> 4)]
103 out += b === undefined ? '=' : B64[((b & 15) << 2) | ((c ?? 0) >> 6)]
104 out += c === undefined ? '=' : B64[c & 63]
105 }
106 return out
107}
108hooks/sql.ts 112 lines1// SQL text helpers: table extraction for the tables-touched graph, catalog classes, previews.
2// A regex reader, not a parser: good for the statements Claude writes, best effort beyond.
3
4export type CatalogClass = 'prod' | 'dev' | 'other'
5
6export const splitList = (value: string): string[] =>
7 value
8 .split(',')
9 .map(s => s.trim().toLowerCase())
10 .filter(s => s.length > 0)
11
12/** Blanks out comments and string literals so keywords inside them are not read as SQL. */
13export const stripNoise = (sql: string): string =>
14 sql
15 .replace(/\/\*[\s\S]*?\*\//g, ' ')
16 .replace(/--[^\n]*/g, ' ')
17 .replace(/'(?:[^'\\]|\\.|'')*'/g, "''")
18 .replace(/"(?:[^"\\]|\\.)*"/g, '""')
19
20// A dotted name of 1-3 parts, each bare or backticked.
21const PART = '(?:`[^`]+`|[A-Za-z_][\\w$]*)'
22const NAME = `(${PART}(?:\\s*\\.\\s*${PART}){0,2})`
23
24const normalizeName = (raw: string): string =>
25 raw
26 .split('.')
27 .map(p => p.trim().replace(/^`|`$/g, ''))
28 .join('.')
29 .toLowerCase()
30
31const READ = new RegExp(`\\b(?:from|join|using)\\s+${NAME}`, 'gi')
32const WRITE = [
33 new RegExp(`\\binsert\\s+(?:into|overwrite)\\s+(?:table\\s+)?${NAME}`, 'gi'),
34 new RegExp(`\\bmerge\\s+into\\s+${NAME}`, 'gi'),
35 new RegExp(`\\bupdate\\s+${NAME}\\s+set\\b`, 'gi'),
36 new RegExp(`\\bdelete\\s+from\\s+${NAME}`, 'gi'),
37 new RegExp(
38 `\\bcreate\\s+(?:or\\s+replace\\s+)?(?:temp(?:orary)?\\s+)?(?:streaming\\s+)?(?:table|view|materialized\\s+view)\\s+(?:if\\s+not\\s+exists\\s+)?${NAME}`,
39 'gi',
40 ),
41 new RegExp(`\\bcopy\\s+into\\s+${NAME}`, 'gi'),
42 new RegExp(`\\btruncate\\s+table\\s+${NAME}`, 'gi'),
43 new RegExp(`\\balter\\s+(?:table|view)\\s+${NAME}`, 'gi'),
44 new RegExp(`\\bdrop\\s+(?:table|view|materialized\\s+view)\\s+(?:if\\s+exists\\s+)?${NAME}`, 'gi'),
45 new RegExp(`\\boptimize\\s+${NAME}`, 'gi'),
46]
47// Words a FROM/JOIN can be followed by that are not tables.
48const NOT_TABLES = new Set(['select', 'lateral', 'values', 'unnest', 'explode', 'json_table', 'read_files', 'range'])
49
50const cteNames = (sql: string): Set<string> => {
51 const names = new Set<string>()
52 // A WITH can open the statement or follow CREATE ... AS / INSERT ...; each CTE is `name AS (`.
53 if (!/\bwith\b/i.test(sql)) return names
54 for (const m of sql.matchAll(/(?:\bwith|,)\s*(?:recursive\s+)?(`[^`]+`|[A-Za-z_]\w*)\s*(?:\([^)]*\))?\s+as\s*\(/gi)) {
55 names.add(normalizeName(m[1]!))
56 }
57 return names
58}
59
60export type Tables = { read: string[]; written: string[] }
61
62/** Tables a statement (or a script of statements) reads and writes, lowercased, CTEs left out. */
63export const extractTables = (sql: string): Tables => {
64 const clean = stripNoise(sql)
65 const ctes = cteNames(clean)
66 const written = new Set<string>()
67 for (const re of WRITE) for (const m of clean.matchAll(re)) written.add(normalizeName(m[1]!))
68 const read = new Set<string>()
69 // `DELETE FROM t` writes t; drop its FROM so the read scan does not count t as read.
70 for (const m of clean.replace(/\bdelete\s+from\b/gi, 'delete ').matchAll(READ)) {
71 const name = normalizeName(m[1]!)
72 if (!NOT_TABLES.has(name) && !ctes.has(name)) read.add(name)
73 }
74 for (const name of ctes) written.delete(name)
75 return { read: [...read].sort(), written: [...written].sort() }
76}
77
78export const catalogOf = (table: string): string | undefined => {
79 const parts = table.split('.')
80 return parts.length === 3 ? parts[0] : undefined
81}
82
83export const classify = (table: string, prod: readonly string[], dev: readonly string[]): CatalogClass => {
84 const catalog = catalogOf(table)
85 if (catalog !== undefined && prod.includes(catalog)) return 'prod'
86 if (catalog !== undefined && dev.includes(catalog)) return 'dev'
87 return 'other'
88}
89
90/** One-line preview: comments dropped, whitespace collapsed, cut to `width`. */
91export const preview = (sql: string, width: number): string => {
92 const flat = sql
93 .replace(/\/\*[\s\S]*?\*\//g, ' ')
94 .replace(/--[^\n]*/g, ' ')
95 .replace(/\s+/g, ' ')
96 .trim()
97 return flat.length <= width ? flat : `${flat.slice(0, Math.max(1, width - 1))}…`
98}
99
100/** Text compared across client and server: comments kept out, whitespace collapsed, case folded, trailing `;` dropped. */
101export const normalizeSql = (sql: string): string =>
102 sql
103 .replace(/\/\*[\s\S]*?\*\//g, ' ')
104 .replace(/--[^\n]*/g, ' ')
105 .replace(/\s+/g, ' ')
106 .replace(/;\s*$/, '')
107 .trim()
108 .toLowerCase()
109
110/** A SQL string literal. */
111export const sqlString = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
112types/index.d.ts 82 lines1/** query: SQL through the Databricks MCP; script: a Python script using databricks-*; cli: the databricks CLI. */
2export type DbxEventType = 'query' | 'script' | 'cli' | 'blocked'
3
4export type DbxStatus = 'running' | 'succeeded' | 'failed' | 'blocked'
5
6/**
7 * One Databricks action. The JSONL file is append-only: a call writes a `running` line
8 * when it starts and its settled line when it ends; the last line per `id` wins.
9 * Metadata only: SQL text and counts, never result rows or cell values.
10 */
11export type DbxEvent = {
12 id: string
13 ts: string
14 ended_ts?: string
15 session_id: string
16 event_type: DbxEventType
17 tool: string
18 sql_text?: string
19 command?: string
20 statement_id?: string
21 warehouse_id?: string
22 status: DbxStatus
23 duration_ms?: number
24 row_count?: number
25 tables_read: string[]
26 tables_written: string[]
27 block_reason?: string
28 /** First line of the tool's error text, for a failed call. */
29 error?: string
30 /** A Bash call sent to the background: its window ends when history says so, capped. */
31 background?: boolean
32 /** The argument the SQL rode in, and the non-SQL scalar arguments worth reusing (warehouse, catalog). */
33 sql_arg?: string
34 call_args?: Record<string, string | number | boolean>
35}
36
37/** One system.query.history row, as read; statement text is SQL, never results. */
38export type DbxHistoryRow = {
39 statement_id: string
40 execution_status?: string
41 total_duration_ms?: number
42 read_bytes?: number
43 produced_rows?: number
44 warehouse_id?: string
45 error_message?: string
46 /** Epoch ms (unix_millis), so matching never depends on the warehouse's time zone. */
47 start_ms?: number
48 end_ms?: number
49 statement_text?: string
50 client_application?: string
51}
52
53/** How a history row ties to an event: by statement id, by identical text in its window, or inferred from a script's window. */
54export type DbxMatch = 'statement_id' | 'text' | 'window'
55
56export type DbxEnrichment = {
57 /** Epoch ms of the last successful read; 0 before any. */
58 fetched_at: number
59 rows: DbxHistoryRow[]
60 /** Event id -> matched rows. */
61 matches: Record<string, { row: string; how: DbxMatch }[]>
62 error?: string
63}
64
65/** What the panel shows: the whole trace, or one action opened from it. */
66export type DbxPaneView = { kind: 'list' } | { kind: 'detail'; id: string }
67
68declare module 'claude-code' {
69 interface PluginState {
70 'dbx-trace': {
71 events: DbxEvent[]
72 enrichment: DbxEnrichment
73 isShown: boolean
74 /** Where this session's page was last written; empty before the first. */
75 pagePath: string
76 paneView: DbxPaneView
77 /** Whether the panel is drawn; the band steps aside while it is. */
78 isPaneOpen: boolean
79 }
80 }
81}
82