LCOV - code coverage report
Current view: top level - dashql-app/src/agent - agent_commands.ts Coverage Total Hit
Test: DashQL 915828b Lines: 97.9 % 387 379
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1