Skip to content

feat(deepnote): load integrations from .deepnote.env.yaml and .env#440

Draft
tkislan wants to merge 14 commits into
tk/single-notebookfrom
tk/integrations-yaml-file
Draft

feat(deepnote): load integrations from .deepnote.env.yaml and .env#440
tkislan wants to merge 14 commits into
tk/single-notebookfrom
tk/integrations-yaml-file

Conversation

@tkislan

@tkislan tkislan commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai ignore

Summary

Adds CLI-parity .deepnote.env.yaml + .env integration loading to the extension as a complementary source alongside VSCode SecretStorage, and applies integration env to running kernels the same way Deepnote cloud does — via the toolkit's live set_integration_env(), with no server restart.

What it does

1. File-based integration config (loader + merge)

  • IntegrationsFileConfigProvider: reads a .deepnote.env.yaml (dir-then-root), resolves env: refs against .env (dotenv) and process.env (real env wins), never throws. Reuses @deepnote/database-integrations' parseIntegrations; replicates only the Node fs/dotenv shell.
  • SqlIntegrationEnvironmentVariablesProvider merges file configs over SecretStorage (file wins on id conflict; file-only additive; federated skip + DuckDB unchanged).
  • Adds the dotenv dependency and the deepnote.integrations.envFile.enabled setting (default true).

2. Live env injection (cloud-parity, no restart)

Rather than restarting the toolkit server when integration env changes, the extension mirrors the cloud "no restart" path:

  • IntegrationsEnvVarsEndpoint: a loopback HTTP endpoint (127.0.0.1, no auth) serving GET /userpod-api/:projectId/integrations/environment-variables[{name,value}] from the provider. This is exactly what the toolkit's set_integration_env() fetches.
  • The toolkit server is started in "direct mode" pointing at that endpoint — DEEPNOTE_RUNTIME__ENV_INTEGRATION_ENABLED / __RUNNING_IN_DETACHED_MODE / __WEBAPP_URL + DEEPNOTE_PROJECT_ID — so it fetches integration env at kernel start. Guarded to degrade to the existing spawn-time injection when the endpoint/project id isn't available.
  • IntegrationEnvLiveRefresher: on an env-file change (IntegrationsEnvFileWatcher, debounced) or a SecretStorage integration change (IntegrationEnvRefreshHandler), runs deepnote_toolkit.set_integration_env() silently (executeHidden) in the affected running kernels — re-fetch + live os.environ update — and shows one dismissible "environment updated" notification.

The spawn-time SQL_* injection is retained as the initial-load safety net. Node-only feature; web is unchanged.

Test plan

  • Unit (full suite green, 0 failing): loader (13), provider merge (+5), endpoint (6), live-refresher (6), server-starter config (+4), watcher + refresh handler.
  • E2E: integrationsEnvFileInjection.e2e.test.ts proves the .deepnote.env.yamlenv: → dotenv → integration → kernel path via spawn-time injection. Runs under ExTester in CI.
  • Needs app/E2E verification (not unit-testable): the live set_integration_env() loop — the toolkit fetching the local endpoint and updating a running kernel's os.environ on change.

Follow-up (separate deepnote/deepnote repo)

@deepnote/database-integrations could add a Node-only subpath export (/node) for the fs/dotenv shell and a DatabaseIntegrationConfig-typed federated guard, to remove the small pieces the extension/CLI currently replicate (plan drafted separately).

🤖 Generated with Claude Code

https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH

tkislan and others added 14 commits July 14, 2026 16:21
Add a stateless node-only loader (IntegrationsFileConfigProvider) that reads
integration configs from a `.deepnote.env.yaml` file with CLI parity: it
resolves `env:` references against a sibling `.env` (via dotenv) and
`process.env`, with real env winning. It replicates the Node fs/dotenv shell
that `@deepnote/database-integrations` does not export, delegating parsing to
the exported `parseIntegrations`.

Files are resolved dir-then-root (next to the .deepnote file, then the
workspace root). Reserved-id, pandas-dataframe, duplicate-id, and federated
(google-oauth) entries are dropped with diagnostic issues. The loader is
stateless (fresh read per call) and never throws.

Also adds the `dotenv` dependency and the `deepnote.integrations.envFile.enabled`
setting (boolean, default true). The loader is not yet registered in DI; that
wiring lands with the watcher in a later chunk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
… env vars

