Skip to content

feat(cli): add deepnote run --cloud to run notebooks in Deepnote Cloud - #417

Merged
jamesbhobbs merged 23 commits into
mainfrom
feat/run-cloud
Jul 15, 2026
Merged

jamesbhobbs merged 23 commits into
mainfrom
feat/run-cloud

Conversation

@jamesbhobbs

@jamesbhobbs jamesbhobbs commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Stacked on #418 (input-coercion fix) → this PR → #419 (@deepnote/local-runner). Review/merge in that order.

What

Two things:

  1. A --cloud flag on deepnote run that runs an existing notebook in Deepnote Cloud and downloads the resulting snapshot locally — all in one command.
  2. Extracts the cloud runs client into a new shared workspace package, @deepnote/cloud, so it isn't buried in the CLI (#419 consumes it too).
deepnote run --cloud --notebook-id <uuid>        # run a remote notebook by id
deepnote run my-project.deepnote --cloud         # notebook id read from the file
deepnote run my-project.deepnote --cloud --input name=Alice -o json

It triggers POST /v2/runs, polls GET /v2/runs/{runId} to completion, then writes the snapshot into the same local snapshots/ convention local runs use — so deepnote diff and the MCP snapshot tools work on it immediately.

Why

The CLI could only execute notebooks locally. This adds first-class cloud execution against the public API, reusing the existing DEEPNOTE_TOKEN / --token auth. Pulling the client into @deepnote/cloud keeps the CLI thin and lets @deepnote/local-runner (#419) share the exact same, already-tested client.

Scope

@deepnote/cloud (new package)

Bearer-auth client for the Deepnote Cloud runs API, extracted from packages/cli/src/utils/cloud-runs.ts into packages/cloud/src/cloud-runs.ts with no behaviour change:

  • triggerNotebookRun, getRun, pollRunUntilComplete (429/5xx backoff, per-request + total timeout, RunTimeoutError carrying the runId)
  • fetchSnapshotContent (cross-origin-safe: no bearer on presigned S3 URLs)
  • isSuccessStatus, describeRunError, and shared types

Response schemas are permissive (.passthrough(), both {run:{…}} and top-level envelopes, inline + downloadUrl snapshot delivery) so the preview API can drift without breaking the client.

CLI design

  • commands/run-cloud.ts — orchestration: notebook-id resolution (--notebook-id--notebook <name> → single/main notebook → error on ambiguity), .env load, blank-token-as-missing, snapshot parse (snapshot-doc → full-file split → raw fallback), timestamped + latest writes, terminal-failure → exit 1 preserving runId/status/snapshotPath, -o json/-o toon output.
  • utils/parse-inputs.ts — extracted shared helper (avoids a runrun-cloud import cycle).
  • run.ts / cli.ts — new options, early dispatch before the local ExecutionEngine path, incompatible-flag guard (--python/--cwd/--top/--profile/--open/--prompt/--dry-run/--list-inputs/--context rejected in cloud mode), help + examples.
  • Docs updated: skills/deepnote/references/cli-run.md and packages/cli/README.md.

Machine output

{ success, runId, status, snapshotPath?, timestampedSnapshotPath?, error? } for both -o json and -o toon. A completed run with status error/internal_error/stopped exits 1 while still reporting the runId, status, and any snapshot path.

Testing

  • @deepnote/cloud, @deepnote/cli, and @deepnote/blocks suites green; typecheck, biome, and prettier clean; package builds (skill schema regenerated).
  • CLI wiring verified from source: run --help shows the flags and hides --push; usage guards (incompatible flag, missing/blank token, --push, non-.deepnote) all exit 2.
  • Verified live against the Deepnote Cloud API with a real token: trigger → poll → snapshot download round-trips (the GET-run response returns snapshot fields flat, which the schema handles).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Deepnote Cloud execution support to deepnote run (run existing cloud notebooks by ID or local .deepnote), including snapshot download, output targeting, and configurable timeouts.
    • Added typed --input handling for Cloud runs and updated machine-readable Cloud run results.
    • Introduced a Cloud client library to trigger runs, poll status, and fetch snapshots.
  • Documentation
    • Updated CLI and Cloud API docs with Cloud options, authentication, input rules, and snapshot behavior.
  • Bug Fixes
    • Improved validation for inputs, notebook selection, and timeout values; enhanced Cloud error handling and exit-code behavior.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f3257a49-3103-4a89-9ba3-c4805ffc67be

📥 Commits

Reviewing files that changed from the base of the PR and between 5959b21 and 77766eb.

📒 Files selected for processing (2)
  • packages/cli/src/utils/run-in-cloud.test.ts
  • packages/cli/src/utils/run-in-cloud.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/cli/src/utils/run-in-cloud.ts
  • packages/cli/src/utils/run-in-cloud.test.ts

📝 Walkthrough

Walkthrough

The CLI now supports deepnote run --cloud, including notebook resolution, typed inputs, timeout validation, authenticated run polling, snapshot retrieval, output writing, and exit-code handling. A new @deepnote/cloud package provides the runs API client and public exports. Shared input and notebook-scope utilities replace local implementations. Tests, shell completions, package metadata, and Cloud execution documentation were added.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant runInDeepnoteCloud
  participant DeepnoteRunsAPI
  participant SnapshotStorage
  CLI->>runInDeepnoteCloud: execute --cloud options
  runInDeepnoteCloud->>DeepnoteRunsAPI: POST /v2/runs
  DeepnoteRunsAPI-->>runInDeepnoteCloud: return run id
  runInDeepnoteCloud->>DeepnoteRunsAPI: poll GET /v2/runs/{runId}
  DeepnoteRunsAPI-->>runInDeepnoteCloud: return terminal status and snapshot
  runInDeepnoteCloud->>SnapshotStorage: save snapshot
Loading

Possibly related PRs

  • deepnote/deepnote#418: Addresses applying and validating CLI input overrides through input blocks, overlapping with this PR’s typed --input handling.

Suggested reviewers: dinohamzic

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding deepnote run --cloud support in the CLI.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Updates Docs ✅ Passed README and skill docs were updated for --cloud/@deepnote/cloud; I couldn't verify the private internal landing page, so please update that too.

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.09184% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.85%. Comparing base (ef3907b) to head (77766eb).

Files with missing lines Patch % Lines
packages/cli/src/utils/run-in-cloud.ts 88.63% 20 Missing ⚠️
packages/cloud/src/cloud-runs.ts 93.43% 9 Missing ⚠️
packages/cli/src/commands/run.ts 83.33% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #417      +/-   ##
==========================================
+ Coverage   86.71%   86.85%   +0.14%     
==========================================
  Files         160      166       +6     
  Lines        8435     8766     +331     
  Branches     2360     2412      +52     
==========================================
+ Hits         7314     7614     +300     
- Misses       1120     1151      +31     
  Partials        1        1              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/README.md`:
- Line 160: The CLI README documents an incomplete default API URL. Update the
`--url` entry to use the complete `DEFAULT_API_URL` value, including the
`https://` scheme, and ensure it matches the URL documented in
`skills/deepnote/references/cli-run.md`.

In `@packages/cli/src/cli.ts`:
- Around line 39-45: Update parseTimeoutSeconds to validate the entire input as
a positive integer rather than relying on Number.parseInt, rejecting values such
as “1.5” and “10s”; parse the validated value and require
Number.isSafeInteger(seconds) with seconds > 0 before returning it.

In `@packages/cli/src/commands/run-cloud.ts`:
- Around line 284-286: Handle the llm value explicitly in the cloud-mode output
logic around isMachineOutput and the result serialization branch: either
implement LLM-formatted output consistently with json and toon, or reject
--output llm with a clear validation error before execution. Ensure it does not
silently fall through to human-readable output or suppress the spinner.
- Around line 319-344: Update the snapshot handling around fetchSnapshotContent
and writeCloudSnapshot so any retrieval error, persistence error, or missing
snapshot content marks the command as failed and produces a nonzero exit status.
Remove the best-effort behavior and ensure the failure path stops the spinner,
reports a clear warning/error, and prevents machine clients from receiving a
successful result when no artifact was saved.

In `@packages/cli/src/commands/run.ts`:
- Around line 502-503: The debug logging in the run command exposes raw --input
and --prompt values. Update the safeOptions construction before debug() to
redact those fields or replace them with non-sensitive metadata such as presence
or counts, while retaining token redaction and ensuring
JSON.stringify(safeOptions) never contains their contents.

In `@packages/cli/src/utils/cloud-runs.ts`:
- Around line 322-326: Update the URL comparison in the download request logic
to compare origins, not hosts, before applying authHeaders(options.token). Use
the resolved URL’s origin and the base URL’s origin so differing protocols such
as HTTP and HTTPS are treated as cross-origin.
- Around line 256-290: Enforce timeoutMs as the total polling deadline in the
cloud-run polling loop. Before each getRun call, sleep, retry backoff, and
terminal return, calculate remaining time from deadline and cap
requestTimeoutMs, backoff, and interval sleeps to that value; re-check the
deadline before fetching and returning, throwing RunTimeoutError with runId and
lastStatus when exhausted. Update the logic around getRun and the existing retry
handling in the polling function.

In `@skills/deepnote/references/cli-run.md`:
- Around line 87-90: Clarify the `-o llm` behavior for cloud runs: either update
`runInDeepnoteCloud` to produce LLM-formatted output alongside `json` and
`toon`, or explicitly document `llm` as unsupported for cloud execution and
explain its fallback behavior in the output-format documentation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4fc660f5-6650-4281-9f32-1aba361c038b

📥 Commits

Reviewing files that changed from the base of the PR and between b521d4e and 66d60a5.

📒 Files selected for processing (11)
  • packages/cli/README.md
  • packages/cli/src/cli.test.ts
  • packages/cli/src/cli.ts
  • packages/cli/src/commands/run-cloud.test.ts
  • packages/cli/src/commands/run-cloud.ts
  • packages/cli/src/commands/run.ts
  • packages/cli/src/utils/cloud-runs.test.ts
  • packages/cli/src/utils/cloud-runs.ts
  • packages/cli/src/utils/parse-inputs.test.ts
  • packages/cli/src/utils/parse-inputs.ts
  • skills/deepnote/references/cli-run.md

Comment thread packages/cli/README.md Outdated
Comment thread packages/cli/src/cli.ts Outdated
Comment thread packages/cli/src/utils/run-in-cloud.ts
Comment thread packages/cli/src/commands/run-cloud.ts Outdated
Comment thread packages/cli/src/commands/run.ts Outdated
Comment thread packages/cli/src/utils/cloud-runs.ts Outdated
Comment thread packages/cloud/src/cloud-runs.ts
Comment thread skills/deepnote/references/cli-run.md Outdated
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/cloud/src/cloud-runs.test.ts (1)

181-237: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test flat snapshot normalization through the API path.

These tests construct NormalizedRun.snapshot directly, so they would pass even if normalizeSnapshot stopped reading snapshotContent or snapshotDownloadUrl from API responses. Add getRun fixtures covering both flat inline content and flat download URLs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cloud/src/cloud-runs.test.ts` around lines 181 - 237, Extend the
fetchSnapshotContent tests to exercise normalization through getRun rather than
constructing NormalizedRun.snapshot directly. Add getRun fixtures for API
responses containing flat snapshotContent and flat snapshotDownloadUrl, then
verify the resulting content and fetch behavior for both inline and downloadable
snapshots.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cloud/src/cloud-runs.ts`:
- Around line 150-157: Update normalizeSnapshot so an empty snapshotContent is
treated as missing, allowing snapshotDownloadUrl to be selected when present;
preserve non-empty inline content precedence. Add a regression test covering
empty snapshotContent with an available download URL, including the
fetchSnapshotContent behavior.

---

Nitpick comments:
In `@packages/cloud/src/cloud-runs.test.ts`:
- Around line 181-237: Extend the fetchSnapshotContent tests to exercise
normalization through getRun rather than constructing NormalizedRun.snapshot
directly. Add getRun fixtures for API responses containing flat snapshotContent
and flat snapshotDownloadUrl, then verify the resulting content and fetch
behavior for both inline and downloadable snapshots.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 81f9a489-624c-4436-8efd-f903f5d4be94

📥 Commits

Reviewing files that changed from the base of the PR and between e993334 and f66baa2.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (8)
  • packages/cli/package.json
  • packages/cli/src/commands/run-cloud.ts
  • packages/cloud/README.md
  • packages/cloud/package.json
  • packages/cloud/src/cloud-runs.test.ts
  • packages/cloud/src/cloud-runs.ts
  • packages/cloud/src/index.ts
  • packages/cloud/tsdown.config.ts
✅ Files skipped from review due to trivial changes (3)
  • packages/cloud/src/index.ts
  • packages/cloud/tsdown.config.ts
  • packages/cloud/README.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/cli/src/commands/run-cloud.ts

Comment thread packages/cloud/src/cloud-runs.ts
jamesbhobbs and others added 8 commits July 11, 2026 13:23
`deepnote run -i <slider>=N` silently dropped the execution snapshot: the
override value was written to `deepnote_variable_value` verbatim (a number),
but the block schema requires a string, so `serializeDeepnoteSnapshot` threw
`Expected string, received number` and the best-effort save swallowed it.

Add a type-aware `coerceInputVariableValue(block, value)` schema-normalization
helper to @deepnote/blocks (slider/text/textarea/date/file → string; checkbox
strict boolean; select shape-only respecting multi-value; date-range arity),
and apply it in the CLI's `applyInputOverrides`. The kernel-injection payload
passed to `runProject({ inputs })` intentionally keeps native user values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a `--cloud` flag to the `run` command that triggers a cloud run of an
existing Deepnote notebook via the public API (`POST /v2/runs`), polls it to
completion (`GET /v2/runs/{runId}`), and downloads the resulting snapshot into
the local `snapshots/` convention — so `deepnote diff` and the MCP snapshot
tools work on it immediately.

The notebook to run is resolved from `--notebook-id`, else the local
`.deepnote` file's notebook (`--notebook <name>` or the single/main notebook).
The notebook must already exist in Deepnote; uploading local content
(`--push`) is a hidden, not-yet-implemented follow-up.

- New `utils/cloud-runs.ts`: Bearer-auth client mirroring `fetchIntegrations`
  with drift-tolerant polling (429/5xx backoff, per-request + total timeout,
  `RunTimeoutError` carrying the runId) and cross-origin-safe snapshot download
  (no bearer on presigned S3 URLs).
- New `commands/run-cloud.ts`: orchestration — notebook-id resolution, `.env`
  load, blank-token-as-missing, snapshot parse (snapshot-doc → full-file split
  → raw fallback), timestamped + latest writes, terminal-failure → exit 1 while
  preserving runId/status/snapshotPath, `-o json`/`-o toon` output.
- `utils/parse-inputs.ts`: extracted shared helper (avoids a run↔run-cloud cycle).
- `run.ts` / `cli.ts`: options, early dispatch, incompatible-flag guard, help.
- Docs: `skills/deepnote/references/cli-run.md` + `packages/cli/README.md`.

The runs API is in preview, so response schemas are intentionally permissive
and the exact snapshot field names / `detached` requirement should be confirmed
against a live token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… origins

Cap per-request timeout, backoff, and interval sleeps to the remaining deadline so `timeoutMs` is a true total wait, and re-check the deadline before each request. Compare URL origins (not hosts) before attaching the bearer token to a snapshot download, so a same-host http:// URL is treated as cross-origin. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e saved

Downloading the snapshot is the command's contract, so a successful run whose snapshot is missing, unretrievable, or unwritable now exits 1 with success:false and an error (rather than best-effort success). A run that already failed keeps a missing snapshot non-fatal. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These can hold secrets or PII and were emitted to stderr in debug mode; log only presence/count now, keeping token redaction. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validate the whole string and require a safe positive integer so `1.5` and `10s` are rejected instead of silently truncated. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…re for cloud runs

Use the full `https://api.deepnote.com` default in the README, note that `-o llm` resolves to `toon`, and document that a successful run whose snapshot can't be saved exits 1. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/cloud package

Move the Deepnote Cloud runs client (trigger/poll/get/fetch-snapshot) out of
cli/utils/cloud-runs.ts into a new @deepnote/cloud package so it can be shared,
and fix snapshot fetching along the way: GET /v2/runs/{runId} returns the
snapshot on flat run.snapshotContent / run.snapshotDownloadUrl fields, not
nested under `snapshot`, so normalizeRun now reads them and `deepnote run
--cloud` actually retrieves outputs. run-cloud imports from @deepnote/cloud;
parseApiErrorMessage is inlined so the package has no CLI dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/cli/src/commands/run.test.ts (1)

2905-2926: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use dedent for the multiline YAML fixture.

Wrap this template literal with ts-dedent so its formatting remains independent of surrounding source indentation.

As per coding guidelines, **/*.ts files should use ts-dedent for clean multiline template strings.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/run.test.ts` around lines 2905 - 2926, Update the
multiline YAML fixture passed to deserializeDeepnoteFile in the test by wrapping
its template literal with ts-dedent. Preserve the fixture contents while
removing dependence on surrounding source indentation, and follow the existing
ts-dedent import or usage conventions in the test file.

Source: Coding guidelines

packages/blocks/src/blocks/input-blocks.test.ts (1)

535-537: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the non-input fixture type-safe.

as unknown as DeepnoteBlock bypasses strict checking and can make this test pass with a block shape that production code would never receive. Construct a schema-valid code block or parse the fixture with deepnoteBlockSchema instead.

As per coding guidelines, TypeScript should prefer type safety over convenience and avoid unsafe shortcuts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/blocks/src/blocks/input-blocks.test.ts` around lines 535 - 537,
Replace the unsafe cast in the codeBlock fixture test with a schema-valid
DeepnoteBlock construction, or parse the fixture through deepnoteBlockSchema
before passing it to coerceInputVariableValue. Keep the test’s code-block type
and metadata behavior unchanged while ensuring the fixture is validated without
as unknown as.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/commands/run-cloud.test.ts`:
- Around line 43-71: Update installFetch to capture the init.headers for
requests matching cfg.downloadUrl, then assert in the relevant cross-origin
download tests that the captured headers do not contain Authorization. Preserve
the existing download response behavior and ensure the assertion verifies
DEEPNOTE_TOKEN is not forwarded.

In `@packages/cli/src/commands/run-cloud.ts`:
- Line 217: Sanitize the API-provided run ID before constructing snapshotPath in
the run command, ensuring path separators and traversal components cannot escape
the intended snapshots directory. Update the filename generation around
snapshotPath to use a safe run ID while preserving the existing snapshot naming
format.
- Around line 151-169: Sanitize the runId before constructing the fallback
snapshot filename in the run command, ensuring path separators or path-like
input cannot escape the snapshots directory; prefer validating against the
allowed ID format or removing separators. Add a regression test covering a
malicious runId and verify the resolved path remains inside ./snapshots.

---

Nitpick comments:
In `@packages/blocks/src/blocks/input-blocks.test.ts`:
- Around line 535-537: Replace the unsafe cast in the codeBlock fixture test
with a schema-valid DeepnoteBlock construction, or parse the fixture through
deepnoteBlockSchema before passing it to coerceInputVariableValue. Keep the
test’s code-block type and metadata behavior unchanged while ensuring the
fixture is validated without as unknown as.

In `@packages/cli/src/commands/run.test.ts`:
- Around line 2905-2926: Update the multiline YAML fixture passed to
deserializeDeepnoteFile in the test by wrapping its template literal with
ts-dedent. Preserve the fixture contents while removing dependence on
surrounding source indentation, and follow the existing ts-dedent import or
usage conventions in the test file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c3c84b15-a5c9-4201-8e81-51e238ba7a73

📥 Commits

Reviewing files that changed from the base of the PR and between f66baa2 and a2dc7bc.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (20)
  • packages/blocks/src/blocks/input-blocks.test.ts
  • packages/blocks/src/blocks/input-blocks.ts
  • packages/blocks/src/index.ts
  • packages/cli/README.md
  • packages/cli/package.json
  • packages/cli/src/cli.test.ts
  • packages/cli/src/cli.ts
  • packages/cli/src/commands/run-cloud.test.ts
  • packages/cli/src/commands/run-cloud.ts
  • packages/cli/src/commands/run.test.ts
  • packages/cli/src/commands/run.ts
  • packages/cli/src/utils/parse-inputs.test.ts
  • packages/cli/src/utils/parse-inputs.ts
  • packages/cloud/README.md
  • packages/cloud/package.json
  • packages/cloud/src/cloud-runs.test.ts
  • packages/cloud/src/cloud-runs.ts
  • packages/cloud/src/index.ts
  • packages/cloud/tsdown.config.ts
  • skills/deepnote/references/cli-run.md
✅ Files skipped from review due to trivial changes (5)
  • packages/cloud/src/index.ts
  • packages/cloud/README.md
  • packages/cli/README.md
  • packages/cloud/package.json
  • skills/deepnote/references/cli-run.md
🚧 Files skipped from review as they are similar to previous changes (9)
  • packages/cloud/tsdown.config.ts
  • packages/cli/package.json
  • packages/cli/src/cli.test.ts
  • packages/cli/src/utils/parse-inputs.test.ts
  • packages/cli/src/utils/parse-inputs.ts
  • packages/cloud/src/cloud-runs.test.ts
  • packages/cli/src/cli.ts
  • packages/cli/src/commands/run.ts
  • packages/cloud/src/cloud-runs.ts

Comment thread packages/cli/src/commands/run-cloud.test.ts Outdated
Comment thread packages/cli/src/utils/run-in-cloud.ts
Comment thread packages/cli/src/commands/run-cloud.ts Outdated
Addresses CodeRabbit review on #417:
- A path-like `runId` from the runs API could escape ./snapshots when used
  in the fallback filename via resolve(); sanitize it (allow [A-Za-z0-9_-],
  cap length) and build the path with join(). Adds a regression test with a
  malicious `../` runId.
- Assert the cross-origin snapshot download omits the Authorization header
  (the presigned S3 URL is a different origin; the bearer must not leak).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dinohamzic and others added 6 commits July 13, 2026 09:13
PR 418 replaced coerceInputVariableValue/applyInputOverrides with block-aware
input parsing (parseInputs(file, flags, notebook)) and override application
inside ExecutionEngine.injectInputs.

Resolution:
- run.ts: take 418's block-aware parseInputs/getInputBlocks; drop the deleted
  applyInputOverrides and its now-removed coerceInputVariableValue import.
- run.ts no longer imports the shared utils/parse-inputs helper; that generic
  parser stays for run-cloud.ts, which can run with --notebook-id and no local
  file to validate against.
- cli-run.md: keep both the new input-typing guidance and the --cloud section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/commands/run.ts`:
- Around line 608-616: Update the malformed input validation in createRunAction
to throw InvalidInputError instead of plain Error for both the missing “=” case
and empty-key case, using static messages that do not echo the full flag.
Preserve the existing InvalidInputError-to-ExitCode.InvalidUsage handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e8ea678c-e382-4d6c-b2af-6b101977a8e5

📥 Commits

Reviewing files that changed from the base of the PR and between a2dc7bc and 6bf6e1f.

📒 Files selected for processing (6)
  • packages/cli/README.md
  • packages/cli/src/cli.ts
  • packages/cli/src/commands/run-cloud.test.ts
  • packages/cli/src/commands/run-cloud.ts
  • packages/cli/src/commands/run.ts
  • skills/deepnote/references/cli-run.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/cli/src/cli.ts
  • skills/deepnote/references/cli-run.md
  • packages/cli/README.md
  • packages/cli/src/commands/run-cloud.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/commands/run.ts`:
- Around line 608-616: Update the malformed input validation in createRunAction
to throw InvalidInputError instead of plain Error for both the missing “=” case
and empty-key case, using static messages that do not echo the full flag.
Preserve the existing InvalidInputError-to-ExitCode.InvalidUsage handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e8ea678c-e382-4d6c-b2af-6b101977a8e5

📥 Commits

Reviewing files that changed from the base of the PR and between a2dc7bc and 6bf6e1f.

📒 Files selected for processing (6)
  • packages/cli/README.md
  • packages/cli/src/cli.ts
  • packages/cli/src/commands/run-cloud.test.ts
  • packages/cli/src/commands/run-cloud.ts
  • packages/cli/src/commands/run.ts
  • skills/deepnote/references/cli-run.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/cli/src/cli.ts
  • skills/deepnote/references/cli-run.md
  • packages/cli/README.md
  • packages/cli/src/commands/run-cloud.test.ts
🛑 Comments failed to post (1)
packages/cli/src/commands/run.ts (1)

608-616: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Classify malformed --input values as invalid usage.

These branches throw plain Error, while createRunAction only maps InvalidInputError to ExitCode.InvalidUsage; malformed flags therefore take the generic error exit path. Throw InvalidInputError here, preferably with a static message instead of echoing the full flag.

Proposed fix
   if (eqIndex === -1) {
-    throw new Error(`Invalid input format: "${flag}". Expected key=value`)
+    throw new InvalidInputError('Invalid input format. Expected key=value')
   }

   const key = flag.slice(0, eqIndex).trim()
   const rawValue = flag.slice(eqIndex + 1)

   if (!key) {
-    throw new Error(`Invalid input: empty key in "${flag}"`)
+    throw new InvalidInputError('Invalid input: key cannot be empty')
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    if (eqIndex === -1) {
      throw new InvalidInputError('Invalid input format. Expected key=value')
    }

    const key = flag.slice(0, eqIndex).trim()
    const rawValue = flag.slice(eqIndex + 1)

    if (!key) {
      throw new InvalidInputError('Invalid input: key cannot be empty')
    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/run.ts` around lines 608 - 616, Update the
malformed input validation in createRunAction to throw InvalidInputError instead
of plain Error for both the missing “=” case and empty-key case, using static
messages that do not echo the full flag. Preserve the existing
InvalidInputError-to-ExitCode.InvalidUsage handling.

Base automatically changed from fix/input-coercion-snapshot to main July 13, 2026 11:37
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 13, 2026
Comment thread packages/cli/src/cli.ts
Comment on lines +302 to +312
.option('--url <url>', 'API base URL (for integrations and cloud runs)', DEFAULT_API_URL)
.option('--token <token>', `Bearer token for the Deepnote API (or use ${DEEPNOTE_TOKEN_ENV} env var)`)
.option('--cloud', 'Run the notebook in Deepnote Cloud, then download the resulting snapshot locally')
.option('--notebook-id <uuid>', 'Cloud notebook id to run (with --cloud); alternative to a local .deepnote file')
.option('--out <path>', 'Write the downloaded cloud snapshot to this exact path (with --cloud)')
.option(
'--timeout <seconds>',
'Max seconds to wait for a cloud run to finish (with --cloud, default 600)',
parseTimeoutSeconds
)
.addOption(new Option('--push', 'Push a local notebook to Deepnote before running').hideHelp())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: these flags require the --cloud flag to be passed, but if the --cloud flag is not passed they are silently ignored. We should either validate (and error out), or use something like .implies({ cloud: true }), that includes the --cloud flag automatically.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 91d53bd--notebook-id, --out, and --timeout now error with a usage message (exit 2) when passed without --cloud, via a new assertCloudOnlyFlagsRequireCloud guard that mirrors the existing assertNoIncompatibleFlags (which rejects local-only flags in cloud mode). I went with validation rather than .implies({ cloud: true }) because auto-enabling cloud would silently turn e.g. a stray --timeout meant for a local run into a networked cloud run — a clear error felt less surprising. Please resolve.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: the hidden --push flag is in this same block and was also cloud-only, so I added it to the guard too (77766eb) — --push without --cloud now errors like the others, rather than silently running a local execution. So all four cloud-only flags (--notebook-id, --out, --timeout, --push) are covered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added push too which agent had missed (see later commit).

Comment on lines +14 to +32
/**
* Parses an error message from a Deepnote API response.
* Expects JSON responses with an `error` field, falls back to raw text.
*
* @param responseBody - Raw response body text
* @param fallback - Fallback message if parsing fails and body is empty
* @returns The extracted error message
*/
export function parseApiErrorMessage(responseBody: string, fallback: string): string {
try {
const json = JSON.parse(responseBody)
if (json.error && typeof json.error === 'string') {
return json.error
}
} catch {
// Not JSON, use raw body
}
return responseBody || fallback
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this function in the database-integrations packages? should it be moved to the cloud package?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It stays in @deepnote/database-integrations because it is shared, not cloud-specific: it sits next to the ApiError class it complements, and both packages/cloud/src/cloud-runs.ts and the CLI's packages/cli/src/utils/import-client.ts (the integrations import path, unrelated to cloud) consume it. database-integrations is a lower-level dependency of both cli and cloud, so moving the parser into cloud would invert the dependency — import-client.ts would have to pull in cloud just to parse an API error. Happy to extract ApiError + parseApiErrorMessage into a dedicated shared module if the naming bothers you, but cloud isn't the right home. Let me know — otherwise please resolve.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right on all three, and my earlier reasoning was wrong. cli already depends on @deepnote/cloud (it imports from it in run-in-cloud.ts), so there's no dependency to invert; the parser has no consumer inside database-integrations; and it has nothing to do with integrations specifically. Moved it to @deepnote/cloud in 5959b21 — its two callers are cloud-runs.ts (now a local import) and the CLI's import-client.ts (via the existing deepnote-api re-export, now pointing at @deepnote/cloud). ApiError stays in database-integrations, where fetch-integrations.ts still throws it; only the message parser moved. Please resolve.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear to anyone reading the above is Claude Code talking to itself.

--notebook-id, --out, and --timeout only affect the cloud run path, so
passing them without --cloud silently did nothing. Add a guard mirroring
assertNoIncompatibleFlags that rejects them with a CloudRunUsageError
(exit 2) before the local execution path runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 15, 2026
It was hoisted into @deepnote/database-integrations only so @deepnote/cloud
could share it, but it has no consumer there and nothing to do with database
integrations. Its only users are cloud-runs.ts and the CLI's import-client.ts,
and the CLI already depends on @deepnote/cloud — so cloud is the right home
with no new dependency edge or cycle. ApiError stays in database-integrations,
where fetch-integrations.ts still throws it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/cloud/src/parse-api-error.ts (1)

9-13: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Narrow the parsed JSON before reading error.

Line 11 makes json effectively any, bypassing strict TypeScript checks. Parse as unknown, verify it is a non-null object containing a non-empty string error, then return it.

Proposed fix
-    const json = JSON.parse(responseBody)
-    if (json.error && typeof json.error === 'string') {
+    const json: unknown = JSON.parse(responseBody)
+    if (
+      typeof json === 'object' &&
+      json !== null &&
+      'error' in json &&
+      typeof json.error === 'string' &&
+      json.error
+    ) {
       return json.error
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cloud/src/parse-api-error.ts` around lines 9 - 13, Update
parseApiErrorMessage so JSON.parse produces an unknown value, then narrow it to
a non-null object before accessing error. Return error only when it is a
non-empty string; otherwise preserve the existing fallback behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/cloud/src/parse-api-error.ts`:
- Around line 9-13: Update parseApiErrorMessage so JSON.parse produces an
unknown value, then narrow it to a non-null object before accessing error.
Return error only when it is a non-empty string; otherwise preserve the existing
fallback behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4c19d4ce-b69d-4754-b449-c57c712985e3

📥 Commits

Reviewing files that changed from the base of the PR and between 91d53bd and 5959b21.

📒 Files selected for processing (4)
  • packages/cli/src/utils/deepnote-api.ts
  • packages/cloud/src/cloud-runs.ts
  • packages/cloud/src/index.ts
  • packages/cloud/src/parse-api-error.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/cloud/src/index.ts
  • packages/cli/src/utils/deepnote-api.ts
  • packages/cloud/src/cloud-runs.ts

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 15, 2026
--push is a cloud-only flag (rejected as not-yet-implemented inside the
cloud path) but was missing from CLOUD_ONLY_FLAGS, so passing it without
--cloud silently ran a local execution. Add it to the guard so it fails
loudly like the other cloud-only flags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jamesbhobbs
jamesbhobbs merged commit c6fcb8c into main Jul 15, 2026
34 of 39 checks passed
@jamesbhobbs
jamesbhobbs deleted the feat/run-cloud branch July 15, 2026 14:21
jamesbhobbs added a commit that referenced this pull request Jul 15, 2026
#417 (feat/run-cloud) was squash-merged into main, so main re-adds the
parseApiErrorMessage export in packages/cloud/src/index.ts. Resolved by
keeping local-runner's superset (./import, ./parse-api-error, ./projects).
dinohamzic added a commit that referenced this pull request Jul 24, 2026
…ted inputs, locally or in Deepnote Cloud (#419)

* fix(cli): coerce input overrides to schema shape so snapshots serialize

`deepnote run -i <slider>=N` silently dropped the execution snapshot: the
override value was written to `deepnote_variable_value` verbatim (a number),
but the block schema requires a string, so `serializeDeepnoteSnapshot` threw
`Expected string, received number` and the best-effort save swallowed it.

Add a type-aware `coerceInputVariableValue(block, value)` schema-normalization
helper to @deepnote/blocks (slider/text/textarea/date/file → string; checkbox
strict boolean; select shape-only respecting multi-value; date-range arity),
and apply it in the CLI's `applyInputOverrides`. The kernel-injection payload
passed to `runProject({ inputs })` intentionally keeps native user values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cli): add `deepnote run --cloud` to run notebooks in Deepnote Cloud

Adds a `--cloud` flag to the `run` command that triggers a cloud run of an
existing Deepnote notebook via the public API (`POST /v2/runs`), polls it to
completion (`GET /v2/runs/{runId}`), and downloads the resulting snapshot into
the local `snapshots/` convention — so `deepnote diff` and the MCP snapshot
tools work on it immediately.

The notebook to run is resolved from `--notebook-id`, else the local
`.deepnote` file's notebook (`--notebook <name>` or the single/main notebook).
The notebook must already exist in Deepnote; uploading local content
(`--push`) is a hidden, not-yet-implemented follow-up.

- New `utils/cloud-runs.ts`: Bearer-auth client mirroring `fetchIntegrations`
  with drift-tolerant polling (429/5xx backoff, per-request + total timeout,
  `RunTimeoutError` carrying the runId) and cross-origin-safe snapshot download
  (no bearer on presigned S3 URLs).
- New `commands/run-cloud.ts`: orchestration — notebook-id resolution, `.env`
  load, blank-token-as-missing, snapshot parse (snapshot-doc → full-file split
  → raw fallback), timestamped + latest writes, terminal-failure → exit 1 while
  preserving runId/status/snapshotPath, `-o json`/`-o toon` output.
- `utils/parse-inputs.ts`: extracted shared helper (avoids a run↔run-cloud cycle).
- `run.ts` / `cli.ts`: options, early dispatch, incompatible-flag guard, help.
- Docs: `skills/deepnote/references/cli-run.md` + `packages/cli/README.md`.

The runs API is in preview, so response schemas are intentionally permissive
and the exact snapshot field names / `detached` requirement should be confirmed
against a live token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): cap cloud-run polling to the total deadline and compare URL origins

Cap per-request timeout, backoff, and interval sleeps to the remaining deadline so `timeoutMs` is a true total wait, and re-check the deadline before each request. Compare URL origins (not hosts) before attaching the bearer token to a snapshot download, so a same-host http:// URL is treated as cross-origin. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): fail the cloud run when a successful run's snapshot can't be saved

Downloading the snapshot is the command's contract, so a successful run whose snapshot is missing, unretrievable, or unwritable now exits 1 with success:false and an error (rather than best-effort success). A run that already failed keeps a missing snapshot non-fatal. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): redact --input and --prompt values from debug logging

These can hold secrets or PII and were emitted to stderr in debug mode; log only presence/count now, keeping token redaction. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): reject partially-numeric --timeout values

Validate the whole string and require a safe positive integer so `1.5` and `10s` are rejected instead of silently truncated. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cli): complete --url default and clarify -o llm / snapshot-failure for cloud runs

Use the full `https://api.deepnote.com` default in the README, note that `-o llm` resolves to `toon`, and document that a successful run whose snapshot can't be saved exits 1. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cloud): extract the run-cloud client into a shared @deepnote/cloud package

Move the Deepnote Cloud runs client (trigger/poll/get/fetch-snapshot) out of
cli/utils/cloud-runs.ts into a new @deepnote/cloud package so it can be shared,
and fix snapshot fetching along the way: GET /v2/runs/{runId} returns the
snapshot on flat run.snapshotContent / run.snapshotDownloadUrl fields, not
nested under `snapshot`, so normalizeRun now reads them and `deepnote run
--cloud` actually retrieves outputs. run-cloud imports from @deepnote/cloud;
parseApiErrorMessage is inlined so the package has no CLI dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): sanitize API-provided runId in the cloud snapshot filename

