LCOV - code coverage report
Current view: top level - dashql-app/src/agent - agent_commands.ts Coverage Total Hit
Test: DashQL d805a4b Lines: 97.3 % 412 401
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import type * as arrow from 'apache-arrow';
       2              : import * as core from '@ankoh/dashql-core';
       3              : 
       4              : import type { LoggerLike, Logger } from '../platform/logger/logger.js';
       5              : import { stringifyError } from '../platform/logger/logger.js';
       6              : import type { ClientFunctionSpec } from '../connections/clfn/clfn_spec.js';
       7              : import { connectorOfDetails } from '../connections/connector_descriptor.js';
       8              : import type { AgentCommandParams, AgentCommandResult } from './agent_protocol.js';
       9              : import { AgentCommandError } from './agent_client.js';
      10              : import type { ConnectionRegistry } from '../connections/connection_registry.js';
      11              : import { ConnectionHealth, connectionCachesQueryResults, type ConnectionState } from '../connections/connection_state.js';
      12              : import { isHyperWasmConnection } from '../connections/connection_label.js';
      13              : import { findEmbeddedConnection } from '../connections/embedded_connection.js';
      14              : import type { QueryExecutor } from '../connections/query_executor.js';
      15              : import {
      16              :     CREATE_SCRIPT,
      17              :     RENAME_SCRIPT,
      18              :     SET_SCRIPT_CONNECTION,
      19              :     SET_SCRIPT_TEXT,
      20              :     getSelectedScriptRef,
      21              :     newScriptConnection,
      22              :     persistedConnectionId,
      23              :     scriptConnectionOf,
      24              :     sessionConnectionIds,
      25              :     type ScriptConnection,
      26              :     type ScriptData,
      27              :     type SessionScripts,
      28              :     type SessionScriptsAction,
      29              : } from '../session/scripts/session_scripts.js';
      30              : import { ensureSessionScriptAnalyzed, type ModifySessionScripts } from '../session/scripts/session_scripts_registry.js';
      31              : import { scriptDisplayName } from '../session/scripts/script_types.js';
      32              : import { runSessionScriptForResult } from '../session/scripts/rerun_query.js';
      33              : import { buildSchemaContext } from './agent_catalog.js';
      34              : import { checkVegaLiteSpec } from '../ui/vegalite/vegalite_check.js';
      35              : import { arrowTableToRows } from '../ui/vegalite/vegalite_rows.js';
      36              : import { CHART_IMAGE_HEIGHT, CHART_IMAGE_SCALE, CHART_IMAGE_WIDTH, renderVegaLitePng, type ChartImage } from '../ui/vegalite/vegalite_image.js';
      37              : import { DEFAULT_RUN_ROWS, RESULT_PAGE_ROWS, formatResultSlice, sliceResult } from './agent_result_format.js';
      38              : import { formatBytes } from '../utils/format.js';
      39              : 
      40              : /// What the commands of the agent work with
      41              : export interface AgentCommandEnvironment {
      42              :     /// The scripts of a session, null if the session is gone
      43              :     sessionScripts: (sessionId: string) => SessionScripts | null;
      44              :     /// Modify the scripts of a session
      45              :     modifySessionScripts: (sessionId: string, action: SessionScriptsAction) => Promise<SessionScripts | null>;
      46              :     /// The connections of the app
      47              :     connections: () => ConnectionRegistry;
      48              :     /// The connection to the embedded Hyper, allocated when the app holds none yet
      49              :     embeddedConnection?: (instance: core.DashQL) => ConnectionState;
      50              :     executeQuery: QueryExecutor;
      51              :     logger: Logger;
      52              :     /// The agent edited a script
      53              :     onScriptEdited?: (sessionId: string, scriptKey: number) => void;
      54              :     /// The agent ran a script
      55              :     onScriptRun?: (sessionId: string, scriptKey: number) => void;
      56              : }
      57              : 
      58            1 : function fail(message: string): never {
      59            1 :     throw new AgentCommandError('failed', message);
      60            1 : }
      61              : 
      62            1 : function usage(message: string): never {
      63            1 :     throw new AgentCommandError('usage', message);
      64            1 : }
      65              : 
      66            1 : function stringArg(args: Record<string, unknown>, name: string): string {
      67            1 :     const value = args[name];
      68            1 :     if (typeof value !== 'string' || value.length === 0) usage(`missing ${name}`);
      69            1 :     return value;
      70            1 : }
      71              : 
      72            1 : function numberArg(args: Record<string, unknown>, name: string, fallback: number): number {
      73            1 :     const value = args[name];
      74            1 :     if (value == null) return fallback;
      75            1 :     if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) usage(`${name} must be a positive integer`);
      76            1 :     return value;
      77            1 : }
      78              : 
      79            1 : function booleanArg(args: Record<string, unknown>, name: string): boolean {
      80            1 :     const value = args[name];
      81            1 :     if (value == null) return false;
      82            1 :     if (typeof value !== 'boolean') usage(`${name} must be a boolean`);
      83            1 :     return value;
      84            1 : }
      85              : 
      86              : /// Find a script by its file name or display name, case-insensitive
      87            1 : export function findScript(state: SessionScripts, name: string): ScriptData | null {
      88            1 :     const wanted = scriptDisplayName(name.trim()).toLowerCase();
      89            1 :     for (const fileName of state.scriptOrder) {
      90            1 :         if (scriptDisplayName(fileName).toLowerCase() === wanted) {
      91            1 :             return state.scripts[state.scriptRefs[fileName].scriptId] ?? null;
      92              :         }
      93              :     }
      94            1 :     return null;
      95            1 : }
      96              : 
      97            1 : function requireScript(state: SessionScripts, name: string): ScriptData {
      98            1 :     const script = findScript(state, name);
      99            1 :     if (script == null) {
     100            1 :         const names = state.scriptOrder.map(scriptDisplayName).join(', ');
     101            1 :         fail(`No script named ${name}. Scripts: ${names.length > 0 ? names : 'none'}`);
     102            1 :     }
     103            1 :     return script;
     104            1 : }
     105              : 
     106              : /// A connection of a session as the agent sees it
     107              : interface AgentConnection {
     108              :     id: string;
     109              :     connector: string | null;
     110              :     /// Is it the embedded Hyper, which reads the attachments and the results of scripts of other connections?
     111              :     embedded: boolean;
     112              :     readOnly: boolean | null;
     113              :     online: boolean;
     114              :     /// Does DashQL cache the query results of the connection, so the embedded Hyper rereads them without a query?
     115              :     cachesResults: boolean | null;
     116              : }
     117              : 
     118            1 : function describeConnection(env: AgentCommandEnvironment, connectionId: string): AgentConnection {
     119            1 :     const conn = env.connections().connections.get(connectionId);
     120            1 :     if (conn == null) return { id: connectionId, connector: null, embedded: false, readOnly: null, online: false, cachesResults: null };
     121            1 :     const embedded = isHyperWasmConnection(conn);
     122            1 :     return {
     123            1 :         id: connectionId,
     124            1 :         connector: conn.connectorInfo.names.displayShort,
     125            1 :         embedded,
     126            1 :         readOnly: conn.connectorInfo.features.readOnly,
     127            1 :         online: conn.connectionHealth === ConnectionHealth.ONLINE,
     128              :         // The embedded Hyper reads its own tables directly, its cache saves no remote query
     129            1 :         cachesResults: !embedded && connectionCachesQueryResults(conn),
     130            1 :     };
     131            1 : }
     132              : 
     133              : /// The connections that the agent may use: the connections of the session and the embedded Hyper, which joins the
     134              : /// attachments and the results of the other connections. The app may hold no embedded Hyper yet, e.g. when every
     135              : /// session runs on a remote connection, then it is allocated so the agent can move scripts onto it.
     136            1 : function agentConnectionIds(env: AgentCommandEnvironment, state: SessionScripts): string[] {
     137            1 :     const ids = sessionConnectionIds(state);
     138            1 :     const embedded = findEmbeddedConnection(env.connections().connections.values()) ?? env.embeddedConnection?.(state.instance) ?? null;
     139            1 :     if (embedded != null && !ids.includes(embedded.connectionId)) ids.push(embedded.connectionId);
     140            1 :     return ids;
     141            1 : }
     142              : 
     143              : /// A connection that the agent may use
     144            1 : function requireSessionConnection(env: AgentCommandEnvironment, state: SessionScripts, connectionId: string): void {
     145            1 :     const ids = agentConnectionIds(env, state);
     146            1 :     if (!ids.includes(connectionId)) fail(`The session does not use the connection ${connectionId}. Connections: ${ids.join(', ')}`);
     147            1 : }
     148              : 
     149              : /// The connection that the agent picked for a script, null if it picked none
     150            1 : function pickedScriptConnection(env: AgentCommandEnvironment, state: SessionScripts, args: Record<string, unknown>): ScriptConnection | null {
     151            1 :     const connectionId = args.connection;
     152            1 :     if (connectionId == null) return null;
     153            1 :     if (typeof connectionId !== 'string' || connectionId.length === 0) usage('invalid connection');
     154            1 :     requireSessionConnection(env, state, connectionId);
     155            1 :     const conn = env.connections().connections.get(connectionId);
     156            1 :     if (conn == null) fail(`The connection ${connectionId} is not set up in DashQL, the user has to connect it`);
     157            1 :     return scriptConnectionOf(conn);
     158            1 : }
     159              : 
     160            1 : function connectionLabel(conn: AgentConnection): string {
     161            1 :     if (conn.connector == null) return conn.id;
     162            1 :     const connector = conn.embedded ? 'embedded Hyper' : conn.connector;
     163            1 :     const caching = conn.cachesResults ? ', caches results' : '';
     164            1 :     return `${conn.id} (${connector}, ${conn.readOnly ? 'read-only' : 'read-write'}, ${conn.online ? 'online' : 'offline'}${caching})`;
     165            1 : }
     166              : 
     167              : /// The SHA-256 of a text as hex
     168            1 : async function sha256(text: string): Promise<string> {
     169            1 :     const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
     170            1 :     return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('');
     171            1 : }
     172              : 
     173              : /// The line and column of an offset, one-based
     174            1 : function lineAndColumn(text: string, offset: number): [number, number] {
     175            1 :     let line = 1;
     176            1 :     let lineStart = 0;
     177            1 :     for (let i = 0; i < Math.min(offset, text.length); ++i) {
     178            1 :         if (text.charCodeAt(i) === 10) {
     179            0 :             line += 1;
     180            0 :             lineStart = i + 1;
     181            0 :         }
     182              :     }
     183            1 :     return [line, offset - lineStart + 1];
     184            1 : }
     185              : 
     186              : /// A diagnostic of a script
     187              : interface AgentDiagnostic {
     188              :     severity: 'error' | 'warning';
     189              :     line: number | null;
     190              :     column: number | null;
     191              :     message: string;
     192              : }
     193              : 
     194            1 : function collectDiagnostics(script: ScriptData): AgentDiagnostic[] {
     195            1 :     const text = script.scriptSession.getText();
     196            1 :     return (script.editorUpdate?.diagnostics ?? []).map(diagnostic => {
     197            1 :         const span = diagnostic.textSpan;
     198            1 :         const [line, column] = span == null ? [null, null] : lineAndColumn(text, Number(span.offset));
     199            1 :         return {
     200            1 :             severity: diagnostic.severity === core.buffers.editor.EditorDiagnosticSeverity.WARNING ? 'warning' : 'error',
     201            1 :             line,
     202            1 :             column,
     203            1 :             message: typeof diagnostic.message === 'string' ? diagnostic.message : '',
     204            1 :         };
     205            1 :     });
     206            1 : }
     207              : 
     208            1 : function formatDiagnostics(name: string, diagnostics: AgentDiagnostic[]): string {
     209            1 :     if (diagnostics.length === 0) return `${name}: no diagnostics`;
     210            1 :     return diagnostics.map(d => `${name}:${d.line ?? '?'}:${d.column ?? '?'}: ${d.severity}: ${d.message}`).join('\n');
     211            1 : }
     212              : 
     213              : /// Analyze a script if its analysis is outdated
     214            1 : async function analyzed(env: AgentCommandEnvironment, sessionId: string, state: SessionScripts, script: ScriptData): Promise<ScriptData> {
     215            1 :     const modify: ModifySessionScripts = action => env.modifySessionScripts(sessionId, action);
     216            1 :     return (await ensureSessionScriptAnalyzed(state, script.scriptKey, modify)) ?? script;
     217            1 : }
     218              : 
     219            1 : async function listScripts(env: AgentCommandEnvironment, state: SessionScripts): Promise<AgentCommandResult> {
     220            1 :     const focused = getSelectedScriptRef(state)?.fileName ?? null;
     221            1 :     const scripts = state.scriptOrder.map(fileName => {
     222            1 :         const script = state.scripts[state.scriptRefs[fileName].scriptId];
     223            1 :         const text = script?.scriptSession.getText() ?? '';
     224            1 :         return {
     225            1 :             name: scriptDisplayName(fileName),
     226            1 :             fileName,
     227            1 :             connectionId: script == null ? null : persistedConnectionId(script),
     228            1 :             lines: text.length === 0 ? 0 : text.split('\n').length,
     229            1 :             focused: fileName === focused,
     230            1 :         };
     231            1 :     });
     232            1 :     const connections = agentConnectionIds(env, state).map(id => describeConnection(env, id));
     233            1 :     const attachments = (state.attachments ?? []).map(attachment => ({
     234            1 :         fileName: attachment.fileName,
     235            1 :         name: attachment.name,
     236            1 :         sizeBytes: attachment.sizeBytes,
     237            1 :         columns: attachment.columns,
     238            1 :     }));
     239            1 :     const lines = [
     240            1 :         `Session ${JSON.stringify(state.name ?? '')} (${state.sessionId})`,
     241            1 :         `Connections: ${connections.map(connectionLabel).join(', ')}`,
     242            1 :     ];
     243            1 :     lines.push(scripts.length === 0 ? 'No scripts' : 'Scripts:');
     244              :     // The scripts name their connections when the session uses several
     245            1 :     const several = connections.length > 1;
     246            1 :     for (const script of scripts) {
     247            1 :         const connection = several && script.connectionId != null ? `  on ${script.connectionId}` : '';
     248            1 :         lines.push(`  ${script.name}  ${script.lines} line${script.lines === 1 ? '' : 's'}${connection}${script.focused ? '  (focused)' : ''}`);
     249            1 :     }
     250              :     // The attachments are table parameters of the scripts of the embedded Hyper
     251            1 :     if (attachments.length > 0) lines.push('Attachments:');
     252            1 :     for (const attachment of attachments) {
     253            1 :         const what = attachment.columns == null ? `:${attachment.name}` : `:${attachment.name} (${attachment.columns.join(', ')})`;
     254            1 :         lines.push(`  ${attachment.fileName}  ${formatBytes(attachment.sizeBytes)}  ${what}`);
     255            1 :     }
     256            1 :     return { text: lines.join('\n'), data: { session: state.sessionId, name: state.name, connections, scripts, attachments } };
     257            1 : }
     258              : 
     259            1 : async function editScript(env: AgentCommandEnvironment, sessionId: string, state: SessionScripts, args: Record<string, unknown>): Promise<AgentCommandResult> {
     260            1 :     const name = stringArg(args, 'script');
     261            1 :     const text = args.text;
     262            1 :     if (typeof text !== 'string') usage('missing text');
     263            1 :     const base = scriptDisplayName(name.trim());
     264            1 :     if (base.length === 0 || /[/\\\0]/.test(base)) usage(`invalid script name: ${name}`);
     265              : 
     266            1 :     const connection = pickedScriptConnection(env, state, args);
     267            1 :     let script = findScript(state, name);
     268            1 :     let next: SessionScripts | null;
     269            1 :     let created = false;
     270            1 :     if (script != null) {
     271            1 :         if (connection != null && connection.connectionId !== script.connection.connectionId) {
     272              :             // The script moves first and keeps its key
     273            1 :             const moved = await env.modifySessionScripts(sessionId, { type: SET_SCRIPT_CONNECTION, value: { fileName: script.fileName, connection } });
     274            1 :             if (moved?.scripts[script.scriptKey]?.connection.connectionId !== connection.connectionId) {
     275            1 :                 fail(`Cannot move ${scriptDisplayName(script.fileName)} to the connection ${connection.connectionId}`);
     276              :             }
     277              :         }
     278            1 :         next = await env.modifySessionScripts(sessionId, { type: SET_SCRIPT_TEXT, value: { scriptKey: script.scriptKey, text } });
     279            1 :     } else {
     280            1 :         next = await env.modifySessionScripts(sessionId, { type: CREATE_SCRIPT, value: { index: null, text, connection: connection ?? undefined } });
     281            1 :         const createdRef = next == null ? undefined : getSelectedScriptRef(next);
     282            1 :         if (next == null || createdRef == null) fail('Cannot create the script');
     283            1 :         next = await env.modifySessionScripts(sessionId, { type: RENAME_SCRIPT, value: { fileName: createdRef.fileName, newFileName: base } });
     284            1 :         created = true;
     285            1 :         script = next == null ? null : next.scripts[createdRef.scriptId] ?? null;
     286            1 :     }
     287            1 :     if (next == null || script == null) fail('The session is gone');
     288            1 :     const edited = next.scripts[script.scriptKey];
     289            1 :     if (edited == null) fail('The script is gone');
     290            1 :     return reportEdit(env, sessionId, next, edited, created ? 'Created' : 'Updated', { created });
     291            1 : }
     292              : 
     293              : /// Analyze an edited script and report its hash and diagnostics
     294            1 : async function reportEdit(
     295            1 :     env: AgentCommandEnvironment,
     296            1 :     sessionId: string,
     297            1 :     state: SessionScripts,
     298            1 :     edited: ScriptData,
     299            1 :     verb: string,
     300            1 :     data: Record<string, unknown>,
     301            1 : ): Promise<AgentCommandResult> {
     302            1 :     env.onScriptEdited?.(sessionId, edited.scriptKey);
     303            1 :     const checked = await analyzed(env, sessionId, state, edited);
     304            1 :     const diagnostics = collectDiagnostics(checked);
     305            1 :     const hash = await sha256(checked.scriptSession.getText());
     306            1 :     const errors = diagnostics.filter(d => d.severity === 'error').length;
     307            1 :     const fileName = scriptDisplayName(checked.fileName);
     308            1 :     const connectionId = checked.connection.connectionId;
     309            1 :     const on = sessionConnectionIds(state).length > 1 ? ` on ${connectionId}` : '';
     310            1 :     const lines = [`${verb} ${fileName}${on}: sha256 ${hash}, ${errors} error${errors === 1 ? '' : 's'}, ${diagnostics.length - errors} warning${diagnostics.length - errors === 1 ? '' : 's'}`];
     311            1 :     if (diagnostics.length > 0) lines.push(formatDiagnostics(fileName, diagnostics));
     312            1 :     return { text: lines.join('\n'), data: { script: fileName, ...data, connection: connectionId, sha256: hash, diagnostics } };
     313            1 : }
     314              : 
     315              : /// Rewrite the `@vis` tag of the terminal query in a scratch script and analyze the result. Returns the
     316              : /// rewritten text and the analyzer warnings about the new tag, e.g. fields that are no output columns.
     317            1 : function rewriteVisTag(state: SessionScripts, script: ScriptData, spec: string): [string, string[]] {
     318            1 :     const scratch = state.instance.createScript(script.connection.catalog);
     319            1 :     try {
     320            1 :         scratch.replaceText(script.scriptSession.getText());
     321            1 :         const tag = scratch.rewriteVegaLite(spec);
     322            1 :         scratch.analyze();
     323            1 :         const analyzedPtr = scratch.getAnalyzed();
     324            1 :         const reader = analyzedPtr.read();
     325            1 :         const problems: string[] = [];
     326            1 :         for (let i = 0; i < reader.errorsLength(); ++i) {
     327            1 :             const error = reader.errors(i)!;
     328            1 :             const span = error.textSpan();
     329            1 :             if (error.errorType() !== core.buffers.analyzer.AnalyzerErrorType.INVALID_VIS_TAG || span == null) continue;
     330            1 :             if (span.offset() < tag.offset + tag.length && span.offset() + span.length() > tag.offset) problems.push(error.message() ?? '');
     331            1 :         }
     332            1 :         return [scratch.toString(), problems];
     333            1 :     } finally {
     334            1 :         scratch.destroy();
     335            1 :     }
     336            1 : }
     337              : 
     338              : /// Visualize the terminal query of a script with a Vega-Lite spec. The spec is checked first: it must be a
     339              : /// JSON object that Vega-Lite compiles without warnings, and its fields must be output columns of the query.
     340            1 : async function editVis(env: AgentCommandEnvironment, sessionId: string, state: SessionScripts, args: Record<string, unknown>): Promise<AgentCommandResult> {
     341            1 :     const script = requireScript(state, stringArg(args, 'script'));
     342            1 :     const specText = stringArg(args, 'spec');
     343            1 :     const name = scriptDisplayName(script.fileName);
     344            1 :     let spec: unknown;
     345            1 :     try {
     346            1 :         spec = JSON.parse(specText);
     347            1 :     } catch (e: unknown) {
     348            1 :         fail(`The Vega-Lite spec is no valid JSON: ${stringifyError(e)}`);
     349            1 :     }
     350            1 :     if (spec == null || typeof spec !== 'object' || Array.isArray(spec)) fail('The Vega-Lite spec is no JSON object');
     351            1 :     const invalid = (problems: string[]) => fail([`The Vega-Lite spec is invalid, ${name} is unchanged:`, ...problems.map(p => `  ${p}`)].join('\n'));
     352              : 
     353            1 :     const compileProblems = await checkVegaLiteSpec(spec as Record<string, unknown>);
     354            1 :     if (compileProblems.length > 0) invalid(compileProblems);
     355            1 :     let after: string;
     356            1 :     let tagProblems: string[];
     357            1 :     try {
     358            1 :         [after, tagProblems] = rewriteVisTag(state, script, specText);
     359            1 :     } catch (e: unknown) {
     360            1 :         fail(`Cannot visualize ${name}: ${stringifyError(e)}`);
     361            1 :     }
     362            1 :     if (tagProblems.length > 0) invalid(tagProblems);
     363              : 
     364            1 :     const next = await env.modifySessionScripts(sessionId, { type: SET_SCRIPT_TEXT, value: { scriptKey: script.scriptKey, text: after } });
     365            1 :     const edited = next?.scripts[script.scriptKey];
     366            1 :     if (next == null || edited == null) fail('The script is gone');
     367            1 :     return reportEdit(env, sessionId, next, edited, 'Visualized', {});
     368            1 : }
     369              : 
     370              : /// The client functions of a connection as lines of a schema: a family per line, with what it reads and what it writes
     371            1 : async function describeClientFunctions(env: AgentCommandEnvironment, conn: ConnectionState | null): Promise<string[]> {
     372            1 :     const binding = conn == null ? undefined : connectorOfDetails(conn.details).clientFunctions;
     373            1 :     if (binding == null) return [];
     374            1 :     let spec: ClientFunctionSpec;
     375            1 :     try {
     376            1 :         spec = await binding.loadSpec();
     377            1 :     } catch (e: unknown) {
     378            0 :         env.logger.warn('Failed to load the client functions', { error: stringifyError(e) }, 'agent_commands');
     379            0 :         return [];
     380            0 :     }
     381            1 :     const families = new Map<string, { reads: string[]; writes: string[] }>();
     382            1 :     for (const fn of spec.functions) {
     383            1 :         const family = fn.family ?? spec.namespace;
     384            1 :         const names = families.get(family) ?? { reads: [], writes: [] };
     385            1 :         (fn.effect === 'read' ? names.reads : names.writes).push(fn.name);
     386            1 :         families.set(family, names);
     387            1 :     }
     388            1 :     const lines = [...families].map(([family, { reads, writes }]) => `-- ${family}: ${[reads.join(', '), writes.length > 0 ? `writes: ${writes.join(', ')}` : ''].filter(s => s.length > 0).join(' | ')}`);
     389            1 :     return [`-- The client functions of the connection, call them with CALL ${spec.namespace}.<function> (<option> => <value>, ...)`, ...lines];
     390            1 : }
     391              : 
     392            1 : async function describeCatalog(env: AgentCommandEnvironment, state: SessionScripts, args: Record<string, unknown>): Promise<AgentCommandResult> {
     393            1 :     const connectionId = typeof args.connection === 'string' ? args.connection : newScriptConnection(state, null)?.connectionId;
     394            1 :     if (connectionId == null) fail('The session has no connection');
     395            1 :     requireSessionConnection(env, state, connectionId);
     396            1 :     const conn: ConnectionState | null = env.connections().connections.get(connectionId) ?? null;
     397            1 :     let schema = buildSchemaContext(connectionId, conn, state, env.logger);
     398              :     // The embedded Hyper reads the Parquet attachments as table parameters
     399            1 :     const attachments = conn != null && isHyperWasmConnection(conn) ? state.attachments ?? [] : [];
     400            1 :     if (attachments.length > 0) {
     401            1 :         const lines = attachments.map(a => `-- :${a.name}${a.columns == null ? '' : ` (${a.columns.join(', ')})`}`);
     402            1 :         schema = [schema, '-- The attachments of the session, read them as table parameters', ...lines].filter(s => s.length > 0).join('\n');
     403            1 :     }
     404            1 :     schema = [schema, ...await describeClientFunctions(env, conn)].filter(s => s.length > 0).join('\n');
     405            1 :     const text = schema.length > 0 ? schema : '-- The catalog is empty';
     406            1 :     return { text, data: { connection: connectionId, schema } };
     407            1 : }
     408              : 
     409            1 : async function lintScript(env: AgentCommandEnvironment, sessionId: string, state: SessionScripts, args: Record<string, unknown>): Promise<AgentCommandResult> {
     410            1 :     const script = await analyzed(env, sessionId, state, requireScript(state, stringArg(args, 'script')));
     411            1 :     const diagnostics = collectDiagnostics(script);
     412            1 :     const name = scriptDisplayName(script.fileName);
     413            1 :     return { text: formatDiagnostics(name, diagnostics), data: { script: name, diagnostics } };
     414            1 : }
     415              : 
     416            1 : async function runScript(env: AgentCommandEnvironment, sessionId: string, state: SessionScripts, args: Record<string, unknown>): Promise<AgentCommandResult> {
     417            1 :     const script = requireScript(state, stringArg(args, 'script'));
     418            1 :     const maxRows = numberArg(args, 'maxRows', DEFAULT_RUN_ROWS);
     419            1 :     const name = scriptDisplayName(script.fileName);
     420            1 :     const force = booleanArg(args, 'force');
     421            1 :     const conn = env.connections().connections.get(script.connection.connectionId);
     422            1 :     if (conn == null || conn.connectionHealth !== ConnectionHealth.ONLINE) {
     423            1 :         fail(`The connection ${script.connection.connectionId} of ${name} is not online, the user has to connect it`);
     424              :     }
     425            1 :     const modify: ModifySessionScripts = action => env.modifySessionScripts(sessionId, action);
     426              :     // What a script may do comes from its analysis, which is only current right before the run
     427            1 :     const beforeRun = (current: ScriptData) => {
     428              :         // A script with @manual is too expensive to run unfiltered, its consumers inline it with their filters
     429            1 :         if (current.annotations.manual && !force) {
     430            1 :             fail(`${name} runs only on request (@manual), it is too expensive to run in full. Read :${name} from another script of its connection that filters or aggregates it, or pass --force if the request is to run it in full.`);
     431              :         }
     432            1 :         env.onScriptRun?.(sessionId, current.scriptKey);
     433            1 :     };
     434            1 :     let table: arrow.Table | null;
     435            1 :     try {
     436              :         // Waits for the run of another script of the session, e.g. one of another turn
     437            1 :         table = await runSessionScriptForResult(() => env.sessionScripts(sessionId), script.scriptKey, env.executeQuery, modify, env.logger as LoggerLike, beforeRun);
     438            1 :     } catch (e: unknown) {
     439            1 :         if (e instanceof AgentCommandError) throw e;
     440            1 :         fail(`${name} failed: ${stringifyError(e)}`);
     441            1 :     }
     442            1 :     if (table == null) {
     443            0 :         return { text: `${name} ran without a result`, data: { script: name, rowCount: 0, columns: [], rows: [], offset: 0 } };
     444              :     }
     445            1 :     const slice = sliceResult(table, 0, maxRows);
     446            1 :     return { text: formatResultSlice(slice, name), data: { script: name, ...slice } };
     447            1 : }
     448              : 
     449              : /// The result of the last run of a script
     450            1 : function lastResult(env: AgentCommandEnvironment, script: ScriptData): arrow.Table {
     451            1 :     const name = scriptDisplayName(script.fileName);
     452            1 :     const queryId = script.latestResultQueryId ?? script.latestQueryId;
     453            1 :     let table: arrow.Table | null = null;
     454            1 :     if (queryId != null) {
     455            1 :         for (const conn of env.connections().connections.values()) {
     456            1 :             const query = conn.queriesFinished.get(queryId) ?? conn.queriesActive.get(queryId);
     457            1 :             if (query != null) {
     458            1 :                 if (query.error != null && query.resultTable == null) fail(`The last run of ${name} failed: ${query.error.message}`);
     459            1 :                 table = query.resultTable;
     460            1 :                 break;
     461            1 :             }
     462            1 :         }
     463              :     }
     464            1 :     if (table == null) fail(`${name} has no result, run it with dashql agent run ${name}`);
     465            1 :     return table;
     466            1 : }
     467              : 
     468            1 : function readResult(env: AgentCommandEnvironment, state: SessionScripts, args: Record<string, unknown>): AgentCommandResult {
     469            1 :     const script = requireScript(state, stringArg(args, 'script'));
     470            1 :     const page = numberArg(args, 'page', 1);
     471            1 :     const name = scriptDisplayName(script.fileName);
     472            1 :     const table = lastResult(env, script);
     473            1 :     const offset = (page - 1) * RESULT_PAGE_ROWS;
     474            1 :     if (offset >= table.numRows && table.numRows > 0) fail(`${name} has ${table.numRows} rows, page ${page} is empty`);
     475            1 :     const slice = sliceResult(table, offset, RESULT_PAGE_ROWS);
     476            1 :     return { text: formatResultSlice(slice, name), data: { script: name, page, ...slice } };
     477            1 : }
     478              : 
     479              : /// The chart of a script: its renderer and the Vega-Lite spec of its `@vis vegalite` tag, without the `$schema` and
     480              : /// `data` that the compiler adds, so that the spec goes back into `vis edit` as it is
     481            1 : async function readVis(env: AgentCommandEnvironment, sessionId: string, state: SessionScripts, args: Record<string, unknown>): Promise<AgentCommandResult> {
     482            1 :     const script = await analyzed(env, sessionId, state, requireScript(state, stringArg(args, 'script')));
     483            1 :     const name = scriptDisplayName(script.fileName);
     484            1 :     const vis = script.annotations.visualizeQuery;
     485            1 :     if (vis == null) fail(`${name} has no chart, add one with dashql agent vis edit ${name}`);
     486            1 :     if (vis.renderer !== 'vegalite') {
     487            1 :         return { text: `${name} shows a ${vis.renderer} @vis tag, no Vega-Lite spec`, data: { script: name, renderer: vis.renderer, spec: null } };
     488              :     }
     489            1 :     const spec: Record<string, unknown> = { ...vis.vegaLiteSpec };
     490            1 :     delete spec.$schema;
     491            1 :     delete spec.data;
     492            1 :     return { text: JSON.stringify(spec, null, 2), data: { script: name, renderer: vis.renderer, spec } };
     493            1 : }
     494              : 
     495              : /// Bytes as base64
     496            1 : function toBase64(bytes: Uint8Array): string {
     497            1 :     let binary = '';
     498            1 :     for (let i = 0; i < bytes.length; i += 0x8000) {
     499            1 :         binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
     500              :     }
     501            1 :     return btoa(binary);
     502            1 : }
     503              : 
     504              : /// The limits of chart images, in CSS pixels and pixels per CSS pixel
     505            1 : const MAX_IMAGE_SIZE = 4000;
     506            1 : const MAX_IMAGE_SCALE = 4;
     507              : 
     508              : /// Render the chart of a script as PNG: the `@vis vegalite` tag of its last query with the last result as data
     509            1 : async function renderVis(env: AgentCommandEnvironment, sessionId: string, state: SessionScripts, args: Record<string, unknown>): Promise<AgentCommandResult> {
     510            1 :     const script = await analyzed(env, sessionId, state, requireScript(state, stringArg(args, 'script')));
     511            1 :     const name = scriptDisplayName(script.fileName);
     512            1 :     const width = numberArg(args, 'width', CHART_IMAGE_WIDTH);
     513            1 :     const height = numberArg(args, 'height', CHART_IMAGE_HEIGHT);
     514            1 :     const scale = numberArg(args, 'scale', CHART_IMAGE_SCALE);
     515            1 :     if (width > MAX_IMAGE_SIZE || height > MAX_IMAGE_SIZE) usage(`width and height must be at most ${MAX_IMAGE_SIZE}`);
     516            1 :     if (scale > MAX_IMAGE_SCALE) usage(`scale must be at most ${MAX_IMAGE_SCALE}`);
     517            1 :     const vis = script.annotations.visualizeQuery;
     518            1 :     if (vis?.renderer !== 'vegalite') fail(`${name} has no Vega-Lite chart, add one with dashql agent vis edit ${name}`);
     519            1 :     const table = lastResult(env, script);
     520            1 :     let image: ChartImage;
     521            1 :     try {
     522            1 :         image = await renderVegaLitePng(vis.vegaLiteSpec, arrowTableToRows(table), { width, height, scale });
     523            1 :     } catch (e: unknown) {
     524            0 :         fail(`Cannot render the chart of ${name}: ${stringifyError(e)}`);
     525            0 :     }
     526            1 :     const kib = Math.max(1, Math.round(image.png.length / 1024));
     527            1 :     return {
     528            1 :         text: `Rendered the chart of ${name}: ${image.width}x${image.height} pixels, ${table.numRows} rows, ${kib} KiB`,
     529            1 :         data: { script: name, width: image.width, height: image.height, rows: table.numRows, png: toBase64(image.png) },
     530            1 :     };
     531            1 : }
     532              : 
     533              : /// Run a command of the command line tool against a session
     534            1 : export async function runAgentCommand(env: AgentCommandEnvironment, params: AgentCommandParams): Promise<AgentCommandResult> {
     535            1 :     const state = env.sessionScripts(params.session);
     536            1 :     if (state == null) throw new AgentCommandError('not_connected', `The session ${params.session} is not open in DashQL`);
     537            1 :     const args = params.args ?? {};
     538            1 :     switch (params.command) {
     539            1 :         case 'scripts':
     540            1 :             return listScripts(env, state);
     541            1 :         case 'read': {
     542            1 :             const script = requireScript(state, stringArg(args, 'script'));
     543            1 :             const text = script.scriptSession.getText();
     544            1 :             return { text, data: { script: scriptDisplayName(script.fileName), text } };
     545            1 :         }
     546            1 :         case 'edit':
     547            1 :             return editScript(env, params.session, state, args);
     548            1 :         case 'catalog':
     549            1 :             return describeCatalog(env, state, args);
     550            1 :         case 'lint':
     551            1 :             return lintScript(env, params.session, state, args);
     552            1 :         case 'run':
     553            1 :             return runScript(env, params.session, state, args);
     554            1 :         case 'result':
     555            1 :             return readResult(env, state, args);
     556            1 :         case 'vis.read':
     557            1 :             return readVis(env, params.session, state, args);
     558            1 :         case 'vis.edit':
     559            1 :             return editVis(env, params.session, state, args);
     560            1 :         case 'vis.image':
     561            1 :             return renderVis(env, params.session, state, args);
     562            0 :         default:
     563            0 :             usage(`unknown command: ${params.command}`);
     564            1 :     }
     565            1 : }
        

Generated by: LCOV version 2.0-1