Wire the file-config loader into SqlIntegrationEnvironmentVariablesProvider as
a complementary source alongside VSCode SecretStorage. The loader is injected
@optional() (absent on web / until DI registration), and getConfigsForFile is
called inside a try/catch so a file-source failure degrades to SecretStorage +
DuckDB and never rejects the method.

Merge semantics (per plan): the file wins on id conflict — SecretStorage is not
even queried for an id the file provides; project integrations absent from the
file fall back to SecretStorage (Promise.allSettled, per-item error logging);
file-only integrations (not declared in project.integrations) are injected
additively. The federated-auth skip and the always-appended DuckDB integration
run after the merge, unchanged. When the provider is absent the output is
byte-identical to the previous SecretStorage-only behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
Add IntegrationsEnvFileWatcher (IExtensionSyncActivationService) that watches
`.deepnote.env.yaml` and `.env` next to open Deepnote notebooks and in
workspace-folder roots (dir-then-root, covering not-yet-existing files). On a
debounced change it finds open Deepnote notebooks with a started kernel whose
`.deepnote` dir or workspace root changed, deduped by `.deepnote` fsPath, and
prompts once with a non-modal Restart action. An `isRestarting` guard plus a
monotonic prompt sequence prevent stacked restarts and let a newer change
supersede a still-open prompt. No affected running kernel → no prompt (the
loader is stateless, so the next start reads fresh config).