Addresses CodeRabbit review on #417:
- A path-like `runId` from the runs API could escape ./snapshots when used
  in the fallback filename via resolve(); sanitize it (allow [A-Za-z0-9_-],
  cap length) and build the path with join(). Adds a regression test with a
  malicious `../` runId.
- Assert the cross-origin snapshot download omits the Authorization header
  (the presigned S3 URL is a different origin; the bearer must not leak).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cloud): add notebook lookup, workspace, and upload helpers

Extend the shared @deepnote/cloud client for consumers that need more than a
plain run-by-id: findNotebook (resolve notebook + project by name via
GET /v2/projects), getWorkspace (GET /v2/me), a notebookUrl builder (runs view),
and uploadNotebook (the "Open in Deepnote" import: POST /v1/import/init +
presigned PUT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(local-runner): add @deepnote/local-runner (run a .deepnote with edited inputs)

Runs a .deepnote with input overrides — locally or in Deepnote Cloud — reusing
@deepnote/blocks, @deepnote/runtime-core, @deepnote/convert, and @deepnote/cloud:
- runWithInputs: local kernel run; coerces inputs to schema shape; returns
  outputs + a snapshot (persisted by default, like `deepnote run`).
- runInCloud: cloud run — resolve id (or find by name), coerce inputs, poll,
  fetch snapshot; upload if missing ("Open in Deepnote"); returns outputs + a
  viewUrl.
- serveStatic: a small node:http helper (GET /api/info, POST /api/run and
  /api/run-cloud) so a static page can drive it. No bundled UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* demo(local-runner): add the static run-from-a-web-page example

A ~10-line serve.mjs + a single index.html driving local and cloud runs via @deepnote/local-runner. Example only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(local-runner): harden serve-static, validate config, and fix demo XSS

Addresses CodeRabbit review on #419:
- serve-static: validate `inputs` is an object (400), return 400 on malformed
  path encoding, resolve symlinks + reject escapes (403) and directories (404)
  and read files before writing the 200 header, and cap request bodies with a
  413 (counting bytes; reject instead of hanging on over-limit/aborted).
- run-with-inputs: reject `persistSnapshot: true` with a path-less input before
  starting the engine, so an invalid config can't trigger execution.
- cloud/import: add a timeout to the presigned-URL upload PUT.
- demo: build the "view runs" link via DOM APIs instead of innerHTML, and flag
  the text/html output as an XSS sink for untrusted notebooks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): align input overrides with block schemas

