From 3443f77ace492949d34453a231b167bfc1a35711 Mon Sep 17 00:00:00 2001 From: Hamel Husain Date: Fri, 13 Feb 2026 07:05:34 -0800 Subject: [PATCH 1/3] feat(app-console): add runme helpers for run-all and clear-outputs --- app/src/components/Actions/Actions.tsx | 3 + app/src/components/AppConsole/AppConsole.tsx | 68 +++++++ app/src/lib/runtime/jsKernel.ts | 37 ++-- app/src/lib/runtime/runmeConsole.test.ts | 175 +++++++++++++++++++ app/src/lib/runtime/runmeConsole.ts | 146 ++++++++++++++++ 5 files changed, 413 insertions(+), 16 deletions(-) create mode 100644 app/src/lib/runtime/runmeConsole.test.ts create mode 100644 app/src/lib/runtime/runmeConsole.ts 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..c7faf8d 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(); @@ -293,6 +357,7 @@ export default function AppConsole() { help: () => { return [ "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", @@ -363,11 +428,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..f18dfb4 --- /dev/null +++ b/app/src/lib/runtime/runmeConsole.test.ts @@ -0,0 +1,175 @@ +// @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(), + ) { + for (const cell of notebook.cells ?? []) { + if (!cell?.refId) { + continue; + } + let runID = ""; + const runner: FakeCellRunner = { + calls: 0, + run: () => { + runner.calls += 1; + runID = this.failedRunRefIds.has(cell.refId) + ? "" + : `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); + }); +}); diff --git a/app/src/lib/runtime/runmeConsole.ts b/app/src/lib/runtime/runmeConsole.ts new file mode 100644 index 0000000..0c54e04 --- /dev/null +++ b/app/src/lib/runtime/runmeConsole.ts @@ -0,0 +1,146 @@ +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; + } + + cellData.run(); + const runID = cellData.getRunID(); + if (runID) { + 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([notebook]) - Clear all outputs in a notebook", + "runme.runAll([notebook]) - Run all non-empty code cells", + "runme.help() - Show this help", + ].join("\n"); + + return { + getCurrentNotebook, + clearOutputs, + runAll, + help, + }; +} From dee203d1f123365d434f94087c01f32058a46447 Mon Sep 17 00:00:00 2001 From: Hamel Husain Date: Fri, 13 Feb 2026 07:15:10 -0800 Subject: [PATCH 2/3] fix(app-console): print namespace help output --- app/src/components/AppConsole/AppConsole.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/components/AppConsole/AppConsole.tsx b/app/src/components/AppConsole/AppConsole.tsx index c7faf8d..1a7b5ef 100644 --- a/app/src/components/AppConsole/AppConsole.tsx +++ b/app/src/components/AppConsole/AppConsole.tsx @@ -355,7 +355,7 @@ export default function AppConsole() { }, }, help: () => { - return [ + const message = [ "Available namespaces:", " runme - Notebook helpers (run all, clear outputs)", " explorer - Manage workspace folders and notebooks", @@ -367,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) => { From 75d32a2754bc719ba802acbbd0a159e88af44450 Mon Sep 17 00:00:00 2001 From: Hamel Husain Date: Fri, 13 Feb 2026 08:26:35 -0800 Subject: [PATCH 3/3] fix(runme-console): detect stale run IDs and clarify help --- app/src/lib/runtime/runmeConsole.test.ts | 44 +++++++++++++++++++++--- app/src/lib/runtime/runmeConsole.ts | 7 ++-- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/app/src/lib/runtime/runmeConsole.test.ts b/app/src/lib/runtime/runmeConsole.test.ts index f18dfb4..c9cfcf3 100644 --- a/app/src/lib/runtime/runmeConsole.test.ts +++ b/app/src/lib/runtime/runmeConsole.test.ts @@ -24,19 +24,21 @@ class FakeNotebookData implements NotebookDataLike { 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 = ""; + let runID = this.initialRunIds.get(cell.refId) ?? ""; const runner: FakeCellRunner = { calls: 0, run: () => { runner.calls += 1; - runID = this.failedRunRefIds.has(cell.refId) - ? "" - : `run-${cell.refId}-${runner.calls}`; + if (this.failedRunRefIds.has(cell.refId)) { + return; + } + runID = `run-${cell.refId}-${runner.calls}`; }, getRunID: () => runID, }; @@ -172,4 +174,38 @@ describe("createRunmeConsoleApi", () => { 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 index 0c54e04..930c519 100644 --- a/app/src/lib/runtime/runmeConsole.ts +++ b/app/src/lib/runtime/runmeConsole.ts @@ -113,9 +113,10 @@ export function createRunmeConsoleApi({ continue; } + const previousRunID = cellData.getRunID(); cellData.run(); const runID = cellData.getRunID(); - if (runID) { + if (runID && runID !== previousRunID) { started += 1; } else { failedToStart += 1; @@ -132,8 +133,8 @@ export function createRunmeConsoleApi({ const help = () => [ "runme.getCurrentNotebook() - Return the active notebook handle", - "runme.clearOutputs([notebook]) - Clear all outputs in a notebook", - "runme.runAll([notebook]) - Run all non-empty code cells", + "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");