feat(cli): add deepnote run --cloud to run notebooks in Deepnote Cloud - #417
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughThe CLI now supports 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
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (5 passed)
Comment |
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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
📒 Files selected for processing (11)
packages/cli/README.mdpackages/cli/src/cli.test.tspackages/cli/src/cli.tspackages/cli/src/commands/run-cloud.test.tspackages/cli/src/commands/run-cloud.tspackages/cli/src/commands/run.tspackages/cli/src/utils/cloud-runs.test.tspackages/cli/src/utils/cloud-runs.tspackages/cli/src/utils/parse-inputs.test.tspackages/cli/src/utils/parse-inputs.tsskills/deepnote/references/cli-run.md
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/cloud/src/cloud-runs.test.ts (1)
181-237: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTest flat snapshot normalization through the API path.
These tests construct
NormalizedRun.snapshotdirectly, so they would pass even ifnormalizeSnapshotstopped readingsnapshotContentorsnapshotDownloadUrlfrom API responses. AddgetRunfixtures 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (8)
packages/cli/package.jsonpackages/cli/src/commands/run-cloud.tspackages/cloud/README.mdpackages/cloud/package.jsonpackages/cloud/src/cloud-runs.test.tspackages/cloud/src/cloud-runs.tspackages/cloud/src/index.tspackages/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
`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>
f66baa2 to
a2dc7bc
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
packages/cli/src/commands/run.test.ts (1)
2905-2926: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse
dedentfor the multiline YAML fixture.Wrap this template literal with
ts-dedentso its formatting remains independent of surrounding source indentation.As per coding guidelines,
**/*.tsfiles should usets-dedentfor 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 winKeep the non-input fixture type-safe.
as unknown as DeepnoteBlockbypasses 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 withdeepnoteBlockSchemainstead.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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (20)
packages/blocks/src/blocks/input-blocks.test.tspackages/blocks/src/blocks/input-blocks.tspackages/blocks/src/index.tspackages/cli/README.mdpackages/cli/package.jsonpackages/cli/src/cli.test.tspackages/cli/src/cli.tspackages/cli/src/commands/run-cloud.test.tspackages/cli/src/commands/run-cloud.tspackages/cli/src/commands/run.test.tspackages/cli/src/commands/run.tspackages/cli/src/utils/parse-inputs.test.tspackages/cli/src/utils/parse-inputs.tspackages/cloud/README.mdpackages/cloud/package.jsonpackages/cloud/src/cloud-runs.test.tspackages/cloud/src/cloud-runs.tspackages/cloud/src/index.tspackages/cloud/tsdown.config.tsskills/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
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>
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>
There was a problem hiding this comment.
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
📒 Files selected for processing (6)
packages/cli/README.mdpackages/cli/src/cli.tspackages/cli/src/commands/run-cloud.test.tspackages/cli/src/commands/run-cloud.tspackages/cli/src/commands/run.tsskills/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
There was a problem hiding this comment.
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
📒 Files selected for processing (6)
packages/cli/README.mdpackages/cli/src/cli.tspackages/cli/src/commands/run-cloud.test.tspackages/cli/src/commands/run-cloud.tspackages/cli/src/commands/run.tsskills/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
--inputvalues as invalid usage.These branches throw plain
Error, whilecreateRunActiononly mapsInvalidInputErrortoExitCode.InvalidUsage; malformed flags therefore take the generic error exit path. ThrowInvalidInputErrorhere, 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.
| .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()) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Added push too which agent had missed (see later commit).
| /** | ||
| * 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 | ||
| } |
There was a problem hiding this comment.
Why is this function in the database-integrations packages? should it be moved to the cloud package?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
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>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/cloud/src/parse-api-error.ts (1)
9-13: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winNarrow the parsed JSON before reading
error.Line 11 makes
jsoneffectivelyany, bypassing strict TypeScript checks. Parse asunknown, verify it is a non-null object containing a non-empty stringerror, 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
📒 Files selected for processing (4)
packages/cli/src/utils/deepnote-api.tspackages/cloud/src/cloud-runs.tspackages/cloud/src/index.tspackages/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
--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>
#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).
…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>
What
Two things:
--cloudflag ondeepnote runthat runs an existing notebook in Deepnote Cloud and downloads the resulting snapshot locally — all in one command.@deepnote/cloud, so it isn't buried in the CLI (#419consumes it too).It triggers
POST /v2/runs, pollsGET /v2/runs/{runId}to completion, then writes the snapshot into the same localsnapshots/convention local runs use — sodeepnote diffand 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/--tokenauth. Pulling the client into@deepnote/cloudkeeps the CLI thin and lets@deepnote/local-runner(#419) share the exact same, already-tested client.Scope
--notebook-id, or read from a local.deepnote). Non-.deepnoteinputs are rejected.--pushexists as a hidden flag that errors "not yet implemented". (Upload-if-missing is implemented in feat(local-runner): @deepnote/local-runner — run a .deepnote with edited inputs, locally or in Deepnote Cloud #419's@deepnote/local-runnerpath.)@deepnote/cloud(new package)Bearer-auth client for the Deepnote Cloud runs API, extracted from
packages/cli/src/utils/cloud-runs.tsintopackages/cloud/src/cloud-runs.tswith no behaviour change:triggerNotebookRun,getRun,pollRunUntilComplete(429/5xx backoff, per-request + total timeout,RunTimeoutErrorcarrying the runId)fetchSnapshotContent(cross-origin-safe: no bearer on presigned S3 URLs)isSuccessStatus,describeRunError, and shared typesResponse schemas are permissive (
.passthrough(), both{run:{…}}and top-level envelopes, inline +downloadUrlsnapshot 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),.envload, blank-token-as-missing, snapshot parse (snapshot-doc → full-file split → raw fallback), timestamped +latestwrites, terminal-failure → exit 1 preservingrunId/status/snapshotPath,-o json/-o toonoutput.utils/parse-inputs.ts— extracted shared helper (avoids arun↔run-cloudimport cycle).run.ts/cli.ts— new options, early dispatch before the localExecutionEnginepath, incompatible-flag guard (--python/--cwd/--top/--profile/--open/--prompt/--dry-run/--list-inputs/--contextrejected in cloud mode), help + examples.skills/deepnote/references/cli-run.mdandpackages/cli/README.md.Machine output
{ success, runId, status, snapshotPath?, timestampedSnapshotPath?, error? }for both-o jsonand-o toon. A completed run with statuserror/internal_error/stoppedexits1while still reporting the runId, status, and any snapshot path.Testing
@deepnote/cloud,@deepnote/cli, and@deepnote/blockssuites green; typecheck, biome, and prettier clean; package builds (skill schema regenerated).run --helpshows the flags and hides--push; usage guards (incompatible flag, missing/blank token,--push, non-.deepnote) all exit2.🤖 Generated with Claude Code
Summary by CodeRabbit
deepnote run(run existing cloud notebooks by ID or local.deepnote), including snapshot download, output targeting, and configurable timeouts.--inputhandling for Cloud runs and updated machine-readable Cloud run results.