* refactor: simplify

* refactor: simplify

* refactor: simplify

* refactor: remove references to outdated implementation

* fix(local-runner): adapt input coercion to the new block-aware override contract

The base branch removed coerceInputVariableValue from @deepnote/blocks: the CLI
now requires already-schema-shaped input values and ExecutionEngine validates
overrides against the input block's schema before applying them.

local-runner is driven by a UI, which yields native values (a range control gives
a number, a checkbox a boolean), so coercion is still load-bearing here:

- Add coerce-input-value.ts, porting the removed coercion and asserting the result
  against getInputBlockValueOverrideValidationError — the same contract the engine
  enforces — so a coerced value can never be rejected downstream.
- applyInputOverrides now returns the coerced values; runWithInputs passes those to
  the engine instead of the raw ones (a raw 7 for a slider would now be rejected).
  Names with no input block still pass through for generic kernel injection.
- run-in-cloud coerces via the same helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(local-runner): cover every branch of input-value coercion

Includes the validator safety net: Infinity/NaN coerce to a string, but not a
numeric one, so the schema-shape check rejects them before they reach the kernel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cli): type --input the same way for cloud runs as for local runs

--input previously meant two different things: local runs typed each value by the
input block it names (a slider takes '7', a checkbox true/false, a multi-select a
JSON string array) and rejected unknown names, while --cloud fell back to a generic
JSON parse that would send the number 42 for a slider whose schema requires '42'.

