SLOPSHOPPER

dbx-trace

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

newpanebandguardcommandprocess
v0.1.0no licenseupdated 2026-10-07mattromano/claude-modz/dbx-trace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dbx-trace
│ ┃ Databricks trace ✕ › fix the failing auth test and add an audit log call │ ┃ Databricks trace │ ┃ No Databricks actions in this session yet. ⏺ Read(src/auth.ts) │ ┃ They appear here as Claude runs queries and ⎿ Read 6 lines │ ┃ scripts. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dbx-trace │ ⎿ dbx-trace: Databricks trace panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Databricks trace
Databricks trace No Databricks actions in this session yet. They appear here as Claude runs queries and scripts.
README

dbx-trace

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.

  • Panel (/dbx-trace): opens itself on a session's first Databricks action. It shows:
  • session totals and query-history status
  • a timeline (coloured cells in the terminal, a drawn chart in the desktop app)
  • guardrails: every block and every prod write
  • every action, newest first, with client time, server time and bytes read
  • tables touched, grouped by catalog

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.

  • Band above the prompt: a compact view of the newest actions for when the panel is closed or the terminal is too narrow for it. It steps aside while the panel is up. Writes to rcm_prod are red, writes to rcm_dev green, and blocks amber.
  • Trace page (/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.
  • Deep links: each query links to its query-history profile and each table to Catalog Explorer. Claude also receives the links with each query result, so its replies can quote them.
  • Server-side numbers: at the end of each turn the mod reads 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.

Use

/dbx-traceOpen the panel
/dbx-trace pageWrite the HTML page and open it in the browser
/dbx-trace refreshRead query history now and update the panel and page
/dbx-trace pathPrint where the trace and page are
/dbx-trace hideClose the panel and hide the band (/dbx-trace brings them back)
/dbx-trace clearEmpty this session's trace
In the panelctrl+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 bandEnter 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.

What is recorded

CapturedHow it is detectedHow it is tied to query history
query: SQL through the Databricks MCPTool name matches mcp_tool_pattern and carries SQL in one of sql_arg_namesBy statement_id when the MCP returns one, otherwise by identical statement text within ±2 min
script: Python through BashA python / uv run / poetry run script whose source imports databricks, or inline -c code that doesEvery statement you ran during the script's run (±5 s), marked as inferred, with client_application shown
cli: the databricks CLIdatabricks <subcommand> at a command positionSame window match as scripts
blockedYour whitelist PreToolUse hook (or any plugin) denied the callNot 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.

Reports (Phase 2 hook-in)

A report generator registers its pages in <session>.reports.json as [{ "title": "...", "path": "...", "kind": "profile" }]. The trace page lists them under Reports.

Configure

Set the options in /config (or under pluginConfigs["dbx-trace"].options in settings.json):

OptionDefault
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_namesstatement,query,sql,sql_queryArgument 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_catalogsrcm_prod / rcm_devCatalogs drawn red and green
enrichtrueRead 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.

Checks on the work machine (Gates 1 and 2)

These were built against the test kit and could not be verified against a live workspace:

  • ☐ Tool names: run /mcp and confirm the Databricks SQL tool matches mcp_tool_pattern and its SQL argument is listed in sql_arg_names.
  • ☐ Statement id: does the MCP response include statement_id? (Check statement_id in the .jsonl after one query.) If not, matching falls back to statement text, which still works.
  • ☐ Async results: if the MCP answers PENDING and needs a second poll tool, the row count may be missing from the trace (the history read still fills it in).
  • ☐ System tables: 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.
  • ☐ Columns: the history read uses 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.
  • ☐ Latency: how long until a statement appears in history? Unmatched actions are retried at each turn end for 60 minutes (history ran ~15 min behind on the work workspace); /dbx-trace refresh forces a read.
  • ☐ Deep links on the Azure workspace: Catalog Explorer /explore/data/<c>/<s>/<t>, query history /sql/history?queryId=<id>, warehouse /sql/warehouses/<id>. The patterns live in hooks/links.ts.

How it works

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.

Develop

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 session
Source 8 files
hooks/register.tsx 857 lines
1import { 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}
857
hooks/enrich.ts 214 lines
1import 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}
214
hooks/events.ts 192 lines
1import 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))}…`
192
hooks/html.ts 425 lines
1import 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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`
425
hooks/links.ts 36 lines
1// 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)}`)
36
hooks/pane.ts 108 lines
1// 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}
108
hooks/sql.ts 112 lines
1// 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, "\\'")}'`
112
types/index.d.ts 82 lines
1/** 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