diff --git a/app/src/components/Actions/Actions.tsx b/app/src/components/Actions/Actions.tsx index 91e605b..7404dce 100644 --- a/app/src/components/Actions/Actions.tsx +++ b/app/src/components/Actions/Actions.tsx @@ -1059,6 +1059,9 @@ export default function Actions() { explorer.mountDrive(driveUrl){"\n"} explorer.openPicker(){"\n"} explorer.listFolders(){"\n"} + runme.getCurrentNotebook(){"\n"} + runme.clearOutputs(runme.getCurrentNotebook()){"\n"} + runme.runAll(runme.getCurrentNotebook()){"\n"} help(){"\n\n"} To attach test notebooks: use the Explorer + button to pick the fixtures folder {"\n"} diff --git a/app/src/components/AppConsole/AppConsole.tsx b/app/src/components/AppConsole/AppConsole.tsx index 26c09c5..1a7b5ef 100644 --- a/app/src/components/AppConsole/AppConsole.tsx +++ b/app/src/components/AppConsole/AppConsole.tsx @@ -8,6 +8,8 @@ import { JSKernel } from "../../lib/runtime/jsKernel"; import { useRunners } from "../../contexts/RunnersContext"; import { useWorkspace } from "../../contexts/WorkspaceContext"; import { useFilesystemStore } from "../../contexts/FilesystemStoreContext"; +import { useCurrentDoc } from "../../contexts/CurrentDocContext"; +import { useNotebookContext } from "../../contexts/NotebookContext"; import { appState } from "../../lib/runtime/AppState"; import { FilesystemNotebookStore, @@ -15,6 +17,10 @@ import { } from "../../storage/fs"; import { Runner } from "../../lib/runner"; import { getRunnersManager } from "../../lib/runtime/runnersManager"; +import { + createRunmeConsoleApi, + type NotebookDataLike, +} from "../../lib/runtime/runmeConsole"; import { googleClientManager } from "../../lib/googleClientManager"; import { oidcConfigManager } from "../../auth/oidcConfig"; import type { OidcConfig } from "../../auth/oidcConfig"; @@ -66,6 +72,8 @@ export default function AppConsole() { // WorkspaceContext provides the persisted list of workspace URIs so we can // mount/unmount folders from the App Console without drilling props. const { getItems, addItem, removeItem } = useWorkspace(); + const { getCurrentDoc } = useCurrentDoc(); + const { getNotebookData } = useNotebookContext(); // FilesystemStoreContext owns the File System Access API store instance that // actually opens folders and produces fs:// workspace URIs. const { fsStore, setFsStore } = useFilesystemStore(); @@ -136,10 +144,66 @@ export default function AppConsole() { return "Opening directory picker..."; }, [addItem, ensureFilesystemStore, getItems, sendStdout]); + const resolveNotebookData = useCallback( + (target?: unknown): NotebookDataLike | null => { + if (target && typeof target === "object") { + const candidate = target as Partial; + if ( + typeof candidate.getUri === "function" && + typeof candidate.getName === "function" && + typeof candidate.getNotebook === "function" && + typeof candidate.updateCell === "function" && + typeof candidate.getCell === "function" + ) { + return candidate as NotebookDataLike; + } + } + + if (typeof target === "string" && target.trim() !== "") { + return getNotebookData(target) ?? null; + } + + const uri = getCurrentDoc(); + if (!uri) { + return null; + } + return getNotebookData(uri) ?? null; + }, + [getCurrentDoc, getNotebookData], + ); + + const runme = useMemo( + () => + createRunmeConsoleApi({ + resolveNotebook: resolveNotebookData, + }), + [resolveNotebookData], + ); + const kernel = useMemo( () => new JSKernel({ globals: { + runme: { + getCurrentNotebook: () => { + return runme.getCurrentNotebook(); + }, + clearOutputs: (target?: unknown) => { + const message = runme.clearOutputs(target); + sendStdout(`${message}\r\n`); + return message; + }, + runAll: (target?: unknown) => { + const message = runme.runAll(target); + sendStdout(`${message}\r\n`); + return message; + }, + help: () => { + const message = runme.help(); + sendStdout(`${message}\r\n`); + return message; + }, + }, aisreRunners: { get: () => { const mgr = getRunnersManager(); @@ -291,8 +355,9 @@ export default function AppConsole() { }, }, help: () => { - return [ + const message = [ "Available namespaces:", + " runme - Notebook helpers (run all, clear outputs)", " explorer - Manage workspace folders and notebooks", " aisreRunners - Configure runner endpoints", " oidc - OIDC/OAuth configuration and auth status", @@ -302,6 +367,8 @@ export default function AppConsole() { "", "Type .help() for detailed commands, e.g. explorer.help()", ].join("\n"); + sendStdout(`${message}\r\n`); + return message; }, explorer: { addFolder: (path?: string) => { @@ -363,11 +430,14 @@ export default function AppConsole() { addItem, defaultRunnerName, deleteRunner, + getCurrentDoc, getItems, + getNotebookData, listRunners, ensureFilesystemStore, openWorkspaceAndAdd, removeItem, + runme, sendStdout, updateRunner, ], diff --git a/app/src/lib/runtime/jsKernel.ts b/app/src/lib/runtime/jsKernel.ts index 1de4322..cde9c82 100644 --- a/app/src/lib/runtime/jsKernel.ts +++ b/app/src/lib/runtime/jsKernel.ts @@ -74,6 +74,9 @@ export class JSKernel { stdout, appRunners, ); + const globalHelp = + (options.globals?.help as (() => unknown) | undefined) ?? + (this.baseGlobals.help as (() => unknown) | undefined); const mergedGlobals: Record = { ...this.baseGlobals, @@ -82,22 +85,24 @@ export class JSKernel { app, // Keep `aisre` as a compatibility alias for existing snippets. aisre: app, - help: () => - stdout( - [ - "App JS console helpers:", - "- d3: D3.js", - "- app.clear(): clear the render container", - "- app.render(fn): render into the container with a D3 selection", - "- console.log/info/warn/error: write to this console", - "- app.runners.get(): list configured runners", - "- app.runners.update(name, endpoint): add/update a runner", - "- app.runners.delete(name): remove a runner", - "- app.runners.getDefault(): show default runner", - "- app.runners.setDefault(name): set default runner", - "- help(): show this message", - ].join("\n") + "\n", - ), + help: + globalHelp ?? + (() => + stdout( + [ + "App JS console helpers:", + "- d3: D3.js", + "- app.clear(): clear the render container", + "- app.render(fn): render into the container with a D3 selection", + "- console.log/info/warn/error: write to this console", + "- app.runners.get(): list configured runners", + "- app.runners.update(name, endpoint): add/update a runner", + "- app.runners.delete(name): remove a runner", + "- app.runners.getDefault(): show default runner", + "- app.runners.setDefault(name): set default runner", + "- help(): show this message", + ].join("\n") + "\n", + )), }; const argNames = Object.keys(mergedGlobals); diff --git a/app/src/lib/runtime/runmeConsole.test.ts b/app/src/lib/runtime/runmeConsole.test.ts new file mode 100644 index 0000000..c9cfcf3 --- /dev/null +++ b/app/src/lib/runtime/runmeConsole.test.ts @@ -0,0 +1,211 @@ +// @vitest-environment node + +import { create } from "@bufbuild/protobuf"; +import { describe, expect, it, vi } from "vitest"; + +import { RunmeMetadataKey, parser_pb } from "../../contexts/CellContext"; +import { + createRunmeConsoleApi, + type NotebookDataLike, +} from "./runmeConsole"; + +type FakeCellRunner = { + run: () => void; + getRunID: () => string; + calls: number; +}; + +class FakeNotebookData implements NotebookDataLike { + private readonly runners = new Map(); + updates: parser_pb.Cell[] = []; + + constructor( + private readonly uri: string, + private readonly name: string, + private readonly notebook: parser_pb.Notebook, + private readonly failedRunRefIds: Set = new Set(), + private readonly initialRunIds: Map = new Map(), + ) { + for (const cell of notebook.cells ?? []) { + if (!cell?.refId) { + continue; + } + let runID = this.initialRunIds.get(cell.refId) ?? ""; + const runner: FakeCellRunner = { + calls: 0, + run: () => { + runner.calls += 1; + if (this.failedRunRefIds.has(cell.refId)) { + return; + } + runID = `run-${cell.refId}-${runner.calls}`; + }, + getRunID: () => runID, + }; + this.runners.set(cell.refId, runner); + } + } + + getUri(): string { + return this.uri; + } + + getName(): string { + return this.name; + } + + getNotebook(): parser_pb.Notebook { + return this.notebook; + } + + updateCell(cell: parser_pb.Cell): void { + this.updates.push(cell); + this.notebook.cells = (this.notebook.cells ?? []).map((existing) => { + if (existing.refId !== cell.refId) { + return existing; + } + return create(parser_pb.CellSchema, cell); + }); + } + + getCell(refId: string): FakeCellRunner | null { + return this.runners.get(refId) ?? null; + } +} + +function codeCell( + refId: string, + value: string, + opts: { + outputs?: parser_pb.CellOutput[]; + metadata?: Record; + } = {}, +): parser_pb.Cell { + return create(parser_pb.CellSchema, { + refId, + kind: parser_pb.CellKind.CODE, + languageId: "bash", + value, + outputs: opts.outputs ?? [], + metadata: opts.metadata ?? {}, + }); +} + +describe("createRunmeConsoleApi", () => { + it("returns the current notebook handle", () => { + const notebook = create(parser_pb.NotebookSchema, { cells: [] }); + const model = new FakeNotebookData("local://one", "One", notebook); + const resolveNotebook = vi.fn(() => model); + const api = createRunmeConsoleApi({ resolveNotebook }); + + expect(api.getCurrentNotebook()).toBe(model); + expect(resolveNotebook).toHaveBeenCalledTimes(1); + }); + + it("clears outputs and run metadata for all matching cells", () => { + const output = create(parser_pb.CellOutputSchema, { + items: [ + create(parser_pb.CellOutputItemSchema, { + mime: "text/plain", + type: "Buffer", + data: new TextEncoder().encode("hello"), + }), + ], + }); + const notebook = create(parser_pb.NotebookSchema, { + cells: [ + codeCell("cell-a", "echo hi", { + outputs: [output], + metadata: { + [RunmeMetadataKey.LastRunID]: "run-cell-a", + [RunmeMetadataKey.Pid]: "42", + [RunmeMetadataKey.ExitCode]: "0", + }, + }), + codeCell("cell-b", "echo bye"), + ], + }); + + const model = new FakeNotebookData("local://one", "Notebook One", notebook); + const api = createRunmeConsoleApi({ + resolveNotebook: () => model, + }); + + const message = api.clearOutputs(); + + expect(message).toContain("Cleared 1 output item group(s) across 1 cell(s)"); + const updated = notebook.cells.find((cell) => cell.refId === "cell-a"); + expect(updated?.outputs).toHaveLength(0); + expect(updated?.metadata?.[RunmeMetadataKey.LastRunID]).toBeUndefined(); + expect(updated?.metadata?.[RunmeMetadataKey.Pid]).toBeUndefined(); + expect(updated?.metadata?.[RunmeMetadataKey.ExitCode]).toBeUndefined(); + expect(model.updates).toHaveLength(1); + }); + + it("runs all non-empty code cells and reports start failures", () => { + const notebook = create(parser_pb.NotebookSchema, { + cells: [ + codeCell("cell-a", "echo a"), + codeCell("cell-b", " "), + create(parser_pb.CellSchema, { + refId: "cell-c", + kind: parser_pb.CellKind.MARKUP, + languageId: "markdown", + value: "# title", + }), + codeCell("cell-d", "echo d"), + ], + }); + const model = new FakeNotebookData( + "local://one", + "Notebook One", + notebook, + new Set(["cell-d"]), + ); + const api = createRunmeConsoleApi({ + resolveNotebook: () => model, + }); + + const message = api.runAll(); + + expect(message).toContain("Started 1/2 code cell(s)"); + expect(message).toContain("1 failed to start"); + expect(model.getCell("cell-a")?.calls).toBe(1); + expect(model.getCell("cell-b")?.calls).toBe(0); + expect(model.getCell("cell-d")?.calls).toBe(1); + }); + + it("treats unchanged stale run IDs as failed starts", () => { + const notebook = create(parser_pb.NotebookSchema, { + cells: [codeCell("cell-a", "echo a")], + }); + const model = new FakeNotebookData( + "local://one", + "Notebook One", + notebook, + new Set(["cell-a"]), + new Map([["cell-a", "old-run-id"]]), + ); + const api = createRunmeConsoleApi({ + resolveNotebook: () => model, + }); + + const message = api.runAll(); + + expect(message).toContain("Started 0/1 code cell(s)"); + expect(message).toContain("1 failed to start"); + }); + + it("documents notebook/URI helper usage in help text", () => { + const notebook = create(parser_pb.NotebookSchema, { cells: [] }); + const model = new FakeNotebookData("local://one", "One", notebook); + const api = createRunmeConsoleApi({ + resolveNotebook: () => model, + }); + + const message = api.help(); + + expect(message).toContain("runme.clearOutputs([notebookOrUri])"); + expect(message).toContain("runme.runAll([notebookOrUri])"); + }); +}); diff --git a/app/src/lib/runtime/runmeConsole.ts b/app/src/lib/runtime/runmeConsole.ts new file mode 100644 index 0000000..930c519 --- /dev/null +++ b/app/src/lib/runtime/runmeConsole.ts @@ -0,0 +1,147 @@ +import { create } from "@bufbuild/protobuf"; + +import { RunmeMetadataKey, parser_pb } from "../../runme/client"; + +type CellRunnerLike = { + run: () => void; + getRunID: () => string; +}; + +export type NotebookDataLike = { + getUri: () => string; + getName: () => string; + getNotebook: () => parser_pb.Notebook; + updateCell: (cell: parser_pb.Cell) => void; + getCell: (refId: string) => CellRunnerLike | null; +}; + +export type RunmeConsoleApi = { + getCurrentNotebook: () => NotebookDataLike | null; + clearOutputs: (target?: unknown) => string; + runAll: (target?: unknown) => string; + help: () => string; +}; + +function formatNotebookLabel(notebook: NotebookDataLike): string { + const name = notebook.getName(); + const uri = notebook.getUri(); + if (name && name !== uri) { + return `${name} (${uri})`; + } + return uri; +} + +function clearCellRunMetadata(cell: parser_pb.Cell): void { + if (!cell.metadata) { + return; + } + delete cell.metadata[RunmeMetadataKey.LastRunID]; + delete cell.metadata[RunmeMetadataKey.Pid]; + delete cell.metadata[RunmeMetadataKey.ExitCode]; +} + +export function createRunmeConsoleApi({ + resolveNotebook, +}: { + resolveNotebook: (target?: unknown) => NotebookDataLike | null; +}): RunmeConsoleApi { + const getCurrentNotebook = () => resolveNotebook(); + + const clearOutputs = (target?: unknown) => { + const notebookData = resolveNotebook(target); + if (!notebookData) { + return "No active notebook found."; + } + + const notebook = notebookData.getNotebook(); + const cells = notebook.cells ?? []; + let updatedCells = 0; + let clearedOutputs = 0; + + for (const cell of cells) { + if (!cell?.refId) { + continue; + } + const hasOutputs = (cell.outputs?.length ?? 0) > 0; + const hasRunMetadata = + typeof cell.metadata?.[RunmeMetadataKey.LastRunID] === "string" || + typeof cell.metadata?.[RunmeMetadataKey.Pid] === "string" || + typeof cell.metadata?.[RunmeMetadataKey.ExitCode] === "string"; + if (!hasOutputs && !hasRunMetadata) { + continue; + } + + const updatedCell = create(parser_pb.CellSchema, cell); + clearedOutputs += updatedCell.outputs.length; + updatedCell.outputs = []; + clearCellRunMetadata(updatedCell); + notebookData.updateCell(updatedCell); + updatedCells += 1; + } + + if (updatedCells === 0) { + return `No cell outputs to clear in ${formatNotebookLabel(notebookData)}.`; + } + + return `Cleared ${clearedOutputs} output item group(s) across ${updatedCells} cell(s) in ${formatNotebookLabel(notebookData)}.`; + }; + + const runAll = (target?: unknown) => { + const notebookData = resolveNotebook(target); + if (!notebookData) { + return "No active notebook found."; + } + + const notebook = notebookData.getNotebook(); + const cells = notebook.cells ?? []; + let runnableCells = 0; + let started = 0; + let failedToStart = 0; + + for (const cell of cells) { + if (!cell?.refId || cell.kind !== parser_pb.CellKind.CODE) { + continue; + } + if ((cell.value ?? "").trim().length === 0) { + continue; + } + runnableCells += 1; + + const cellData = notebookData.getCell(cell.refId); + if (!cellData) { + failedToStart += 1; + continue; + } + + const previousRunID = cellData.getRunID(); + cellData.run(); + const runID = cellData.getRunID(); + if (runID && runID !== previousRunID) { + started += 1; + } else { + failedToStart += 1; + } + } + + if (runnableCells === 0) { + return `No runnable code cells found in ${formatNotebookLabel(notebookData)}.`; + } + + return `Started ${started}/${runnableCells} code cell(s) in ${formatNotebookLabel(notebookData)}.${failedToStart > 0 ? ` ${failedToStart} failed to start.` : ""}`; + }; + + const help = () => + [ + "runme.getCurrentNotebook() - Return the active notebook handle", + "runme.clearOutputs([notebookOrUri]) - Clear all outputs in a notebook", + "runme.runAll([notebookOrUri]) - Run all non-empty code cells", + "runme.help() - Show this help", + ].join("\n"); + + return { + getCurrentNotebook, + clearOutputs, + runAll, + help, + }; +}