Hoist the block-aware parser out of run.ts into utils/parse-inputs.ts (with the
notebook-scope helper it needs) and use it from both paths, so there is one set of
rules and one set of error messages. Cloud inputs are typed against the notebook
that will actually run.

Typing needs the notebook's blocks, and the only copy we have is the local file, so
--input now requires it: passing only --notebook-id with --input is a usage error
rather than an unchecked payload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(cli,cloud): align the cloud-run feature with repo conventions

An audit against the existing packages/commands/utils turned up several deviations
and one real bug:

- run.ts's notebook-scope helper threw a bare Error, so `run --notebook <unknown>`
  exited 1 while `cat --notebook <unknown>` exits 2. Throw the repo's
  NotFoundInProjectError and map it in createRunAction. (+ tests)
- cloud-runs normalizeRun used schema.parse, leaking a raw ZodError to users on an
  unexpected preview-API payload. Use safeParse + ApiError, as fetchIntegrations
  does. (+ test)
- parseApiErrorMessage was duplicated verbatim in @deepnote/cloud. Hoist it into
  @deepnote/database-integrations next to the ApiError it builds messages for.
- run-cloud.ts was the only file in commands/ that is not a Commander action
  factory. Move it to utils/run-in-cloud.ts, mirroring utils/open-file-in-cloud.ts.