Add IDeepnoteKernelAutoSelector.restartServerForNotebook, which the watcher
invokes: the toolkit server captures its env at spawn, so a plain
kernel.restart() would keep stale values. It resolves the environment first
(bailing without stopping the server if it's gone), then stops the server and
LSP clients, clears the cached connection metadata and environment id (to
bypass the same-environment early-return in ensureKernelSelectedWithConfiguration
so startServer re-runs and re-gathers integration env vars), and re-runs kernel
selection. The controller object is reused via updateConnection (no DISPOSED
errors). The watcher is not yet registered in DI; that lands in the next chunk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
…on E2E

Register IIntegrationsFileConfigProvider (the loader) and IntegrationsEnvFileWatcher
(as an IExtensionSyncActivationService) in the node service registry only. The
single shared DI container means the loader binds for the @optional() injection in
SqlIntegrationEnvironmentVariablesProvider (registered in the kernels registry);
web keeps SecretStorage-only behavior since nothing is registered there.

Add the first kernel-injection E2E test: a fixture declaring one pgsql integration
and a cell printing an integration-derived env var. The test writes a
`.deepnote.env.yaml` (host: env:DEMO_DB_HOST) and `.env` (DEMO_DB_HOST=...) into the
temp workspace, runs the cell, and asserts the resolved value appears. The cell
prints PROD_POSTGRES_HOST (derived from the integration name), while `.env` uses the
distinct key DEMO_DB_HOST — so a pass proves the .deepnote.env.yaml -> env: -> dotenv
-> integration -> kernel path, not customEnvironmentVariablesProvider's direct `.env`
injection. E2E runs under ExTester in CI (compile-e2e verified here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
F1: gate the integrations env-file watcher on server existence
(IDeepnoteServerStarter.isServerRunningForFile), not kernel.startedAtLeastOnce.
The toolkit server captures env at spawn — before the first cell executes — so
an env edit in the open→first-run window previously produced no restart prompt
and the first cell ran with stale values.

F2: dispose the notebook's kernel in restartServerForNotebook. areKernelConnectionsEqual
compares only id/environmentName/notebookName for Deepnote kernels, so a same-env
respawn (only baseUrl changes) looks "unchanged" and updateConnection does not
dispose the kernel — leaving it bound to the killed server (reconnect spinner +
"connection failed" on nearly every restart). Disposing it forces a fresh kernel
against the new server and suppresses the spurious error.

F3: run each notebook's restart atomically. The cancellable withProgress token was
forwarded into both stopServer and startServer; cancelling after the process was
killed rejected and stranded the notebook on a dead server. Cancellation is still
honored between notebooks, but once a restart starts it runs to completion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
Remove the promptSeq/supersede logic and the isRestarting guard from
IntegrationsEnvFileWatcher. Restarting is a user action — a stale or missed
prompt is simply re-triggered by the next save or a manual restart — so
overlapping prompts/restarts don't warrant special handling. This also removes
the source of the cross-project supersession and change-during-restart edge
cases. The server-existence gate, atomic restart, debounce, and per-.deepnote
dedup are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
Bring the feature branch up to date with the base (12 commits: explorer/tree
refactors, per-sibling integration persistence, document-flush guard, snapshot
scoping, and e2e coverage). No conflicts — disjoint file sets. Merged tree
compiles and the full unit suite passes (2512 passing, 0 failing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
…nToken mock

Condense the integrations injection E2E's header to two lines and the
folder-open note to one (the isolation rationale stays in the const comments).
Replace the `as unknown as CancellationToken` force casts in the watcher unit
test with a fully-typed `mock<CancellationToken>()` helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
Extension-side infrastructure for cloud-style live integration-env injection
(no server restart):

- IntegrationsEnvVarsEndpoint: a loopback HTTP server (127.0.0.1) serving
  GET /userpod-api/:projectId/integrations/environment-variables, returning a
  project's integration env vars as [{name,value}] from
  SqlIntegrationEnvironmentVariablesProvider — what the toolkit's
  set_integration_env() fetches. Localhost-only, no auth.

- IntegrationEnvLiveRefresher: runs `import deepnote_toolkit;
  deepnote_toolkit.set_integration_env()` silently (executeHidden) in each
  affected running kernel and shows one dismissible notification, only when the
  snippet ran without error outputs.

Registered node-only. Not yet wired to the server-starter (B2) or the
watcher / restart handler (B3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
At server spawn, when the loopback endpoint is listening and the .deepnote file
resolves to a project id, set the toolkit config env so it fetches integration
env from our endpoint in "direct mode":
DEEPNOTE_RUNTIME__ENV_INTEGRATION_ENABLED / __RUNNING_IN_DETACHED_MODE /
__WEBAPP_URL, plus legacy DEEPNOTE_PROJECT_ID (maps to runtime.project_id and
satisfies set_notebook_path's has_env check). Detached mode avoids dev_mode's
/work redirect.

Guarded all-or-nothing on baseUrl + project id, so when the endpoint isn't ready
the feature stays off and the pre-existing spawn-time SQL_* injection remains the
env source. That injection is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
Replace the toolkit-server restart on integration/env-file change with a live
in-kernel refresh via IIntegrationEnvLiveRefresher (runs set_integration_env(),
which re-fetches from the local endpoint and updates os.environ without a
restart), plus a dismissible "environment updated" notification.

- IntegrationsEnvFileWatcher: on a debounced .deepnote.env.yaml/.env change,
  live-refresh the affected notebooks' kernels (dir-then-root; no prompt).
- IntegrationEnvRefreshHandler (renamed from IntegrationKernelRestartHandler,
  which no longer restarts): on a SecretStorage integration change, refresh open
  Deepnote notebooks. Node-only now (the live-refresh mechanism has no web
  equivalent), so it is removed from the web service registry.

Delete the restart machinery this obsoletes: restartServerForNotebook
(IDeepnoteKernelAutoSelector) and isServerRunningForFile (IDeepnoteServerStarter),
including their impls and tests. Full unit suite: 2521 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
…add live-refresh e2e

Verifying the live set_integration_env() loop against the real pinned toolkit
(2.1.1) surfaced a crash: its get_project_auth_headers() dereferences
project_secret with no null-check, so detached mode fails unless a secret is set.
Inject DEEPNOTE_RUNTIME__PROJECT_SECRET (a generated token), and — since the
loopback endpoint serves integration credentials — have the endpoint validate
that bearer token (401 otherwise).

Extend integrationsEnvFileInjection.e2e.test.ts to prove the no-restart live
refresh: after the initial run, rewrite .env and assert a re-run of the SAME
kernel reads the new value (applied via the watcher's set_integration_env()).

Verified end-to-end against toolkit 2.1.1: direct-mode URL matches the endpoint
route, initial fetch+inject, live refresh on re-fetch, and unset-on-remove all
pass. Full unit suite: 2522 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
Remove comments that restate readable code; keep only the non-obvious "why" as
single lines — the toolkit 2.1.1 project_secret quirk + detached-mode guard, the
endpoint's bearer-token validation, the `_dntk`-not-guaranteed import, and the
"public for testing" note. Comments-only change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
…ndpoints

The loopback server hosts the toolkit's userpod-api surface, of which the
integration environment-variables route is currently the only endpoint.
Rename it to a host-oriented name so future userpod-api endpoints have a home,
and drop the http.Server force-cast in stop() now that @types/node declares
closeAllConnections() directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
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.

1 participant