- RunCloudOptions was a hand-maintained copy of RunOptions. Alias it: the import is
  type-only, so it adds no runtime dependency and cannot drift.
- parseTimeoutSeconds was inline in cli.ts; every other validator lives in utils/
  with tests (cf. utils/format-validator.ts). Move it there. (+ tests)
- packages/cloud: pin zod exactly (3.25.76) like the other 6 packages instead of
  floating on ^3.23.8; drop devDeps the root already provides; use an explicit
  barrel instead of the repo's only bare `export *`; give the README the standard
  shape.
- Register the new run flags in shell completions; fix --url/--token help text that
  still said 'for fetching integrations'; list @deepnote/cloud in AGENTS.md and the
  CLI README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(cloud): reword test title to satisfy cspell

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(local-runner): read and view snapshots with no kernel

A snapshot is a .deepnote with the outputs stored inline, so reading one is
parsing, not executing — no Python, no ExecutionEngine, no toolkit.

- snapshot-view.ts: parseSnapshot/toSnapshotView, browser-safe (no node imports).
  Reads outputs from every executable block — code, sql, visualization, big-number
  — not just code, which is what the private extractOutputs dropped. Also surfaces
  each input block's value, so a reader can see what produced the outputs.
- read-snapshot.ts: readSnapshot(path | yaml | object), the Node convenience.
- snapshot-viewer.ts + browser.ts: a self-contained browser bundle (yaml parser,
  schemas, renderer) that fetches a snapshot and renders it. Ships as
  @deepnote/local-runner/snapshot-viewer.
- examples/snapshot-viewer: index.html + snapshot.deepnote, served anywhere static.

HTML outputs render in a sandboxed iframe with scripts disabled rather than via
innerHTML: a snapshot you share must not run script in the reader's page.

The browser modules are excluded from the root tsconfig and typechecked by a
package tsconfig with the DOM lib — adding DOM to the root program makes lib.dom's
fetch/FormData collide with Node's across every package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(local-runner): keep the DOM out of the library

The viewer's renderer needed the DOM lib, which forced a per-package tsconfig and
a root-tsconfig exclude — and adding DOM to the root program instead would have
broken import-client.ts, a pre-existing file, by colliding lib.dom's fetch/FormData
with Node's across every package.

So the library ships the parser and the page owns the rendering, which is what
serveStatic already assumed ('bring your own page'):

- @deepnote/local-runner/snapshot-reader is the browser bundle: parseSnapshot and
  the schemas, no DOM.
- The ~60-line renderer moves into examples/snapshot-viewer/index.html as plain JS,
  where the existing local-runner demo already keeps its renderer.
- Drops packages/local-runner/tsconfig.json and the root tsconfig exclude; no DOM
  types anywhere in the workspace.

Also removes the untested DOM surface from the package, so patch coverage is back
to the tested parser (snapshot-view.ts: 100% of lines).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(examples): make snapshot outputs legible in the viewer

Found by rendering a real run (pandas table, matplotlib PNG, a failing block).

- HTML outputs sit in a sandboxed iframe, which is its own document and inherits
  none of the page's styles: it set borders and font but never color/background, so
  a pandas table rendered as default black text on the dark page showing through.
  Give the frame its own light/dark theme.
- Collapse tracebacks behind the exception line. A traceback is mostly library
  internals and buried the rest of the notebook.
- Dark mode missed the iframe border and the error block (dark red on near-white).

Also drop html from lint-staged's biome glob: biome does not process HTML, so with
ignoreUnknown:false it exits 1 on any staged .html and fails the pre-commit hook
outright — nothing could be committed alongside an HTML file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(examples): address review — sandbox the demo's HTML output, distinguish parse failures, a11y

- local-runner-demo: replace the unsanitized innerHTML text/html sink with the same
  sandboxed iframe the snapshot viewer uses. The earlier 'needs a DOMPurify
  dependency, out of scope' rationale no longer holds — a scripts-disabled iframe
  needs no dependency, so the sink goes rather than stays documented.
- snapshot-viewer: a snapshot that fetches but does not parse now reports a parse
  error instead of the misleading file:// hint, and the file picker catches parse
  failures instead of leaving an unhandled rejection.
- a11y: iframe title and img alt on generated outputs.
- .meta was unreadable in dark mode (#666 on #0d1117, ~3.3:1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): keep runId and status when the snapshot re-fetch fails

The run has already reached a terminal state by the time we re-fetch it for a
snapshot some deployments only attach then. Letting that re-fetch throw discarded
the runId and status the structured result is supposed to carry — the snapshot
error path already handles 'finished but no snapshot' properly, so route the
failure there instead of escaping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(local-runner): re-fetch a terminal run that lacks an inline snapshot

Mirrors the CLI's defensive re-fetch, which runInCloud was missing: some
deployments only attach the snapshot once the run is terminal, so a polled run
could come back successful with snapshotYaml: null and outputs: [] — a silently
empty success. Re-fetch once (on success only; a failed run has no snapshot to
wait for), and tolerate a failure there, since the run itself already finished.

Also extract inputBlocksByName, the input-block lookup that applyInputOverrides
and the cloud path's coerceInputs each had their own copy of, so the two cannot
drift. It maps a name to every block defining it, preserving applyInputOverrides'
handling of a name defined in more than one notebook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cli): reject cloud-only flags without --cloud

--notebook-id, --out, and --timeout only affect the cloud run path, so
passing them without --cloud silently did nothing. Add a guard mirroring
assertNoIncompatibleFlags that rejects them with a CloudRunUsageError
(exit 2) before the local execution path runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cloud): move parseApiErrorMessage into @deepnote/cloud

It was hoisted into @deepnote/database-integrations only so @deepnote/cloud
could share it, but it has no consumer there and nothing to do with database
integrations. Its only users are cloud-runs.ts and the CLI's import-client.ts,
and the CLI already depends on @deepnote/cloud — so cloud is the right home
with no new dependency edge or cycle. ApiError stays in database-integrations,
where fetch-integrations.ts still throws it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): include --push in the cloud-only flag guard

--push is a cloud-only flag (rejected as not-yet-implemented inside the
cloud path) but was missing from CLOUD_ONLY_FLAGS, so passing it without
--cloud silently ran a local execution. Add it to the guard so it fails
loudly like the other cloud-only flags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): scope input coercion, keep non-code cloud outputs, honor custom domain

Addresses three review findings on the cloud/local run paths:

- Major: input override coercion was not scoped to the notebook being run.
  applyInputOverrides coerced each name against the last same-named block
  across every notebook (and mutated them all), and the cloud path coerced
  against the first match. A name shared across notebooks with different input
  types could be coerced for the wrong block — failing the run or mutating an
  off-scope notebook. Coercion is now scoped to the target notebook (by name
  locally, by id in the cloud), and a name defined by incompatible in-scope
  types is rejected with a clear error instead of silently mis-coerced.

- Medium: runInCloud dropped non-code outputs. Cloud result extraction only
  kept block.type === 'code', so SQL/visualization/big-number outputs vanished.
  It now reuses parseSnapshot/toSnapshotView and flattens every block that
  carries outputs, in document order.

- Medium: the upload-if-missing fallback ignored a custom cloud domain — it
  always uploaded to deepnote.com even when baseUrl pointed elsewhere. It now
  derives the domain from baseUrl (reusing deriveDomain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cloud): direct tests for the import and projects API helpers

Addresses the review's test-gap finding: uploadNotebook, findNotebook,
getWorkspace, and notebookUrl were only exercised indirectly through mocks.
Adds direct tests covering URL construction, bearer-auth headers, request
bodies, zod schema handling (match/fallback/undefined), and ApiError bodies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): apply input overrides atomically

applyInputOverrides mutated blocks as it iterated, so a later coercion
failure left the file half-mutated — a caller that caught the error and
reused the file could persist partially applied overrides. Coerce and
validate every value first, then mutate blocks in a second pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): scope block-targeted runs to the block's notebook

A blockId/blockIds run with no --notebook coerced inputs across every
notebook, so a same-named input of a different type in another notebook
could be coerced against or mutated (and the engine injected into all of
them). Derive the notebook containing the targeted block(s) and scope both
the coercion and the engine run to it; when the blocks span more than one
notebook the run stays unscoped, as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(local-runner): stream agent-block events via onAgentEvent

runWithInputs already streams code-block outputs through onOutput, but an
agent block's output was only visible as one consolidated stdout chunk at
the end — the engine's per-token AgentStreamEvent stream (text/reasoning
deltas, tool calls) was not surfaced by the library, even though the CLI
already consumes it.

Add an onAgentEvent callback to RunWithInputsOptions, forward it to the
engine's runProject, and re-export AgentStreamEvent so a consumer can type
the handler. The agent's final text still lands in the snapshot outputs;
this is purely the live channel. Cloud runs remain poll-to-completion (the
runs API delivers no incremental agent events yet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(local-runner): polish the examples into two reference apps

Reworks the local-runner examples into a merge-ready pair under a shared
parent, examples/local-runner/{run-app,snapshot-viewer}, driven by two new
committed artifacts:

- examples/local-runner-showcase.deepnote — an input-rich sales dashboard
  (all input types; KPI, table, matplotlib chart, written summary) that is
  deterministic and key-free, so the run-app executes it live.
- examples/snapshot-showcase.snapshot.deepnote — that dashboard already run,
  plus an agent block with precomputed output, so the viewer shows agent-block
  support with no API key.

Both pages share a quiet, Deepnote-like visual language (app shell for the
run-app, a static report for the viewer; light + dark). The run-app gains a
proper inputs panel and a dashboard results canvas and now renders every input
control (text/textarea/select/slider/checkbox/date/date-range); the viewer
renders a metadata header, input chips, prominent outputs, and secondary
collapsed source.

One-command scripts (pnpm example:local-runner / example:snapshot-viewer) build
the package and serve; the viewer's new serve.mjs wires up the built reader and
the sample snapshot with no copy steps. The run-app no longer persists snapshots
(persistSnapshot: false), so trying it doesn't litter the repo. Drops the
"spike / not intended for merge" framing and updates the moved-path references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): scope inputs on the upload-if-missing fallback

runInCloud coerces/scopes normal cloud-run inputs to the target notebook,
but the not-found upload path called openInCloud, which applied the input
overrides across every notebook. For a multi-notebook file with a same-named
input of a different type, the first "upload if missing" click could fail or
mutate an unrelated notebook. Add a scope option to openInCloud (forwarded to
applyInputOverrides) and pass the target notebook from runInCloud.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(local-runner): fix stale README and harden the examples

- README no longer says "local execution only — no cloud" (the package
  exports runInCloud), the serveStatic snippet lists /api/run-cloud, and the
  HTML-output note describes the null-origin sandbox (allow-scripts is used
  only for iframe height reporting, not "scripts disabled").
- Showcase notebook escapes the free-text inputs (report_title, analyst_notes)
  with html.escape before interpolating them into HTML output.
- Both example pages validate postMessage height reports by sender: null
  origin and a matching iframe contentWindow, not just the id.
- The run app wraps the /api/info load so a failure shows the status-style
  error instead of a half-initialized page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(local-runner): run an agent block in the run-app demo (#421)

The run-app executed live but had no agent block, so the only agent block a
reader could see was the snapshot-viewer's precomputed one. Add a real agent
block to the showcase notebook: it writes an executive readout from what the
dashboard already computed, and both Run paths execute it for real.

It runs last, deliberately. The engine breaks at the first failing block, so
anywhere earlier a missing key would take the whole dashboard down with it;
at the end, a keyless run still renders every dashboard block and reports the
error on the agent block alone.

`deepnote_agent_model: auto` is what lets one notebook serve both paths —
locally it resolves to $OPENAI_MODEL (default gpt-5) against OPENAI_API_KEY,
while a cloud run needs only DEEPNOTE_TOKEN and Deepnote supplies the model.

serve.mjs now reads .env like `deepnote run` does, and prints which keys it
found, so a missing key is visible at startup rather than mid-run.

Verified both paths against the showcase notebook:
- local: failedBlocks 0; agent called add_code_block + add_markdown_block
- cloud: run 4005cab5 success; agent inserted a code block computing real
  month-over-month growth and a readout citing the analyst-notes input

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cloud): create notebooks headlessly, so a cloud run is one call

Running a notebook that wasn't in Deepnote yet took two attempts and a browser:
runInCloud uploaded the file via /v1/import, returned `needs-open` + a launchUrl,
and you finished the import by hand before running again.

That flow exists because /v1/import is unauthenticated — it sends no bearer token,
so only a logged-in browser session can say which workspace to import into. But
runInCloud already requires a token and throws without one. It is authenticated by
definition, so it had nothing to gain from the browser and was simply borrowing the
mechanism `deepnote open` needs.

Add createProject to @deepnote/cloud — POST /v2/projects, /v2/notebooks, /v2/blocks
against the authenticated public API — and have runInCloud create-and-run in a single
call, reporting `created: true`. The `needs-open` status and `launchUrl` field are
gone rather than deprecated: the package is unpublished, so this is free now and a
breaking change later.

It takes a plain ProjectSpec, not a DeepnoteFile, so @deepnote/cloud stays a thin
client with no domain types — and `deepnote run --cloud --push`, still unimplemented
on main, can build on this rather than reinvent it.

Two API details it has to absorb:
- POST /v2/projects seeds a project with an empty placeholder notebook, and nothing
  can rename one. It creates ours, then deletes the placeholders — in that order, so
  a project is never momentarily empty, and best-effort, so a stray one is a warning
  rather than a failed create.
- There is no bulk block endpoint, so blocks are one sequential request each to keep
  `position` meaningful. onProgress exists because 16 blocks is 16 round-trips before
  the run even starts.

`deepnote open`, the /v1 import client, and uploadNotebook are untouched — the
unauthenticated browser flow is still the right one when there is no token.

Verified against the live API from an empty workspace: created 16 blocks and ran
them in one call (created: true, success, 47.9s), inputs applied, agent block
executed, and no placeholder notebook left behind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(local-runner): show cloud run history in the run-app

The run-app knew nothing about a notebook's past runs, so a page sitting next
to a workspace full of successful runs still opened on "Edit the inputs, then
Run" — with the last run's outputs one fetch away.

Add listNotebookRuns and getCloudRun (@deepnote/cloud and @deepnote/local-runner
respectively), expose them as GET /api/cloud-runs and /api/cloud-runs/{runId},
and give the run-app a Cloud runs sidebar. It loads the newest successful run's
snapshot on open, and each row loads that run's outputs — read from the snapshot,
not re-executed.

The history is the notebook's real one, so runs started from the Deepnote UI show
up here too, not just runs from this page.

The two routes fail differently, on purpose. Listing answers `{ runs: [] }` when
there's no token or the notebook was never pushed: both are ordinary states for a
demo, not errors. Asking for a specific run by id and not getting it has no
sensible empty state, so it 502s.

Deepnote exposes no per-run URL — GET /v2/notebooks/{id}/runs returns runId,
status, createdAt, completedAt and nothing linkable. Rather than have every row
open the same page, rows load their own snapshot locally and the one external
link lives in the header.

Only cloud runs appear: the run-app runs locally with persistSnapshot: false, so
local runs leave no snapshot to list.

Verified against the live API: the history lists real runs, and the newest one
renders 7 output blocks from its 105KB snapshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(local-runner): split reading cloud runs out of run-in-cloud

run-in-cloud.ts had grown to 432 lines and three public functions, while every
other file in the package is one concern. I put listCloudRuns and getCloudRun
there because they reused its private helpers — a smaller diff at the cost of the
package's convention. Reading a notebook's run history is not running a notebook.

- cloud-runs.ts   — listCloudRuns + getCloudRun; nothing here executes anything
- cloud-common.ts — what both entry points share: token/base-URL resolution,
                    locating a notebook, and reading a snapshot into outputs
- run-in-cloud.ts — back to one public function (245 lines)

extractOutputs went to cloud-common rather than snapshot-view, despite looking
like a snapshot concern: its own docstring says "a cloud snapshot", nothing but
the cloud paths call it, and snapshot-view.ts is deliberately Node-free so it can
bundle for the browser via browser.ts. Even a type-only edge to run-with-inputs
is a tripwire for whoever refactors it next.

The token check was duplicated three times over; requireToken() now owns it and
names the caller in the error.

No behavior change. cloud-runs.ts also gets the direct unit tests these two never
had — they were only covered through serve-static's fakes.

Verified live, since a refactor is only as good as its behavior: from an empty
workspace, listCloudRuns returned the empty state, runInCloud created and ran
(created: true, success), listCloudRuns then found the run and built its viewUrl,
and getCloudRun read back 7 output blocks from an 85KB snapshot with the input
applied and the agent block executed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): don't create duplicates or run stale block ids

Review findings on the headless-create change, two of them real bugs it
introduced:

A swallowed lookup error became destructive. findNotebook was wrapped in
.catch(() => undefined), so any failure read as "not in Deepnote". That was
harmless when the fallback was a launchUrl; now the fallback creates a project,
so a transient /v2/projects blip would silently litter the workspace with
duplicates. Only a lookup that succeeds and finds nothing means absence — a
failed lookup is now just a failure.

Targeted runs ran the wrong blocks. --block ids come from the local file, but a
created notebook has ids Deepnote assigned, so they addressed nothing there.
createProject returns block ids in the order it was given them, so source blocks
map onto cloud ids positionally. An id that doesn't map throws: a targeted run
that quietly ran something else is worse than one that fails.

Also:
- The showcase agent prompt said "this quarter" while trailing_months spans 3-12,
  and told the agent to "take the analyst notes into account" — free text a user
  types. That is an injection surface: the notebook escapes that same input for
  HTML but the agent path had no equivalent care. The notes are now framed as
  commentary to weigh, never instructions.
- The run history rendered a failed past run as green "Showing a cloud run" over
  an empty canvas, which reads as success. It reports the failure now.
- Docs caught up with the headless change: the package README still said cloud
  runs need a notebook that already exists. The run-app README claimed output
  "can never touch this page" — it can postMessage, which is exactly how the
  height fix works, hence the origin+source check — and its slider example read
  backwards about which side holds the string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): stop conflating cloud and local ids, and mean it on errors

Second review pass. The theme is the same each time: a value that means two
things, or an error quietly turned into a fact.

options.notebookId addresses a notebook in Deepnote, but it was also used to
scope input coercion against the *local* file. Those are only the same id by
luck. notebooksInScope silently widens to every notebook when an id matches
none — harmless for one notebook, wrong for several, where a name defined
twice gets typed against whichever came first. A cloud id names no local
notebook as a matter of course, so that fallback was doing the deciding. The
two are now resolved separately, and an id that names none of several notebooks
is an error rather than a guess — unless there are no inputs, when it cannot
matter.

blockIds were still forwarded on the find-by-name path. The create path remaps
them; this one has no mapping to offer, because the notebook was matched by
name and Deepnote gave its blocks their own ids. It now refuses instead of
running something else.

listCloudRuns turned any lookup failure into "no runs". "Found nothing" and
"couldn't look" are different answers and { runs: [] } can only say the first.
The demo route keeps its own catch, so it stays quiet.

Malformed upstream responses bypassed the error contract three ways: JSON.parse
and response.json() threw raw SyntaxErrors past ApiError, and a snapshot that
would not parse became a successful run with no outputs — a claim about the
notebook, and a false one.

GET /api/cloud-runs/{runId} decoded its id outside the try, so bad percent-
encoding was a 500. It is a malformed request: 400.

The slider docs were wrong, twice, so this time I ran it: the snapshot stores
"6" because the schema says a slider's value is a string, but the block's
generated Python is `months = 6`, so the kernel has an int. The showcase's
"input values arrive as strings" comment said otherwise and is what I read it
from; both now say what the kernel actually gets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): inline the by-name blockIds guard

The helper it replaces was called rejectUnaddressableBlockIds, and cspell was
right that "Unaddressable" isn't a word — the pre-push spell-check caught it.
Rather than teach the dictionary a coinage, the guard now sits inline where the
by-name branch is, which is where a reader meets the constraint anyway.

No behavior change; folds into the previous commit if you squash on merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): refuse to guess which notebook a cloud id means

Third review pass, and the same two shapes again.

The ambiguity fix last round only covered coercion. But `notebookNameFor` has a
silent fallback of its own — to the first notebook — and both the by-name lookup
and the create path go through it. So an explicit cloud id against a
multi-notebook file with no inputs sailed past the guard and could find, or
create, the wrong notebook entirely. Which notebook *of the file* a request
means is now resolved in one place, and refused when unanswerable. Still only
asked where it matters: a run that neither coerces nor falls back never needs to
know, so passing a cloud id to a multi-notebook file keeps working.

createFromFile was being handed the cloud id to pick a local notebook by, which
is the same mistake one level down.

The run-app could paint an old run over a new one. The history load on open, a
run, and a click on a past run all render, and all finish whenever they like:
click Run quickly enough after opening and the auto-loaded history would land on
top of your fresh output. Each now takes a generation before it starts and drops
its result if something newer has claimed the canvas. That counter also replaces
`rendered` — "has anything claimed it" was the question all along.

Docs: the package README still said coerced slider strings are what the kernel
sees (it is `months = 7`, an int — the string is storage). runInCloud's docstring
still promised only missing config throws, which stopped being true when lookup
and parse failures started throwing rather than being swallowed; the line is now
about whether Deepnote ran the notebook and told us, not whether the news is good.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): identify the created notebook by position, not name

A .deepnote file may hold two notebooks with the same name — nothing forbids it —
and createFromFile matched the created notebook back by that name. Ask for the
second and you got the first: the run, and the block-id map with it, silently
pointed at the wrong notebook.

createProject creates notebooks in the order it was handed them, so position
identifies them and a name cannot. The same ordering already maps the blocks;
this just stops the notebook itself being the odd one out.

The regression fails on the old logic — it picks the first notebook, whose block
map has no id for the block being asked for, so the targeted run errors out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(local-runner): assert what the snapshot file contains, not just that it exists

The persistence test checked snapshotPath was defined, the file existed, and
something in snapshots/ ended in .snapshot.deepnote — all of which a truncated or
mis-serialized write would also satisfy.

runWithInputs builds the returned snapshot and the persisted one from the same
(file, outputs, timing) specifically so they cannot drift. That guarantee was
worth nothing while nothing checked it, so the test now reads the file back and
compares. Confirmed it catches drift by introducing some.

Raised by CodeRabbit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(local-runner): createFromFile no longer falls back to the first notebook

The docstring still described the behaviour the duplicate-name fix removed — it
was documenting the bug. The notebook is identified by position now, and a
mismatch throws rather than quietly running the first one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): explain a failed cloud run instead of shrugging at it

A real run failed and neither the user nor I could say why. It took reading the
raw API to find the reason, which was sitting in the snapshot we had already
decided not to fetch.

A failed run was a dead end by construction, at the one moment information is
worth something:

- The snapshot was fetched only on success. `const snapshotYaml = success ? ... :
  null`, with a comment claiming "a failed run has no snapshot to wait for". That
  failed run had an 80KB one, holding both the outputs of the five blocks that ran
  fine and the only account of what broke.
- `error` came back undefined. describeRunError reads `run.error`, which the API
  sets to null on a genuine failure more often than not, so callers got
  `success: false` with nothing attached.
- viewUrl was built only on success — so the one place that could have shown the
  answer was the one link we withheld.

Now: fetch the snapshot whatever the status, keep the outputs it holds, always
build viewUrl, and describe the failure from the snapshot when the API won't —
a block's error output, or an agent block's `deepnote_agent_status: failed`,
which is the only place a failed agent is recorded at all. The bare status is the
last resort; never silence.

This also fixes the snapshot race: the settle loop retries a terminal run whose
snapshot has not landed, rather than re-fetching once and reporting a successful
run with no outputs. First retry is immediate — usually enough — and only then
does it wait.

Both entry points were wrong the same way, and I fixed runInCloud first and
getCloudRun only after the live check caught it still failing — which is the same
fix-the-instance-not-the-shape mistake this PR keeps making. The diagnosis now
lives in cloud-common, where both read it.

Verified against the run that actually failed: it now reports "The agent block
failed (deepnote_agent_status: failed)" and keeps 6 blocks of output, where before
it said nothing and returned none.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): don't read a broken snapshot download as an empty run

The settling loop caught every fetchSnapshotContent failure and returned null,
which flattened two different nothings into one. fetchSnapshotContent returns
null when the run has no snapshot yet — worth retrying, and reportable as no
outputs — but throws when there is a snapshot it could not read. Swallowing the
second meant a failed download surfaced as `success: true` with no outputs, or as
a failed run explained away with "Deepnote reported no reason". Both invent an
answer out of an error.

Worse, getCloudRun let that same failure throw, so the fresh-run path and the
history path disagreed about the same run — which is the bug this whole thread
started with, in miniature.

Now the loop retries either kind (a download can fail transiently too), and only
decides at the end: a snapshot that never attached returns null, one that never
became readable throws the error it actually hit.

Also stop the re-fetch test waiting on real 1.5s sleeps — its getRun rejects every
time, so it walked the whole backoff for nothing. The file drops from 3.4s to
0.3s.

Raised in review at d440a454f7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(examples): one snapshot, four frontends — and the gap they found (#422)

The snapshot-viewer is one answer to "what does a notebook run look like": a
read-only document, blocks in order, code behind details. It reads like the only
answer, and it treats a snapshot as a picture of a notebook — injecting the HTML
the notebook baked, ignoring the dataframe sitting next to it with precomputed
histograms and per-column stats nothing reads.

Four static pages over the same file, arguing otherwise:

- dashboard — the snapshot as product-ready metrics
- explorer  — the snapshot as a structured dataset
- deck      — the snapshot as presentation data
- terminal  — the snapshot as a plain-text log

The dashboard makes the case by refusing the easy route. The notebook already
rendered its KPI cards; the dashboard ignores them and computes revenue,
attainment and top region from the dataframe plus the values the run executed
with. They reconcile with what the notebook rendered — $6,572,100 and North
America exactly, and 109.5% is the card's 110% before Python's {pct:.0f} rounds
it. The card's "13.7% vs prior period" is left out on purpose: the prior window
was never written to an output, so recomputing it would mean inventing it.

None of the four injects notebook HTML, so unlike the viewer none needs an
iframe: every snapshot-derived string goes to the DOM via textContent. Not a
criticism of the viewer — it has to render what the notebook drew. These ask the
data questions instead and get to skip the problem.

And the point of building four rather than one: they all wanted to label a
parameter and couldn't. SnapshotBlock.input carried { name, value }, so a page
could say `trailing_months = 6` but not "Trailing months · 6 of 3–12".
listInputBlocks already returned exactly the right shape, under a docstring
reading "metadata a UI needs to render an editable control" — run-app gets it
over /api/info, a browser didn't. Gated by entry point, not capability, with no
Node dependency anywhere in the chain.

So: the per-block read moves to input-info.ts, listInputBlocks and toSnapshotView
both call it, and SnapshotBlock.input carries the same fields. listInputBlocks'
own tests are untouched — that is what proves the refactor changed nothing for
existing callers. Bundle cost 0.25 kB gzipped.

Still open, deliberately: the agent's model lives in block metadata, which a
SnapshotBlock still doesn't expose. Exposing input metadata was contained and had
four consumers asking. Exposing arbitrary metadata is a wider question and one
demo isn't enough to answer it.

Chart colours are the dataviz reference palette, validated rather than eyeballed:
sequential blue for magnitude, status green for the target, both modes passing all
six checks (blue-450 on light, blue-400 on dark — the dark step chosen, not flipped).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cloud): page through the project lookup, and stop substituting a notebook

Two ways findNotebook could answer confidently and be wrong.

GET /v2/projects pages at 50. One unfiltered request meant a workspace past
that size reported an existing project as absent — and absence is what sends
createIfMissing off to create a duplicate. It now narrows server-side with
nameContains and reads every matching page. A body it cannot parse throws
rather than returning undefined, for the same reason: "I could not read the
answer" is not "the project is not there".

And when a notebook name was asked for and no notebook had it, it fell back to
the project's first notebook. Names are unique within a project, so another
notebook is never a stand-in — in a newer half-built project that fallback runs
something else entirely. The first notebook is now only ever the answer to "any
notebook of this project".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cloud): adopt the seeded notebook, and refuse duplicate names up front

POST /v2/projects seeds every new project with a notebook called "Notebook 1",
POST /v2/notebooks answers a duplicate name with a 409, and there is no rename
endpoint. Since our notebooks were created before the seed was deleted, a
source notebook called "Notebook 1" — Deepnote's own default, so a very common
name — failed immediately and left a half-built project behind. It now adopts a
placeholder whose name a source notebook wants (they come with no blocks, so
adopting one is the same as having created it) and deletes only the rest.

Two notebooks sharing a name failed the same way, just later. That one is a
fact about the caller's spec, so it is now refused before the first request
rather than discovered halfway through, along with a nameless notebook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cloud): read the run's completedAt, and narrow what an input may be

GET /v2/runs/{id} returns completedAt. We parsed finishedAt, which the API does
not have at all — that name belongs to a field inside a snapshot document — so
every run's completion timestamp was silently dropped on the way through. The
list endpoint already read completedAt, so the two disagreed about the same
run.

TriggerRunBody.inputs was Record<string, unknown>, which is wider than the API:
it takes string | boolean | string[], and only for names the notebook's own
input blocks define. Naming the union is what makes a value it would reject a
type error here instead of a 400 there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cloud): read the v2 API's `message`, not just v1's `error`

The /v1/import flow answers with `error`; the /v2 API answers with `message`.
Reading only the first meant every v2 failure reached the caller as the raw
JSON body — `{"message":"Notebook not found"}` where "Notebook not found" was
the whole point. Both keys are now read, `error` first so v1 is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): translate SQL integrations, and check a run before creating

A .deepnote SQL block keeps its connection in metadata.sql_integration_id.
Deepnote rejects that key outright — a 400, not a silent strip — and takes the
connection as a top-level integrationId, which it then writes into that very
key itself. We forwarded the metadata untouched and looked for a top-level
integrationId that the block schema has no field for, so the check was dead
code and every SQL block would have failed. Two of the shipped examples have
one. The value is now lifted out of the metadata; an id Deepnote cannot accept
(it must be a UUID, which the built-in dataframe connection is not) is dropped
with a warning, since an unbound block is the only shape the API will take.

The "not found" recovery matched /not found/i, which is three different
failures wearing one phrase. `Notebook not found` is the only one worth
recovering from; `Block not found in notebook` is a bad block id, and the
endpoint's only 404 is a bare `Not found` meaning the token's owner has left
the workspace. Both of those used to be answered by looking the notebook up and
creating a duplicate project — a typo, or an expired membership, and you get a
new project.

The rest is moving decisions ahead of the first request, since all of them are
facts about the local file: which notebook is the target, whether the requested
blocks are in it, and whether an input name is one the notebook defines.
Deepnote has no kernel injection, so an unmatched name is a 400 there — unlike
runWithInputs, where it is a variable and stays one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(local-runner): require a notebook for multi-notebook run history

Run history belongs to one notebook, but listCloudRuns quietly took the first
notebook of the file whenever there were several — answering with a real,
plausible history that simply belongs to something else. It now resolves the
notebook the same way runInCloud does, and refuses the ambiguity rather than
picking, so the two agree about which notebook a file means.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(repo): stop sorting .gitignore, which broke the png exception

lint-staged ran `sort -o .gitignore .gitignore` on every commit that touched
the file. Order is significant in a .gitignore — the last matching pattern
wins — so sorting hoisted `!packages/**/*.png` above `*.png` and quietly
un-did the exception. It also left every line duplicated.

Restoring the file alone would have lasted until the next commit that touched
it, so the rule goes too. There is no safe way to sort a file whose semantics
depend on order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cloud): report a non-JSON projects body as ApiError, not a SyntaxError

findNotebook now promises to throw rather than report absence when it cannot
read a response, but `await response.json()` was unguarded — so a non-JSON body
(an HTML error page from a proxy, say) escaped as a raw SyntaxError, which is
neither that contract nor the ApiError callers of this package catch.

`create-project.ts` already guards exactly this, so the guard is shared here and
applied to getWorkspace too rather than leaving one of the two unguarded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cloud): run block-targeted runs live, since a detached run refuses them

`POST /v2/runs` defaults every run to `detached: true` and rejects `blockIds`
on one — `blockIds is not supported for detached runs`, a 400. So every
targeted run failed: the direct one, and the one started right after creating
the notebook. Deepnote only runs selected blocks in live mode, so a body
carrying blockIds now says `detached: false` for itself.

Done in `triggerNotebookRun` rather than at each call site, so the rule lives
once next to the endpoint that has it. An empty `blockIds` is dropped there
too — the API takes a non-empty list, and "no blocks in particular" is what
omitting it means.

* fix(local-runner): create a file whose notebooks call each other, rather than refuse it

A notebook-function block names the notebook it invokes in
`function_notebook_id`, and creating the file gives that notebook a new id the
reference would not follow — so `createFromFile` refused any file holding one.
That kept stale ids out, but it also turned down valid multi-notebook files
that create-if-missing promises to handle, and told the user to go and create
the project by hand.

The id is knowable, just not while notebooks and blocks are created in
lockstep: `createProject` now creates every notebook first, builds
`sourceId` → created id, and offers it to a `rewriteBlock` hook as each block
goes out. The client stays domain-agnostic — it echoes ids it never reads —
and `local-runner` uses that to re-point references into the file at the
notebooks Deepnote just made. A reference out of the file already names a real
Deepnote notebook, so it is left alone.

* fix(cloud): finish the project lookup, or throw — never report absence early

`findNotebook` returning `undefined` is what sends `createIfMissing` off to
create a project, so it has to mean "looked everywhere, not there". Two things
could make it mean less than that: `pagination` was optional in the client
though the endpoint always sends it, so a body without one read exactly like a
last page; and the page walk stopped at `MAX_PROJECT_PAGES` even with a token
still in hand, quietly answering from a lookup that had not finished.

Pagination is now required, and a walk that runs out of pages with more to read
throws. Both cases were a duplicate project waiting to happen.

* feat(examples): advance the gallery deck on click, not only on arrow keys

A deck that only moves on arrow keys is a deck nobody can present from a
trackpad, and clicking the slide is what every projector deck does. The
listener sits on `.stage` rather than the document, so the chrome needs no
exceptions: the gallery link and the dots are fixed siblings, not descendants,
and their clicks never reach it.

Forward only. Back stays on the arrow keys and the dots, which already reach
any slide — a left-half-goes-back zone is invention, not a fix. A click that
ends a text selection is skipped: the last slide is the agent readout as
selectable prose, and finishing a copy-drag should not jump off what you just
selected.

* fix: css

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: dinohamzic <dino@subtlebits.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants