mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
Merge remote-tracking branch 'origin/datatable-external-instance' into datatable-external-instance-2
# Conflicts: # backend/ee-repo-ref.txt # backend/windmill-api-workspaces/src/workspaces.rs
This commit is contained in:
@@ -210,7 +210,7 @@ the diff first — `git diff --name-only main...HEAD` answers most of these.
|
||||
**Flip without asking** when it is self-contained: a single-file fix, test-only, docs-only, one
|
||||
call site, no new public surface.
|
||||
|
||||
Unattended (webmux oneshot) there is nobody to ask, so the judgement holds and the action
|
||||
Unattended (a one-shot run) there is nobody to ask, so the judgement holds and the action
|
||||
degrades: flip the self-contained ones, and leave the rest at a clean draft with a line in the PR
|
||||
description saying why — `left in draft: adds a migration, wants a human look before ready`.
|
||||
Don't flip a wide-blast-radius change just because the round came back clean, and don't ask a
|
||||
|
||||
+79
-1
@@ -1,4 +1,13 @@
|
||||
# Basic set up for three package managers
|
||||
# Weekly, grouped version updates for every shipped manifest, plus one grouped
|
||||
# security-update PR per manifest. Security-update PRs have no open-PR limit, so
|
||||
# without an `applies-to: security-updates` group each alert would get its own PR.
|
||||
# Bun has no security updates: /cli alerts are fixed through its version group.
|
||||
#
|
||||
# Deliberately NOT covered here:
|
||||
# - /rust-client: Cargo.toml is generated and gitignored
|
||||
# - /backend/parsers/windmill-parser-wasm: excluded from the workspace, needs nightly cargo
|
||||
# - /benchmarks/pulumi: one-off load-test infrastructure, not shipped
|
||||
# - /integration_tests: CI-only
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
@@ -13,12 +22,32 @@ updates:
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
# minor + patch bumps in one PR; majors in their own PR so they can be
|
||||
# reviewed separately without blocking the routine bumps
|
||||
frontend-deps:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
frontend-majors:
|
||||
patterns: ["*"]
|
||||
update-types: ["major"]
|
||||
frontend-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
# Maintain dependencies for cargo
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/backend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
# majors stay as individual PRs: they usually need code changes
|
||||
cargo-deps:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
cargo-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
# Maintain dependencies for Docker
|
||||
- package-ecosystem: "docker"
|
||||
@@ -31,3 +60,52 @@ updates:
|
||||
directory: "/python-client/wmill"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
python-client-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
# Maintain dependencies for the CLI (bun.lock)
|
||||
- package-ecosystem: "bun"
|
||||
directory: "/cli"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
cli-deps:
|
||||
patterns: ["*"]
|
||||
|
||||
# Maintain dependencies for the YAML validator
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/windmill-yaml-validator"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
yaml-validator-deps:
|
||||
patterns: ["*"]
|
||||
yaml-validator-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
# Maintain dependencies for the TypeScript client (devDependencies only)
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/typescript-client"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
typescript-client-deps:
|
||||
patterns: ["*"]
|
||||
typescript-client-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
# Maintain dependencies for multiplayer
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/multiplayer"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
multiplayer-deps:
|
||||
patterns: ["*"]
|
||||
multiplayer-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
@@ -63,11 +63,11 @@ jobs:
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
# Node 24 ships npm 11, which frontend/package-lock.json is authored
|
||||
# with; npm 10 rejects it ("Missing: picomatch@4.0.5 from lock file").
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# tests what production runs, instead of floating on the latest v2.x.
|
||||
deno-version: 2.2.1
|
||||
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.21.5
|
||||
|
||||
@@ -75,11 +75,11 @@ jobs:
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: astral-sh/setup-uv@v6.2.1
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "0.11.24"
|
||||
|
||||
|
||||
@@ -53,16 +53,16 @@ jobs:
|
||||
# Pin to the Deno version shipped in the runtime image (Dockerfile) so CI
|
||||
# tests what production runs, instead of floating on the latest v2.x.
|
||||
deno-version: 2.2.1
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.21.5
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
- uses: astral-sh/setup-uv@v6.2.1
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "0.11.24"
|
||||
- uses: shivammathur/setup-php@v2
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
sparse-checkout: |
|
||||
frontend/src
|
||||
.github/scripts
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22.x"
|
||||
- name: Verify docs links are not 404
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
cache-workspaces: backend
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
cache-workspaces: backend
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ jobs:
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v7
|
||||
- name: install_jq
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "20.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "20.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "20.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
ssh-key: ${{ secrets.CLI_DOCS_DEPLOY_KEY }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
# The interpreter is named explicitly: on a clean checkout uv picks the
|
||||
# runner's system python and stops with "not compatible with the locked
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
|
||||
@@ -55,12 +55,23 @@ Open-source platform for internal tools, workflows, API integrations, background
|
||||
> defaults in this section apply only to a plain single checkout. **Discover the real
|
||||
> values before running anything** — see "Per-worktree ports and database" below.
|
||||
|
||||
**Check whether they are already running before starting anything.** In a webmux worktree
|
||||
(`$WEBMUX_WORKTREE_PATH` is set) the backend and frontend are already up in sibling tmux panes —
|
||||
use those, don't spawn your own. `tmux list-panes -t "$(tmux display-message -p -t "$TMUX_PANE"
|
||||
'#{window_id}')" -F '#{pane_index} #{pane_current_command}'` shows what is running; read its log
|
||||
with `tmux capture-pane`, and see `backend/AGENTS.md` to restart it with different cargo features.
|
||||
A second server started in your own shell fights the first one for the port. The commands below
|
||||
**Check whether they are already running before starting anything.** In a managed worktree the
|
||||
backend and frontend are already up in sibling panes — use those, don't spawn your own. A second
|
||||
server started in your own shell fights the first one for the port.
|
||||
|
||||
**herdr** is the worktree manager; `$HERDR_ENV=1` marks one of its panes. `herdr pane list
|
||||
--workspace "$HERDR_WORKSPACE_ID"` lists this worktree's panes — match on `cwd`, since the servers
|
||||
run from `backend/` and `frontend/` — and `herdr pane read <pane_id> --source recent-unwrapped
|
||||
--lines 50` reads one's log; `herdr --skill` prints the full reference for
|
||||
inspecting and driving panes and agents. The plugins that provision these worktrees live in the
|
||||
private `windmill-labs/windmill-herdr` — clone it and run `./setup.sh` to install them and the
|
||||
keybindings they need.
|
||||
|
||||
A worktree from the older **webmux** setup sets `$WEBMUX_WORKTREE_PATH` instead and is driven
|
||||
through tmux: `tmux list-panes -t "$(tmux display-message -p -t "$TMUX_PANE" '#{window_id}')" -F
|
||||
'#{pane_index} #{pane_current_command}'` for what is running, `tmux capture-pane` for a pane's log.
|
||||
|
||||
See `backend/AGENTS.md` to restart the backend with different cargo features. The commands below
|
||||
are for a plain checkout with nothing running.
|
||||
|
||||
- **Backend**: `cargo run` from `backend/` (API at http://localhost:8000)
|
||||
@@ -72,10 +83,16 @@ are for a plain checkout with nothing running.
|
||||
|
||||
### Per-worktree ports and database
|
||||
|
||||
In a webmux worktree the authoritative values live in
|
||||
`$(git rev-parse --git-dir)/webmux/runtime.env` — `BACKEND_PORT`, `FRONTEND_PORT`,
|
||||
`DATABASE_URL`, `CARGO_FEATURES`, `WM_DB_NAME`. Every pane sources it at startup. Read that
|
||||
first: it is not a `.env*` file, so the repo's secret-file read rules don't stand in the way.
|
||||
**`.env.local` in the worktree root holds the real values** — `BACKEND_PORT`, `FRONTEND_PORT`,
|
||||
`REMOTE`, `DATABASE_URL`, `CARGO_FEATURES`, `WM_DB_NAME`. Every manager writes those fields, so it
|
||||
is correct whichever one you are in. Read it with `cat .env.local` from a shell: the file-read
|
||||
tool denies every `.env.*` path, and `DATABASE_URL` is written with an `export` prefix that a
|
||||
`^DATABASE_URL=` grep misses.
|
||||
|
||||
herdr keeps nothing besides that file; its hooks write it and read it back. A webmux worktree
|
||||
additionally has `$(git rev-parse --git-dir)/webmux/runtime.env`, which every pane sources at
|
||||
startup and which carries the extras `.env.local` lacks: `WEBMUX_*`, `WM_CLONE_DB`,
|
||||
`USE_RUST_PLUGIN`.
|
||||
|
||||
In a plain checkout, fall back to `.env` / `.env.local` (repo root) and `backend/.env`.
|
||||
|
||||
@@ -84,19 +101,25 @@ hook. It is not a copy of the main dev instance: you get the `admins` workspace,
|
||||
`admin@windmill.dev` superadmin, the license key copied from the base database, and whatever the
|
||||
migrations seed — and none of your own workspaces, scripts, flows or apps. Create whatever a test
|
||||
needs. Cloning the base `windmill` database instead is
|
||||
opt-in per project via `WM_CLONE_DB` in `.webmux.yaml`; read the note there before turning it on.
|
||||
opt-in via `WM_CLONE_DB` (the `windmill.worktree` plugin's `config.env` under herdr, or
|
||||
`.webmux.yaml` under webmux); read the note in `.webmux.yaml` before turning it on.
|
||||
|
||||
The database is named after the **worktree directory, not the branch** (`scripts/worktree-common.sh`):
|
||||
`windmill_` + the directory basename with `-` → `_`, which Postgres then truncates at 63
|
||||
characters. Branch `hugo/win-2340-ai-agent-evals-standalone-agent-runs-and-eval-datasets` sits in
|
||||
a worktree directory named `win-2340-…`, so its database is
|
||||
`windmill_win_2340_ai_agent_evals_standalone_agent_runs_and_eval` — no `hugo_`, and the tail
|
||||
chopped. Take `WM_DB_NAME` from `runtime.env` instead of reconstructing the name. Read those, or
|
||||
discover from what is already running:
|
||||
characters. herdr creates the directory under `~/.herdr/worktrees/<repo>/` from the branch name
|
||||
with each `/` turned into `-`, so branch
|
||||
`hugo/win-2544-add-options-field-to-the-postgresql-resource-type` gets the database
|
||||
`windmill_hugo_win_2544_add_options_field_to_the_postgresql_reso` — the whole branch name, cut
|
||||
mid-word at the limit. The two drift apart as soon as the branch is renamed, and webmux named its
|
||||
directories after the ticket alone, so reconstructing the name from the branch you are on is
|
||||
wrong in both. Take `WM_DB_NAME` from `.env.local` instead. Read that, or discover from what is
|
||||
already running:
|
||||
|
||||
```bash
|
||||
# match on the worktree directory, truncated the way Postgres truncates it (63 - len('windmill_')):
|
||||
psql postgres://postgres:changeme@localhost:5432/postgres -tAc \
|
||||
"select datname from pg_database where datname like 'windmill%'" | grep "$(git branch --show-current | tr - _)"
|
||||
"select datname from pg_database where datname like 'windmill%'" \
|
||||
| grep -F "$(basename "$(git rev-parse --show-toplevel)" | tr '/-' '_' | cut -c1-54)"
|
||||
# the port the frontend actually proxies to (REMOTE of this worktree's vite):
|
||||
for p in $(pgrep -f vite); do case "$(readlink /proc/$p/cwd)" in *"$(basename "$(git rev-parse --show-toplevel)")"*)
|
||||
tr '\0' '\n' < /proc/$p/environ | grep -E '^REMOTE=|^PORT=';; esac; done
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
DataMetric,
|
||||
DataTableTables,
|
||||
DataTableTableSchema,
|
||||
EndpointTool,
|
||||
GetDraftForUserResponse,
|
||||
GetOwnDraftResponse,
|
||||
ListDraftsResponse,
|
||||
@@ -1020,119 +1019,6 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion {
|
||||
}
|
||||
}
|
||||
|
||||
// ============= API endpoint catalog (McpService.listMcpTools + raw fetch) =============
|
||||
// The global chat's API catalog tools list endpoints via McpService and execute
|
||||
// them with a plain relative fetch('/api/...'), which has no meaning in the
|
||||
// vitest environment. A representative slice of the real catalog is served here,
|
||||
// and `handleBenchmarkApiFetch` answers the executed calls.
|
||||
|
||||
const BENCHMARK_MCP_TOOLS: EndpointTool[] = [
|
||||
{
|
||||
name: 'listWorkers',
|
||||
description: 'List workers',
|
||||
instructions: 'List all workers with their last ping and job counts.',
|
||||
path: '/workers/list',
|
||||
method: 'GET',
|
||||
query_params_schema: {
|
||||
type: 'object',
|
||||
properties: { page: { type: 'integer' }, per_page: { type: 'integer' } }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'listQueue',
|
||||
description: 'List queued jobs',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/jobs/queue/list',
|
||||
method: 'GET',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' } },
|
||||
required: ['workspace']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'getJob',
|
||||
description: 'get job',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/jobs_u/get/{id}',
|
||||
method: 'GET',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, id: { type: 'string', format: 'uuid' } },
|
||||
required: ['workspace', 'id']
|
||||
},
|
||||
query_params_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
no_logs: { type: 'boolean' },
|
||||
no_code: { type: 'boolean' },
|
||||
approval_token: { type: 'string' }
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'runScriptByPath',
|
||||
description: 'Run the deployed version of a script by path',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/jobs/run/p/{path}',
|
||||
method: 'POST',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
|
||||
required: ['workspace', 'path']
|
||||
},
|
||||
body_schema: { type: 'object', properties: {} }
|
||||
},
|
||||
{
|
||||
name: 'runFlowByPath',
|
||||
description: 'Run the deployed version of a flow by path',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/jobs/run/f/{path}',
|
||||
method: 'POST',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
|
||||
required: ['workspace', 'path']
|
||||
},
|
||||
body_schema: { type: 'object', properties: {} }
|
||||
},
|
||||
// Draft-covered endpoints, present so steering cases exercise the guard the
|
||||
// way production does (hidden from search, refused at call time).
|
||||
{
|
||||
name: 'getScriptByPath',
|
||||
description: 'Get a script by path',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/scripts/get/p/{path}',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
name: 'createFlow',
|
||||
description: 'Create a flow',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/flows/create',
|
||||
method: 'POST'
|
||||
},
|
||||
{
|
||||
name: 'deleteSchedule',
|
||||
description: 'Delete a schedule',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/schedules/delete/{path}',
|
||||
method: 'DELETE'
|
||||
},
|
||||
{
|
||||
name: 'getVariable',
|
||||
description: 'Get a variable',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/variables/get/{path}',
|
||||
method: 'GET'
|
||||
}
|
||||
]
|
||||
|
||||
export function listBenchmarkMcpTools(): EndpointTool[] {
|
||||
return BENCHMARK_MCP_TOOLS
|
||||
}
|
||||
|
||||
/** A stand-in Windmill hub. `search_hub_scripts` and a `hub/` read go out over
|
||||
* relative `/api/...` fetches, which have no origin here, so without these the
|
||||
* hub tools throw and no case can exercise hub reuse. Serving fixtures rather
|
||||
@@ -1292,26 +1178,6 @@ const BENCHMARK_WORKERS = [
|
||||
}
|
||||
]
|
||||
|
||||
const BENCHMARK_JOB_GET_PATH = /^\/api\/w\/([^/]+)\/jobs_u\/get\/([^/]+)$/
|
||||
const BENCHMARK_RUN_BY_PATH = /^\/api\/w\/([^/]+)\/jobs\/run\/(p|f)\/([^/]+)$/
|
||||
|
||||
/** `executeEndpoint` sends a JSON string; anything else means no args were supplied. */
|
||||
function parseBenchmarkRequestBody(
|
||||
body: BodyInit | null | undefined
|
||||
): Record<string, unknown> | undefined {
|
||||
if (typeof body !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
return typeof parsed === 'object' && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** True when `handleBenchmarkApiFetch` has an answer for this `/api/...` url.
|
||||
* Any other relative fetch must keep its normal (non-benchmark) behavior —
|
||||
* intercepting it with a synthetic 404 sends the model into retry loops. */
|
||||
@@ -1323,17 +1189,13 @@ export function hasBenchmarkApiHandler(url: string): boolean {
|
||||
const path = url.split('?')[0]
|
||||
return (
|
||||
path === '/api/workers/list' ||
|
||||
BENCHMARK_JOB_GET_PATH.test(path) ||
|
||||
BENCHMARK_RUN_BY_PATH.test(path) ||
|
||||
/^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path) ||
|
||||
path === '/api/embeddings/query_hub_scripts' ||
|
||||
path.startsWith('/api/scripts/hub/get_full/') ||
|
||||
BENCHMARK_AI_MODELS_PATH.test(path)
|
||||
)
|
||||
}
|
||||
|
||||
/** Answer a relative `/api/...` fetch — from the API catalog executor, or from the
|
||||
* chat's hub tools. */
|
||||
/** Answer the relative `/api/...` fetches no mocked service covers. */
|
||||
export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Response {
|
||||
const path = url.split('?')[0]
|
||||
if (path === '/api/workers/list') {
|
||||
@@ -1350,39 +1212,6 @@ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Respon
|
||||
?.aiProviders?.find((entry) => entry.path === resourcePath)
|
||||
return Response.json({ data: (seed?.models ?? []).map((id) => ({ id })) })
|
||||
}
|
||||
if (/^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path)) {
|
||||
return Response.json([])
|
||||
}
|
||||
const jobGet = BENCHMARK_JOB_GET_PATH.exec(path)
|
||||
if (jobGet) {
|
||||
const id = decodeURIComponent(jobGet[2])
|
||||
const job = getBenchmarkCompletedJob(decodeURIComponent(jobGet[1]), id)
|
||||
if (!job) {
|
||||
return Response.json({ error: `Job not found for "${id}"` }, { status: 404 })
|
||||
}
|
||||
// The real endpoint lets a caller drop the bulky fields. Ignoring that here would
|
||||
// size the model's context off a payload it explicitly asked to shrink.
|
||||
const query = new URLSearchParams(url.split('?')[1] ?? '')
|
||||
if (query.get('no_logs') === 'true') {
|
||||
delete job.logs
|
||||
}
|
||||
if (query.get('no_code') === 'true') {
|
||||
delete job.raw_code
|
||||
}
|
||||
return Response.json(job)
|
||||
}
|
||||
const runByPath = BENCHMARK_RUN_BY_PATH.exec(path)
|
||||
if (runByPath) {
|
||||
const workspace = decodeURIComponent(runByPath[1])
|
||||
const runnablePath = decodeURIComponent(runByPath[3])
|
||||
const args = parseBenchmarkRequestBody(init?.body)
|
||||
// The real endpoint answers with the bare job id as text, not JSON.
|
||||
return new Response(
|
||||
runByPath[2] === 'f'
|
||||
? runBenchmarkFlowByPath({ workspace, path: runnablePath, args })
|
||||
: runBenchmarkScriptByPath({ workspace, path: runnablePath, args })
|
||||
)
|
||||
}
|
||||
if (path === '/api/embeddings/query_hub_scripts') {
|
||||
const text = new URLSearchParams(url.split('?')[1] ?? '').get('text') ?? ''
|
||||
return Response.json(searchBenchmarkHubScripts(text))
|
||||
|
||||
@@ -1,83 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
createBenchmarkCompletedJob,
|
||||
getBenchmarkCompletedJob,
|
||||
handleBenchmarkApiFetch,
|
||||
hasBenchmarkApiHandler,
|
||||
listBenchmarkMcpTools,
|
||||
resetBenchmarkMockBackend,
|
||||
registerBenchmarkWorkspaceRunnables
|
||||
} from './mockBackend'
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend'
|
||||
|
||||
const WORKSPACE = 'benchmark-api-ws'
|
||||
// `list_workers`'s client call is not faked, so its request really does leave as a relative
|
||||
// `/api/...` url that node's fetch cannot resolve. Without this route the tool throws instead
|
||||
// of answering.
|
||||
describe('benchmark API fetch handlers', () => {
|
||||
it('lists workers, the way list_workers reaches them', async () => {
|
||||
const url = '/api/workers/list?per_page=100'
|
||||
|
||||
// A catalog entry with no fetch handler is a dead end: the catalog executor builds a
|
||||
// relative `/api/...` url, the stub declines it, and node's fetch throws on the relative
|
||||
// url instead of returning a result the model can act on. Mutating entries are reachable
|
||||
// too — the eval runners define no `requestConfirmation`, so `call_api_endpoint` executes
|
||||
// unconfirmed.
|
||||
describe('benchmark API catalog', () => {
|
||||
beforeEach(() => resetBenchmarkMockBackend())
|
||||
afterEach(() => resetBenchmarkMockBackend())
|
||||
expect(hasBenchmarkApiHandler(url)).toBe(true)
|
||||
const body = await handleBenchmarkApiFetch(url).json()
|
||||
|
||||
it('answers every endpoint it advertises', () => {
|
||||
const unanswered = listBenchmarkMcpTools()
|
||||
.map((tool) =>
|
||||
`/api${tool.path.replace('{workspace}', WORKSPACE)}`.replace(/\{[^}]+\}/g, 'x')
|
||||
)
|
||||
.filter((url) => !hasBenchmarkApiHandler(url))
|
||||
|
||||
// The draft-covered entries are refused by name before any fetch, so they are
|
||||
// advertised without a handler on purpose.
|
||||
expect(unanswered).toEqual([
|
||||
`/api/w/${WORKSPACE}/scripts/get/p/x`,
|
||||
`/api/w/${WORKSPACE}/flows/create`,
|
||||
`/api/w/${WORKSPACE}/schedules/delete/x`,
|
||||
`/api/w/${WORKSPACE}/variables/get/x`
|
||||
])
|
||||
})
|
||||
|
||||
it('runs a deployed script by path, the way call_api_endpoint reaches it', async () => {
|
||||
registerBenchmarkWorkspaceRunnables(WORKSPACE, {
|
||||
scripts: [
|
||||
{
|
||||
path: 'f/evals/greet',
|
||||
summary: 'Greet',
|
||||
language: 'bun',
|
||||
content: 'export async function main() {}'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const res = handleBenchmarkApiFetch(
|
||||
`/api/w/${WORKSPACE}/jobs/run/p/${encodeURIComponent('f/evals/greet')}`,
|
||||
{ method: 'POST', body: JSON.stringify({ name: 'ada' }) }
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const job = getBenchmarkCompletedJob(WORKSPACE, (await res.text()).trim())
|
||||
expect(job).toMatchObject({ success: true, args: { name: 'ada' } })
|
||||
})
|
||||
|
||||
it('serves a recorded job so a model can check the run it just started', async () => {
|
||||
const id = createBenchmarkCompletedJob({
|
||||
workspace: WORKSPACE,
|
||||
jobKind: 'preview',
|
||||
result: 'Hello, World!'
|
||||
})
|
||||
|
||||
const res = handleBenchmarkApiFetch(`/api/w/${WORKSPACE}/jobs_u/get/${id}`)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({
|
||||
id,
|
||||
success: true,
|
||||
result: 'Hello, World!'
|
||||
})
|
||||
})
|
||||
|
||||
it('404s an unknown job id instead of letting the fetch fall through', () => {
|
||||
expect(hasBenchmarkApiHandler(`/api/w/${WORKSPACE}/jobs_u/get/missing`)).toBe(true)
|
||||
expect(handleBenchmarkApiFetch(`/api/w/${WORKSPACE}/jobs_u/get/missing`).status).toBe(404)
|
||||
// The tool counts the list and maps over it, so it needs a bare array, not an envelope.
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body[0]).toMatchObject({ worker: expect.any(String), worker_group: expect.any(String) })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,8 @@ import { mkdir, writeFile } from 'fs/promises'
|
||||
import { dirname, resolve } from 'path'
|
||||
import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend'
|
||||
|
||||
// The API catalog executor issues relative fetch('/api/...') calls, which have
|
||||
// no meaning in the vitest environment — serve the ones the benchmark handles.
|
||||
// Some tools reach the backend by relative fetch('/api/...'), which has no meaning
|
||||
// in the vitest environment — serve the ones the benchmark handles.
|
||||
// Every other relative fetch keeps its normal behavior (it fails the same way
|
||||
// it does without this stub) so unrelated tools see an unchanged environment.
|
||||
// The frontend builds API URLs from location.origin (fetchAvailableModels does), and node has no
|
||||
@@ -92,8 +92,7 @@ vi.mock('$lib/gen', async () => {
|
||||
runBenchmarkFlowPreview,
|
||||
runBenchmarkScriptByPath,
|
||||
runBenchmarkScriptPreview,
|
||||
updateBenchmarkDraft,
|
||||
listBenchmarkMcpTools
|
||||
updateBenchmarkDraft
|
||||
} = await import('./mockBackend')
|
||||
|
||||
function wrapService<T extends object>(target: T, overrides: Record<string, unknown>): T {
|
||||
@@ -434,12 +433,6 @@ vi.mock('$lib/gen', async () => {
|
||||
queryResourceTypes: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data)
|
||||
}),
|
||||
McpService: wrapService(actual.McpService, {
|
||||
listMcpTools: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? listBenchmarkMcpTools()
|
||||
: actual.McpService.listMcpTools(data)
|
||||
}),
|
||||
VariableService: wrapService(actual.VariableService, {
|
||||
existsVariable: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
|
||||
@@ -1919,11 +1919,10 @@
|
||||
- when the lookup fails, tells the user instead of inventing table names
|
||||
- does not write scripts or resources to answer a read-only question
|
||||
|
||||
# --- Dedicated tools preferred over the API catalog ---
|
||||
# The harness serves worker/queue reads itself (benchmark fetch handlers in
|
||||
# adapters/frontend), so these cases do not require an mcp-enabled eval backend.
|
||||
# The stale `api-catalog` in the id below is kept so results stay comparable
|
||||
# across benchmark runs.
|
||||
# --- Dedicated tools for workspace, run and instance state ---
|
||||
# The harness serves the worker read itself (a benchmark fetch handler in
|
||||
# adapters/frontend). The stale `api-catalog` in the id below is kept so
|
||||
# results stay comparable across benchmark runs.
|
||||
|
||||
- id: global-test30-api-catalog-workers
|
||||
prompt: |-
|
||||
@@ -1937,8 +1936,6 @@
|
||||
requiredToolsUsed:
|
||||
- list_workers
|
||||
forbiddenToolsUsed:
|
||||
- call_api_get
|
||||
- call_api_endpoint
|
||||
- write_script
|
||||
- deploy_workspace_item
|
||||
# Read-only workspace inspection produces no draft; validate via tool use.
|
||||
@@ -1990,13 +1987,11 @@
|
||||
requiredToolsUsed:
|
||||
- test_run_script
|
||||
forbiddenToolsUsed:
|
||||
- call_api_endpoint
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
# The judge only sees the drafts artifact and cannot observe runs, so it always
|
||||
# docks the prompt's "run it" requirement — validate deterministically instead:
|
||||
# draft content via valueIncludes, the test run via toolExpect (test_run_script
|
||||
# required, call_api_endpoint forbidden).
|
||||
# draft content via valueIncludes, the test run via toolExpect.
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- creates an AI draft of f/evals/global/format_greeting with the name uppercased in the greeting
|
||||
@@ -2014,13 +2009,11 @@
|
||||
requiredToolsUsed:
|
||||
- delete_workspace_item
|
||||
forbiddenToolsUsed:
|
||||
- call_api_endpoint
|
||||
- search_api_endpoints
|
||||
- write_script
|
||||
# Deletion produces no draft; validate via tool use.
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- deletes the deployed script via delete_workspace_item rather than a raw API endpoint
|
||||
- deletes the deployed script via delete_workspace_item
|
||||
|
||||
- id: global-test33-run-deployed-script-with-form
|
||||
prompt: |-
|
||||
@@ -2041,7 +2034,6 @@
|
||||
- read_workspace_item
|
||||
forbiddenToolsUsed:
|
||||
- test_run_script
|
||||
- call_api_endpoint
|
||||
- write_script
|
||||
- deploy_workspace_item
|
||||
# An empty form pushes the work back onto the user, so the prefill is part of
|
||||
@@ -2054,7 +2046,7 @@
|
||||
# Running produces no draft, and the judge cannot observe runs; validate via tool use.
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- runs the deployed script through run_script rather than a preview test run or a raw API endpoint
|
||||
- runs the deployed script through run_script rather than a preview test run
|
||||
- passes the name "ada" so the confirmation form comes up prefilled
|
||||
|
||||
- id: global-test34-run-with-secret-from-variable
|
||||
@@ -2113,7 +2105,6 @@
|
||||
- read_workspace_item
|
||||
forbiddenToolsUsed:
|
||||
- test_run_flow
|
||||
- call_api_endpoint
|
||||
- write_flow
|
||||
- deploy_workspace_item
|
||||
# An empty form pushes the work back onto the user, so the prefill is part of
|
||||
@@ -2126,7 +2117,7 @@
|
||||
# Running produces no draft, and the judge cannot observe runs; validate via tool use.
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- runs the deployed flow through run_flow rather than a preview test run or a raw API endpoint
|
||||
- runs the deployed flow through run_flow rather than a preview test run
|
||||
- passes the customer "acme" so the confirmation form comes up prefilled
|
||||
|
||||
- id: global-test36-draft-flow-test-run-not-deployed
|
||||
@@ -2151,7 +2142,6 @@
|
||||
# version instead — the edit would not be in what ran.
|
||||
forbiddenToolsUsed:
|
||||
- run_flow
|
||||
- call_api_endpoint
|
||||
- deploy_workspace_item
|
||||
# The judge cannot observe runs, and the edit's content is already pinned by
|
||||
# global-test5 on this fixture; what this case guards is where the run went.
|
||||
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (workspace_id, datatable, timestamp) DO UPDATE SET name = EXCLUDED.name, code_up = EXCLUDED.code_up, code_down = EXCLUDED.code_down WHERE NOT $7 OR (datatable_migrations.name = $8::text AND datatable_migrations.code_up = $9::text AND datatable_migrations.code_down IS NOT DISTINCT FROM $10::text)",
|
||||
"query": "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (workspace_id, datatable, timestamp) DO UPDATE SET name = EXCLUDED.name, code_up = EXCLUDED.code_up, code_down = EXCLUDED.code_down WHERE datatable_migrations.name = $7::text AND datatable_migrations.code_up = $8::text AND datatable_migrations.code_down IS NOT DISTINCT FROM $9::text",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -11,7 +11,6 @@
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
@@ -19,5 +18,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2b93351b9c2c91272c3f69c9c8eba192d2e4d087c7d7db8d3b257bb864c8265d"
|
||||
"hash": "0f374865faa5bdf884345808a004ce891a85daa00e037aba4b65b60f87243bd3"
|
||||
}
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3 RETURNING name",
|
||||
"query": "DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3 AND code_up = $4 AND code_down IS NOT DISTINCT FROM $5 RETURNING name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -13,12 +13,14 @@
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8"
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3c057e0284f1219865b8f7dc6eb3c1293c4b72d0b52abeefd1e954617984a2d8"
|
||||
"hash": "91e2456814ac29534f5a1f65a3bb97462276930f0a83ffb458f7cb26035c8c3d"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n (SELECT CASE WHEN r.v->'governed_by'->>'workspace_id' = $2\n THEN jsonb_set(r.v, '{governed_by,workspace_id}', to_jsonb($1::text))\n ELSE r.v END\n FROM (SELECT CASE WHEN dt.value->'reference'->>'workspace_id' = $2\n THEN jsonb_set(dt.value, '{reference,workspace_id}', to_jsonb($1::text))\n ELSE dt.value END AS v) r)\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'\n AND (ws.datatable::text LIKE '%\"reference\"%'\n OR ws.datatable::text LIKE '%\"governed_by\"%')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d0548225d92e6e7d0eb9a0227a6522398d2a3ae99d0568b785e63749d6f3225a"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE EXISTS (\n SELECT 1 FROM (VALUES ('reference'), ('governed_by')) k(link)\n WHERE dt.value->k.link->>'workspace_id' = $1\n AND dt.value->k.link->>'datatable' = $2\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "datatable!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e524e89440004953cc0b1cb57b8df6bc09f19cf53b141fac5aefa981463d39fa"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n (SELECT CASE WHEN r.v->'governed_by'->>'workspace_id' = $1\n AND r.v->'governed_by'->>'datatable' = $2\n THEN jsonb_set(r.v, '{governed_by,datatable}', to_jsonb($3::text))\n ELSE r.v END\n FROM (SELECT CASE WHEN dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2\n THEN jsonb_set(dt.value, '{reference,datatable}', to_jsonb($3::text))\n ELSE dt.value END AS v) r)\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE EXISTS (\n SELECT 1 FROM jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d,\n LATERAL (VALUES ('reference'), ('governed_by')) k(link)\n WHERE d.value->k.link->>'workspace_id' = $1\n AND d.value->k.link->>'datatable' = $2\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f7ece5036ad92e485b5e15a70e5052b42aaf5c87e5be6333b2df67a81990c8a6"
|
||||
}
|
||||
+28
-17
@@ -23,7 +23,7 @@
|
||||
## Cargo features & running the dev backend
|
||||
|
||||
The dev backend runs under `cargo watch` and is launched by default with **only
|
||||
`--features quickjs`** (see the tmux backend pane). That baseline compiles fast but
|
||||
`--features quickjs`** (see the backend pane). That baseline compiles fast but
|
||||
**deliberately omits most functionality** — notably S3/object storage, the S3 proxy, all
|
||||
EE code, MCP, and every non-JS language runtime. A running server never gains a feature you
|
||||
didn't compile in: feature-gated routes 404 or return a `"requires <feature>"` stub. So if
|
||||
@@ -33,22 +33,30 @@ MUST **restart the backend with the appropriate features** for what you're worki
|
||||
### Restarting the dev backend with the right features
|
||||
|
||||
Restart in the **same pane**, so the relaunch inherits that pane's `DATABASE_URL`, `BACKEND_PORT`
|
||||
and the rest of `runtime.env`. Scope every kill to this worktree — **never**
|
||||
and the rest of the worktree environment. Scope every kill to this worktree — **never**
|
||||
`pkill -f target/debug/windmill`, which kills every sibling worktree's backend.
|
||||
|
||||
1. Find the backend pane by what it is running, not by index. The index depends on the webmux
|
||||
profile: pane 1 is the backend under `full`, but the *frontend* under `frontendOnly`.
|
||||
1. Find the backend pane by where it is running, not by index. Under herdr the two server panes
|
||||
sit in the worktree's `backend/` and `frontend/`, so match on `cwd`:
|
||||
|
||||
```bash
|
||||
herdr pane list --workspace "$HERDR_WORKSPACE_ID"
|
||||
```
|
||||
|
||||
Under webmux they are tmux panes, and the index depends on the profile: pane 1 is the backend
|
||||
under `full`, but the *frontend* under `frontendOnly`.
|
||||
|
||||
```bash
|
||||
WIN=$(tmux display-message -p -t "$TMUX_PANE" '#{window_id}')
|
||||
tmux list-panes -t "$WIN" -F '#{pane_index} #{pane_current_command} #{pane_pid}'
|
||||
```
|
||||
|
||||
2. Read the feature set it is **actually** running. `CARGO_FEATURES` in `runtime.env` is only what
|
||||
the pane started with, and goes stale the first time anyone restarts by hand:
|
||||
2. Read the feature set it is **actually** running. The `CARGO_FEATURES` the worktree was created
|
||||
with is only what the pane started with, and goes stale the first time anyone restarts by hand:
|
||||
|
||||
```bash
|
||||
ps --ppid <pane_pid> -o args=
|
||||
herdr pane process-info --pane <pane_id> # foreground_processes[].argv
|
||||
ps --ppid <pane_pid> -o args= # under webmux
|
||||
# /home/hugo/.cargo/bin/cargo-watch watch -x run --features quickjs
|
||||
```
|
||||
|
||||
@@ -56,22 +64,25 @@ and the rest of `runtime.env`. Scope every kill to this worktree — **never**
|
||||
explicitly:
|
||||
|
||||
```bash
|
||||
tmux send-keys -t "$WIN.<idx>" C-c
|
||||
tmux send-keys -t "$WIN.<idx>" 'PORT=$BACKEND_PORT cargo watch -x "run --features quickjs,private,parquet"' Enter
|
||||
herdr pane send-keys <pane_id> C-c
|
||||
herdr pane run <pane_id> 'PORT=$BACKEND_PORT cargo watch -x "run --features quickjs,private,parquet"'
|
||||
# under webmux: tmux send-keys -t "$WIN.<idx>" C-c, then the same command line with Enter
|
||||
```
|
||||
|
||||
Carry over every feature the old command had unless you mean to drop one — rebuilding the list
|
||||
from memory is how a backend silently loses `quickjs`.
|
||||
|
||||
4. Persist the new set so a recreated pane starts with it: set `CARGO_FEATURES` in the
|
||||
worktree's `.env.local`, which `scripts/post-create.sh` writes and webmux reads. Do **not**
|
||||
edit `runtime.env` for this — webmux regenerates it from metadata and `.env.local` every time
|
||||
the worktree is opened, so an edit there is lost on the next reopen. Either way the change
|
||||
only affects a future pane; step 3 is what takes effect now.
|
||||
4. Persist the new set so a recreated pane starts with it: set `CARGO_FEATURES` in the worktree's
|
||||
`.env.local`, which the worktree hooks write. A herdr pane sources that file directly, so the
|
||||
next pane picks the value up. A webmux pane sources `webmux/runtime.env` instead, which webmux
|
||||
regenerates from metadata and `.env.local` when the worktree is *opened* — so the value lands
|
||||
on the next reopen, not the next pane, and editing `runtime.env` by hand is lost at that same
|
||||
reopen. Either way the change only affects a future pane; step 3 is what takes effect now.
|
||||
|
||||
5. Re-capture the pane until `health check completed` appears before hitting the API. A cold
|
||||
rebuild takes ~60s, and the previous run's success line is still in the scrollback, so a
|
||||
capture taken too early reads as ready when it isn't.
|
||||
5. Re-read the pane (`herdr pane read <pane_id> --source recent-unwrapped`, or `tmux
|
||||
capture-pane`) until `health check completed` appears before hitting the API. A cold rebuild
|
||||
takes ~60s, and the previous run's success line is still in the scrollback, so a capture taken
|
||||
too early reads as ready when it isn't.
|
||||
|
||||
cargo-watch only re-runs on a file change, so after an idle/failed run `touch README.md` (from
|
||||
`backend/`, where the watch runs) is a cheap retrigger (touching a `.rs` forces a full rebuild).
|
||||
|
||||
Generated
+23
-28
@@ -6949,13 +6949,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "8.3.0"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"pem 1.1.1",
|
||||
"ring 0.16.20",
|
||||
"base64 0.22.1",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring 0.17.14",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
@@ -6963,17 +6964,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
version = "10.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
"getrandom 0.2.17",
|
||||
"js-sys",
|
||||
"pem 3.0.6",
|
||||
"ring 0.17.14",
|
||||
"pem",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"signature",
|
||||
"simple_asn1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7068,7 +7072,7 @@ dependencies = [
|
||||
"jsonpath-rust",
|
||||
"k8s-openapi",
|
||||
"kube-core",
|
||||
"pem 3.0.6",
|
||||
"pem",
|
||||
"rustls 0.23.35",
|
||||
"secrecy",
|
||||
"serde",
|
||||
@@ -7932,7 +7936,7 @@ dependencies = [
|
||||
"lru 0.18.4",
|
||||
"mysql_common",
|
||||
"native-tls",
|
||||
"pem 3.0.6",
|
||||
"pem",
|
||||
"percent-encoding",
|
||||
"rand 0.10.2",
|
||||
"serde",
|
||||
@@ -9075,15 +9079,6 @@ dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8"
|
||||
dependencies = [
|
||||
"base64 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
@@ -10125,7 +10120,7 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||
dependencies = [
|
||||
"pem 3.0.6",
|
||||
"pem",
|
||||
"ring 0.17.14",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
@@ -14810,7 +14805,7 @@ dependencies = [
|
||||
"git-version",
|
||||
"hex",
|
||||
"hmac",
|
||||
"jsonwebtoken 8.3.0",
|
||||
"jsonwebtoken 10.4.0",
|
||||
"lazy_static",
|
||||
"once_cell",
|
||||
"opentelemetry 0.30.0",
|
||||
@@ -14961,7 +14956,7 @@ dependencies = [
|
||||
"hyper 1.11.1",
|
||||
"indexmap 2.14.2",
|
||||
"itertools 0.14.0",
|
||||
"jsonwebtoken 8.3.0",
|
||||
"jsonwebtoken 10.4.0",
|
||||
"lazy_static",
|
||||
"libxml",
|
||||
"magic-crypt",
|
||||
@@ -15115,7 +15110,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"http 1.5.0",
|
||||
"itertools 0.14.0",
|
||||
"jsonwebtoken 8.3.0",
|
||||
"jsonwebtoken 10.4.0",
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.5",
|
||||
@@ -15635,7 +15630,7 @@ dependencies = [
|
||||
"hyper 1.11.1",
|
||||
"indexmap 2.14.2",
|
||||
"itertools 0.14.0",
|
||||
"jsonwebtoken 8.3.0",
|
||||
"jsonwebtoken 10.4.0",
|
||||
"lazy_static",
|
||||
"magic-crypt",
|
||||
"mail-send",
|
||||
@@ -16472,7 +16467,7 @@ dependencies = [
|
||||
"google-cloud-pubsub",
|
||||
"http 1.5.0",
|
||||
"itertools 0.14.0",
|
||||
"jsonwebtoken 8.3.0",
|
||||
"jsonwebtoken 10.4.0",
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.5",
|
||||
@@ -16740,7 +16735,7 @@ dependencies = [
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper-util",
|
||||
"itertools 0.14.0",
|
||||
"jsonwebtoken 8.3.0",
|
||||
"jsonwebtoken 10.4.0",
|
||||
"lazy_static",
|
||||
"libffi-sys",
|
||||
"libloading",
|
||||
@@ -16754,7 +16749,7 @@ dependencies = [
|
||||
"opentelemetry-proto 0.30.0",
|
||||
"oracle",
|
||||
"parquet",
|
||||
"pem 3.0.6",
|
||||
"pem",
|
||||
"pep440_rs",
|
||||
"postgres-native-tls 0.5.3",
|
||||
"process-wrap",
|
||||
|
||||
+3
-1
@@ -638,7 +638,9 @@ libxml = { version = "=0.3.3" }
|
||||
samael = { git="https://github.com/njaremko/samael", rev="f879f1942ec1b34b6d3027ce7e4724ad95d15dfa", features = ["xmlsec"] }
|
||||
gcp_auth = "0.9.0"
|
||||
rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]}
|
||||
jsonwebtoken = "8.3.0"
|
||||
# aws_lc_rs, not rust_crypto: the RustCrypto backend refuses RSA keys above 4096 bits, and
|
||||
# external/guest JWT issuers may sign with 8192-bit keys.
|
||||
jsonwebtoken = { version = "^10.3", features = ["aws_lc_rs"] }
|
||||
pem = "3.0.1"
|
||||
nix = { version = "0.27.1", features = ["process", "signal"] }
|
||||
fs4 = "0.13"
|
||||
|
||||
@@ -1 +1 @@
|
||||
92a72d522c3934f46f146ac35f565cf4af97928d
|
||||
5585dae0957838fd7bd2ae8c58059431f2cea4d9
|
||||
|
||||
+46
-43
@@ -267,6 +267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -374,12 +375,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -1337,7 +1332,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"spin 0.9.8",
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2186,16 +2181,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "8.3.0"
|
||||
version = "10.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378"
|
||||
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
"getrandom 0.2.17",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring 0.16.20",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"signature",
|
||||
"simple_asn1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2225,7 +2224,7 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin 0.9.8",
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2598,7 +2597,7 @@ dependencies = [
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin 0.9.8",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
@@ -2994,11 +2993,12 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "1.1.1"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64 0.13.1",
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3403,7 +3403,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.0",
|
||||
"ring 0.17.14",
|
||||
"ring",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
@@ -3755,21 +3755,6 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.16.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"spin 0.5.2",
|
||||
"untrusted 0.7.1",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3901,7 +3886,7 @@ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"ring 0.17.14",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
@@ -3964,7 +3949,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring 0.17.14",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
@@ -4199,15 +4184,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.143"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4426,12 +4412,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
@@ -6233,6 +6213,7 @@ dependencies = [
|
||||
"pep440_rs",
|
||||
"phf 0.11.3",
|
||||
"pin-project-lite",
|
||||
"pkcs1",
|
||||
"postgres-native-tls",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
@@ -6244,8 +6225,10 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yml",
|
||||
"sha1",
|
||||
"sha2 0.10.9",
|
||||
"size",
|
||||
"spki",
|
||||
"sqlx",
|
||||
"strum 0.27.2",
|
||||
"strum_macros 0.27.2",
|
||||
@@ -7282,6 +7265,20 @@ name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
@@ -7315,3 +7312,9 @@ dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
@@ -142,3 +142,35 @@ async fn test_add_down_to_an_applied_migration(db: Pool<Postgres>) -> anyhow::Re
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A definition saved before `-- role` annotations were parsed can open with a comment that now
|
||||
/// reads as a malformed one. It never gets through a role check again, so an admin must still be
|
||||
/// able to finish it rather than be refused on its annotation.
|
||||
#[sqlx::test(fixtures("datatable_migrations_grants"))]
|
||||
async fn test_add_down_to_a_migration_whose_comment_predates_role_annotations(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
setup_applied_migration_without_down(&db).await?;
|
||||
let code_up = format!("-- Role table for widgets\n{CODE_UP}");
|
||||
sqlx::query("UPDATE datatable_migrations SET code_up = $1 WHERE workspace_id = 'dtmig-ws'")
|
||||
.bind(&code_up)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let resp = authed(reqwest::Client::new().post(format!(
|
||||
"http://localhost:{}/api/w/dtmig-ws/workspaces/upsert_datatable_migration/main",
|
||||
server.addr.port()
|
||||
)))
|
||||
.json(&json!({
|
||||
"timestamp": VERSION,
|
||||
"name": "create_widgets",
|
||||
"code_up": code_up,
|
||||
"code_down": CODE_DOWN,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -210,7 +210,9 @@ pub fn validate_model_pricing_json(ai_config: &serde_json::Value) -> Result<(),
|
||||
return Err(format!("Price override for {} is not an object", key));
|
||||
};
|
||||
for field in ["input", "output", "cache_read", "cache_write"] {
|
||||
let Some(rate) = price.get(field) else { continue };
|
||||
let Some(rate) = price.get(field) else {
|
||||
continue;
|
||||
};
|
||||
let rate = rate
|
||||
.as_f64()
|
||||
.filter(|r| r.is_finite() && *r >= 0.0 && *r <= MAX_MODEL_RATE);
|
||||
@@ -223,9 +225,98 @@ pub fn validate_model_pricing_json(ai_config: &serde_json::Value) -> Result<(),
|
||||
}
|
||||
for required in ["input", "output"] {
|
||||
if !price.contains_key(required) {
|
||||
return Err(format!("Price override for {} is missing {}", key, required));
|
||||
return Err(format!(
|
||||
"Price override for {} is missing {}",
|
||||
key, required
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-model token maps
|
||||
// ============================================================================
|
||||
|
||||
/// One `provider:model` -> tokens map of the AI config, with the bounds the API
|
||||
/// documents for it. Both maps are validated the same way, so the bounds live here
|
||||
/// rather than at each call site.
|
||||
pub struct TokenMap {
|
||||
pub field: &'static str,
|
||||
pub label: &'static str,
|
||||
pub min: i64,
|
||||
pub max: i64,
|
||||
}
|
||||
|
||||
/// The chat compacts its history as it nears the window, so a value below any real
|
||||
/// model's window would compact every turn; the ceiling is far above any shipped
|
||||
/// model and only catches typos.
|
||||
pub const CONTEXT_WINDOWS: TokenMap = TokenMap {
|
||||
field: "context_window_per_model",
|
||||
label: "Context window",
|
||||
min: 1024,
|
||||
max: 10_000_000,
|
||||
};
|
||||
|
||||
/// The completion cap the chat asks the provider for.
|
||||
pub const OUTPUT_LIMITS: TokenMap =
|
||||
TokenMap { field: "max_tokens_per_model", label: "Output limit", min: 1, max: 2_000_000 };
|
||||
|
||||
pub const TOKEN_MAPS: [TokenMap; 2] = [CONTEXT_WINDOWS, OUTPUT_LIMITS];
|
||||
|
||||
pub fn validate_token_limit(map: &TokenMap, key: &str, tokens: i64) -> Result<(), String> {
|
||||
if (map.min..=map.max).contains(&tokens) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{} for {} must be between {} and {} tokens",
|
||||
map.label, key, map.min, map.max
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound these maps in the untyped instance AI config, for the reason given on
|
||||
/// `validate_model_pricing_json`.
|
||||
pub fn validate_token_maps_json(ai_config: &serde_json::Value) -> Result<(), String> {
|
||||
for map in TOKEN_MAPS.iter() {
|
||||
let entries = match ai_config.get(map.field) {
|
||||
None | Some(serde_json::Value::Null) => continue,
|
||||
Some(v) => v
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("{} must be an object", map.field))?,
|
||||
};
|
||||
for (key, tokens) in entries {
|
||||
let tokens = tokens
|
||||
.as_i64()
|
||||
.ok_or_else(|| format!("{} for {} must be an integer", map.label, key))?;
|
||||
validate_token_limit(map, key, tokens)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn instance_token_maps_are_bounded_integers() {
|
||||
assert!(validate_token_maps_json(&json!({})).is_ok());
|
||||
for map in TOKEN_MAPS.iter() {
|
||||
let entry = |value| json!({ map.field: { "customai:m": value } });
|
||||
assert!(validate_token_maps_json(&entry(json!(map.min))).is_ok());
|
||||
assert!(validate_token_maps_json(&entry(json!(map.max))).is_ok());
|
||||
for bad in [
|
||||
json!(map.min - 1),
|
||||
json!(map.max + 1),
|
||||
json!(4096.5),
|
||||
json!("4096"),
|
||||
] {
|
||||
assert!(validate_token_maps_json(&entry(bad)).is_err());
|
||||
}
|
||||
assert!(validate_token_maps_json(&json!({ map.field: [4096] })).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,21 +627,234 @@ async fn a_rename_has_to_match_the_save_it_claims_to_describe(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn database_exists(db: &Pool<Postgres>, name: &str) -> anyhow::Result<bool> {
|
||||
Ok(
|
||||
sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1)")
|
||||
.bind(name)
|
||||
.fetch_one(db)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
async fn workspace_exists(db: &Pool<Postgres>, id: &str) -> anyhow::Result<bool> {
|
||||
Ok(
|
||||
sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM workspace WHERE id = $1)")
|
||||
.bind(id)
|
||||
.fetch_one(db)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn a_data_table_under_roles_is_not_copied_into_a_fork(
|
||||
async fn a_data_table_under_roles_is_cloned_only_with_its_grants(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
// The database a fork's copy of `main` goes into is named after the fork. Every request below
|
||||
// is refused before it is created, so the cluster-wide name never collides with a sibling run.
|
||||
let target = "wm_fork_copy__main";
|
||||
let fork = |token: &str, forked: Value| {
|
||||
authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/workspaces/create_fork"
|
||||
)),
|
||||
token,
|
||||
)
|
||||
.json(&json!({"id": "wm-fork-copy", "name": "copy", "forked_datatables": [forked]}))
|
||||
};
|
||||
|
||||
// Rows are a workspace admin's to copy, as they were before roles.
|
||||
let resp = fork(
|
||||
"SECRET_TOKEN_2",
|
||||
json!({"name": "main", "new_dbname": target, "fork_behavior": "schema_and_data"}),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 403, "{}", resp.text().await?);
|
||||
assert!(!database_exists(&db, target).await?);
|
||||
|
||||
// Even the schema alone lists every table, which a member covered by no role cannot read in
|
||||
// the parent.
|
||||
let resp = fork(
|
||||
"SECRET_TOKEN_3",
|
||||
json!({"name": "main", "new_dbname": target, "fork_behavior": "schema_only"}),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 401, "{}", resp.text().await?);
|
||||
assert!(!database_exists(&db, target).await?);
|
||||
|
||||
// Refused in the first phase too, before a git branch is created for a fork that cannot be.
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/workspaces/create_workspace_fork_branch"
|
||||
)),
|
||||
"SECRET_TOKEN_3",
|
||||
)
|
||||
.json(
|
||||
&json!({"id": "wm-fork-copy", "name": "copy", "forked_datatables": [
|
||||
{"name": "main", "new_dbname": target, "fork_behavior": "schema_only"}
|
||||
]}),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 401, "{}", resp.text().await?);
|
||||
|
||||
// A database the request did not create — whatever the data table, under roles or not — may be
|
||||
// a copy left behind by a deleted fork, which the entry would reach without its governance.
|
||||
let resp = fork(
|
||||
"SECRET_TOKEN",
|
||||
json!({"name": "other", "new_dbname": "wm_fork_copy__other"}),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
assert!(
|
||||
resp.text().await?.contains("fork_behavior"),
|
||||
"a database the fork did not create was taken"
|
||||
);
|
||||
assert!(!workspace_exists(&db, "wm-fork-copy").await?);
|
||||
|
||||
// The copy an admin asks for goes ahead in an edition that replays grants, and is refused
|
||||
// before any database exists in one that does not. The fixture's database does not exist, so
|
||||
// the dump fails either way — and leaves neither a database nor a fork behind.
|
||||
let resp = fork(
|
||||
"SECRET_TOKEN",
|
||||
json!({"name": "main", "new_dbname": target, "fork_behavior": "schema_only"}),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_ne!(resp.status(), 200);
|
||||
let body = resp.text().await?;
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
assert!(body.contains("pg_dump"), "{body}");
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
assert!(body.contains("Enterprise Edition"), "{body}");
|
||||
assert!(!database_exists(&db, target).await?);
|
||||
assert!(!workspace_exists(&db, "wm-fork-copy").await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn a_clone_takes_its_roles_from_the_data_table_it_was_cloned_from(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
sqlx::query(
|
||||
r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, '{datatables,copy}', '{
|
||||
"database": {"resource_type": "instance", "resource_path": "wm_fork_dt__copy"},
|
||||
"governed_by": {"workspace_id": "test-workspace", "datatable": "main"},
|
||||
"forked_from": {}
|
||||
}'::jsonb) WHERE workspace_id = 'wm-fork-dt'"#,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let fork = format!("http://localhost:{port}/api/w/wm-fork-dt/workspaces");
|
||||
|
||||
// `test-user-2` administers the fork, and is only a tenant of `analytics` in the parent.
|
||||
let resp = authed(
|
||||
client().get(format!("{fork}/datatable_usable_roles/copy")),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let body: Value = resp.json().await?;
|
||||
assert_eq!(body["roles"], json!(["analytics"]), "{body}");
|
||||
|
||||
let resp = authed(
|
||||
client().get(format!("{fork}/datatable_permissions/copy")),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let body: Value = resp.json().await?;
|
||||
assert_eq!(body["editable"], false, "{body}");
|
||||
assert_eq!(body["clone_of"]["workspace_id"], "test-workspace", "{body}");
|
||||
|
||||
// Nobody changes a clone's roles, a superadmin included: they are the source's.
|
||||
for token in ["SECRET_TOKEN_2", "SECRET_TOKEN"] {
|
||||
let resp = authed(
|
||||
client().post(format!("{fork}/datatable_permissions/copy")),
|
||||
token,
|
||||
)
|
||||
.json(&json!({"permissioned": false}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400, "{token} changed a clone's roles");
|
||||
}
|
||||
|
||||
// A settings save cannot clear the link.
|
||||
let resp = authed(
|
||||
client().post(format!("{fork}/edit_datatable_config")),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.json(&json!({
|
||||
"settings": {"datatables": {"main": {}, "copy": {
|
||||
"database": {"resource_type": "instance", "resource_path": "wm_fork_dt__copy"}
|
||||
}}},
|
||||
"renames": [], "deleted_datatables": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let governed_by: Option<Value> = sqlx::query_scalar(
|
||||
"SELECT datatable->'datatables'->'copy'->'governed_by' FROM workspace_settings
|
||||
WHERE workspace_id = 'wm-fork-dt'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(governed_by.unwrap()["datatable"], "main");
|
||||
|
||||
// Nor move it, a superadmin included: its grants were replayed into that database alone.
|
||||
let resp = authed(
|
||||
client().post(format!("{fork}/edit_datatable_config")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"settings": {"datatables": {"main": {}, "copy": {
|
||||
"database": {"resource_type": "instance", "resource_path": "wm_fork_dt__elsewhere"}
|
||||
}}},
|
||||
"renames": [], "deleted_datatables": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
|
||||
|
||||
// Nor reach its database through a second entry without roles, which would connect as `admin`.
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/workspaces/edit_datatable_config"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"settings": {"datatables": {
|
||||
"main": {"database": {"resource_type": "instance", "resource_path": "dt_main"}},
|
||||
"alias": {"database": {"resource_type": "instance", "resource_path": "wm_fork_dt__copy"}}
|
||||
}},
|
||||
"renames": [], "deleted_datatables": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(status, 400, "{body}");
|
||||
assert!(body.contains("without carrying those roles"), "{body}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn a_data_table_under_roles_is_not_copied_without_its_grants(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
// `pg_dump` carries no roles and the restore drops ACLs, so a copy would arrive with the
|
||||
// parent's tenants and none of the grants behind them: every role but admin denied by
|
||||
// Postgres in a data table that reads as configured. Refuse the copy rather than ship that,
|
||||
// and refuse it before any data moves.
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// Both halves of the clone: the database the copy would land in, then the copy itself. The
|
||||
// first has to refuse too, or a permissioned fork leaves an empty registered database that
|
||||
// no data table entry names and nothing collects.
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/workspaces/create_pg_database"
|
||||
@@ -1201,6 +1414,158 @@ async fn a_fork_waits_for_a_rename_of_the_data_table_it_keeps(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enable_migrations_on_main(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings
|
||||
SET datatable = jsonb_set(datatable, '{datatables,main,migrations_enabled}', 'true')
|
||||
WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn a_migration_is_written_only_by_who_may_run_it(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
enable_migrations_on_main(&db).await?;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let base = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/workspaces",
|
||||
server.addr.port()
|
||||
);
|
||||
let create = |token: &str, body: Value| {
|
||||
authed(
|
||||
client()
|
||||
.post(format!("{base}/create_datatable_migration/main"))
|
||||
.json(&body),
|
||||
token,
|
||||
)
|
||||
.send()
|
||||
};
|
||||
let upsert = |token: &str, code_up: &str| {
|
||||
authed(
|
||||
client()
|
||||
.post(format!("{base}/upsert_datatable_migration/main"))
|
||||
.json(&json!({"timestamp": 20260101000000i64, "name": "m", "code_up": code_up})),
|
||||
token,
|
||||
)
|
||||
.send()
|
||||
};
|
||||
|
||||
// `test-user-2` holds `analytics` and nothing else, so of a migration they may store only
|
||||
// what they could run: SQL that declares no role runs as admin, in the up or the down.
|
||||
for body in [
|
||||
json!({"name": "m", "code_up": "DROP TABLE t"}),
|
||||
json!({"name": "m", "code_up": "-- role analytics\nSELECT 1", "code_down": "DROP TABLE t"}),
|
||||
] {
|
||||
let resp = create("SECRET_TOKEN_2", body.clone()).await?;
|
||||
assert_eq!(resp.status(), 401, "{body}: {}", resp.text().await?);
|
||||
}
|
||||
let resp = create(
|
||||
"SECRET_TOKEN_2",
|
||||
json!({"name": "m", "code_up": "-- role analytics\nSELECT 1"}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
|
||||
// An admin's migration re-pushed as stored writes nothing, so it passes. Replacing it removes
|
||||
// SQL they could not run, even with SQL they could, so it is refused like deleting it.
|
||||
let resp = upsert("SECRET_TOKEN", "DROP TABLE t").await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let resp = upsert("SECRET_TOKEN_2", "DROP TABLE t").await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
for code_up in ["DROP TABLE u", "-- role analytics\nSELECT 1"] {
|
||||
let resp = upsert("SECRET_TOKEN_2", code_up).await?;
|
||||
assert_eq!(resp.status(), 401, "{code_up}: {}", resp.text().await?);
|
||||
}
|
||||
let resp = authed(
|
||||
client().delete(format!(
|
||||
"{base}/delete_datatable_migration/main/20260101000000"
|
||||
)),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 401, "{}", resp.text().await?);
|
||||
|
||||
let code_up: String = sqlx::query_scalar(
|
||||
"SELECT code_up FROM datatable_migrations
|
||||
WHERE workspace_id = 'test-workspace' AND timestamp = 20260101000000",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(code_up, "DROP TABLE t");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn an_operator_neither_writes_nor_runs_a_migration(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
enable_migrations_on_main(&db).await?;
|
||||
// Off roles, where every role check passes everyone, so only the operator guard is left to
|
||||
// refuse.
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings SET datatable = datatable #- '{datatables,main,permissions}'
|
||||
WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
// A token of its own: the auth cache is process-wide, and the other tests here authenticate
|
||||
// `SECRET_TOKEN_3` in this workspace as a non-operator.
|
||||
sqlx::raw_sql(
|
||||
"UPDATE usr SET operator = true, role = 'Operator'
|
||||
WHERE workspace_id = 'test-workspace' AND username = 'test-user-3';
|
||||
INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin)
|
||||
VALUES (encode(sha256('DT_OPERATOR_TOKEN'::bytea), 'hex'), 'DT_OPERATO',
|
||||
'DT_OPERATOR_TOKEN', 'test3@windmill.dev', 'operator', false);",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let base = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/workspaces",
|
||||
server.addr.port()
|
||||
);
|
||||
let migration = json!({"timestamp": 20260101000000i64, "name": "m", "code_up": "SELECT 1"});
|
||||
|
||||
for request in [
|
||||
client()
|
||||
.post(format!("{base}/create_datatable_migration/main"))
|
||||
.json(&migration),
|
||||
client()
|
||||
.post(format!("{base}/upsert_datatable_migration/main"))
|
||||
.json(&migration),
|
||||
client().delete(format!(
|
||||
"{base}/delete_datatable_migration/main/20260101000000"
|
||||
)),
|
||||
client().post(format!("{base}/run_datatable_migrations/main")),
|
||||
client().post(format!("{base}/rollback_datatable_migrations/main")),
|
||||
client().post(format!("{base}/generate_initial_datatable_migration/main")),
|
||||
] {
|
||||
let resp = authed(request, "DT_OPERATOR_TOKEN").send().await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
assert!(
|
||||
status == 401 && text.contains("Operators cannot"),
|
||||
"{status}: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
// The same write from a developer is accepted: what refused the operator is theirs alone.
|
||||
let resp = authed(
|
||||
client()
|
||||
.post(format!("{base}/upsert_datatable_migration/main"))
|
||||
.json(&migration),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
const ENTERPRISE_REFUSAL: &str = "Data table roles are a Windmill Enterprise Edition feature";
|
||||
|
||||
@@ -1321,3 +1686,73 @@ async fn without_the_enterprise_edition_a_data_table_under_roles_is_refused_a_co
|
||||
assert!(err.to_string().contains(ENTERPRISE_REFUSAL), "{err}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A save blocked on an instance database's lock is not a user of it until it commits one: the
|
||||
/// cleanup for a database whose setup failed lets that save through and reads what it left.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn cleanup_waits_out_a_save_racing_it_for_an_instance_database(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let key = "instance_database:dt_probe";
|
||||
|
||||
// Queues cleanup behind `holder`, then a save behind cleanup, so cleanup takes the lock with
|
||||
// the save already waiting on it — the ordering the waiter check is for.
|
||||
let race = |commit: bool| {
|
||||
let db = db.clone();
|
||||
async move {
|
||||
let mut holder = db.acquire().await?;
|
||||
sqlx::query("SELECT pg_advisory_lock(hashtext($1))")
|
||||
.bind(key)
|
||||
.execute(&mut *holder)
|
||||
.await?;
|
||||
|
||||
let cleanup = tokio::spawn({
|
||||
let db = db.clone();
|
||||
async move { windmill_common::drop_unused_instance_database(&db, "dt_probe").await }
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
|
||||
let save = tokio::spawn({
|
||||
let db = db.clone();
|
||||
async move {
|
||||
let mut tx = db.begin().await?;
|
||||
windmill_common::lock_instance_databases(&mut tx, ["dt_probe"]).await?;
|
||||
sqlx::query(
|
||||
r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable,
|
||||
'{datatables,probe}',
|
||||
'{"database": {"resource_type": "instance", "resource_path": "dt_probe"}}')
|
||||
WHERE workspace_id = 'test-workspace'"#,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if commit {
|
||||
tx.commit().await?;
|
||||
} else {
|
||||
tx.rollback().await?;
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
|
||||
sqlx::query("SELECT pg_advisory_unlock(hashtext($1))")
|
||||
.bind(key)
|
||||
.execute(&mut *holder)
|
||||
.await?;
|
||||
save.await??;
|
||||
Ok::<_, anyhow::Error>(cleanup.await??)
|
||||
}
|
||||
};
|
||||
|
||||
assert!(
|
||||
matches!(race(false).await?, windmill_common::Cleanup::Dropped),
|
||||
"a save that rolled back kept the database, whose name then blocks every retry"
|
||||
);
|
||||
let kept = race(true).await?;
|
||||
let windmill_common::Cleanup::InUse(users) = kept else {
|
||||
anyhow::bail!("the database was dropped under a save that committed a reference to it")
|
||||
};
|
||||
assert_eq!(users, vec!["test-workspace".to_string()]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -968,6 +968,8 @@ async fn run_setting_pre_write_hook(
|
||||
AI_CONFIG_SETTING => {
|
||||
windmill_ai::ai_types::validate_model_pricing_json(value)
|
||||
.map_err(error::Error::BadRequest)?;
|
||||
windmill_ai::ai_types::validate_token_maps_json(value)
|
||||
.map_err(error::Error::BadRequest)?;
|
||||
}
|
||||
AUTOMATE_USERNAME_CREATION_SETTING => {
|
||||
if value.as_bool().unwrap_or(false) {
|
||||
@@ -1712,6 +1714,8 @@ struct CustomInstanceDbLogs {
|
||||
replication_user: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
replication_user_error: Option<String>,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
user_connect: String,
|
||||
}
|
||||
|
||||
async fn list_custom_instance_pg_databases(
|
||||
@@ -2125,9 +2129,57 @@ async fn setup_custom_instance_pg_database_inner(
|
||||
}
|
||||
}
|
||||
|
||||
// Everything above logged in as the DATABASE_URL user, but whatever uses the database logs
|
||||
// in as custom_instance_user. A proxy that routes on the login name can accept one and
|
||||
// refuse the other, which would otherwise surface only once a data table first connects.
|
||||
let user_creds = PgDatabase {
|
||||
user: Some(windmill_common::datatable_roles::CUSTOM_INSTANCE_USER.to_string()),
|
||||
password: Some(windmill_common::utils::get_custom_pg_instance_password(db).await?),
|
||||
..pg_creds
|
||||
};
|
||||
let (client, connection) = user_creds
|
||||
.connect(Some(db))
|
||||
.await
|
||||
.map_err(|e| custom_instance_user_connect_error(&user_creds.host, dbname, e))?;
|
||||
let join_handle = tokio::spawn(async move { connection.await });
|
||||
logs.user_connect = "OK".to_string();
|
||||
drop(client); // /!\ Drop before joining to avoid deadlock
|
||||
windmill_common::shutdown_pg_connection(join_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn custom_instance_user_connect_error(host: &str, dbname: &str, e: error::Error) -> error::Error {
|
||||
let cause = match &e {
|
||||
error::Error::Anyhow { error, .. } => format!("{error:#}"),
|
||||
e => e.to_string(),
|
||||
};
|
||||
// Supavisor, Supabase's pooler, reads the tenant to route to from the login
|
||||
// (`<user>.<project_ref>`), and only knows the logins configured for that tenant.
|
||||
let lower = cause.to_lowercase();
|
||||
let routing_refused = [
|
||||
"enoidentifier",
|
||||
"tenant identifier",
|
||||
"tenant or user",
|
||||
"tenant/user",
|
||||
]
|
||||
.iter()
|
||||
.any(|signature| lower.contains(signature));
|
||||
if routing_refused {
|
||||
error::Error::BadConfig(format!(
|
||||
"DATABASE_URL reaches Postgres through a connection pooler ({host}) that picks the \
|
||||
server to route to from the login name, and it refused custom_instance_user, the \
|
||||
role Windmill uses for instance databases ({cause}). Instance databases cannot be \
|
||||
used through this pooler: use your own Postgres database instead, or point \
|
||||
DATABASE_URL at the Postgres server directly rather than at the pooler."
|
||||
))
|
||||
} else {
|
||||
error::Error::ExecutionErr(format!(
|
||||
"Could not connect to {dbname} as custom_instance_user: {cause}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn drop_custom_instance_pg_database(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -2530,6 +2582,34 @@ mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use windmill_common::instance_config::{GlobalSettings, InstanceConfig, WorkerGroupConfig};
|
||||
|
||||
#[test]
|
||||
fn supavisor_refusing_custom_instance_user_is_named() {
|
||||
use windmill_common::error::{to_anyhow, Error};
|
||||
let connect_error = |message: &str| {
|
||||
let e = Error::from(to_anyhow(std::io::Error::other(message.to_string())));
|
||||
super::custom_instance_user_connect_error(
|
||||
"aws-0-eu-west-1.pooler.supabase.com",
|
||||
"dt",
|
||||
e,
|
||||
)
|
||||
};
|
||||
for supavisor in [
|
||||
"db error: FATAL: (ENOIDENTIFIER) no tenant identifier provided",
|
||||
"db error: FATAL: Tenant or user not found",
|
||||
] {
|
||||
assert!(
|
||||
matches!(connect_error(supavisor), Error::BadConfig(m) if m.contains("connection pooler")),
|
||||
"{supavisor}"
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
connect_error(
|
||||
"db error: FATAL: password authentication failed for user \"custom_instance_user\""
|
||||
),
|
||||
Error::ExecutionErr(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_config_yaml_round_trip() {
|
||||
let config = InstanceConfig {
|
||||
|
||||
@@ -245,6 +245,8 @@ pub struct DatatableAclInfo {
|
||||
/// Whether this caller may plan and apply changes: they administer the data table, on an
|
||||
/// edition that has the planner.
|
||||
pub editable: bool,
|
||||
/// Whether the data table is a clone, whose grants stay as they were copied from its source.
|
||||
pub clone: bool,
|
||||
/// Whether the server is Postgres 17 or later, which added the `MAINTAIN` table privilege.
|
||||
pub supports_maintain: bool,
|
||||
/// The database the target lives in, which no target carries itself.
|
||||
@@ -335,13 +337,40 @@ async fn connect_as_admin_unchecked(
|
||||
pg.user = Some(CUSTOM_INSTANCE_USER.to_string());
|
||||
pg.password = Some(windmill_common::utils::get_custom_pg_instance_password(db).await?);
|
||||
let dbname = pg.dbname.clone();
|
||||
let (client, mut connection) = pg.connect(Some(db)).await?;
|
||||
let (client, notices) = connect_with_notices(db, &pg).await?;
|
||||
Ok((client, notices, dbname))
|
||||
}
|
||||
|
||||
/// A connection to `pg`, and the notices Postgres sends on it — which is where a grant or revoke
|
||||
/// that changed nothing is reported ([`execute_acl_statements`]).
|
||||
pub(crate) async fn connect_with_notices(
|
||||
db: &DB,
|
||||
pg: &PgDatabase,
|
||||
) -> Result<(tokio_postgres::Client, mpsc::UnboundedReceiver<DbError>)> {
|
||||
let (client, connection) = pg.connect(Some(db)).await?;
|
||||
Ok((
|
||||
client,
|
||||
drive_with_notices(connection, windmill_common::TokioPgConnection::poll_message),
|
||||
))
|
||||
}
|
||||
|
||||
type PollMessage<C> =
|
||||
fn(
|
||||
&mut C,
|
||||
&mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<std::result::Result<AsyncMessage, tokio_postgres::Error>>>;
|
||||
|
||||
/// Drive `connection` in the background with `poll`, forwarding its notices.
|
||||
pub(crate) fn drive_with_notices<C: Send + 'static>(
|
||||
mut connection: C,
|
||||
poll: PollMessage<C>,
|
||||
) -> mpsc::UnboundedReceiver<DbError> {
|
||||
// Unbounded: the driver must never wait on the receiver, which only drains once the statement
|
||||
// the driver is carrying has completed.
|
||||
let (notices_tx, notices) = mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match std::future::poll_fn(|cx| connection.poll_message(cx)).await {
|
||||
match std::future::poll_fn(|cx| poll(&mut connection, cx)).await {
|
||||
Some(Ok(AsyncMessage::Notice(notice))) => {
|
||||
let _ = notices_tx.send(notice);
|
||||
}
|
||||
@@ -354,7 +383,41 @@ async fn connect_as_admin_unchecked(
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok((client, notices, dbname))
|
||||
notices
|
||||
}
|
||||
|
||||
/// Run `statements` in order on `tx`, failing on the first that errors or that Postgres only warns
|
||||
/// about. A privilege the connection cannot pass on is a warning to Postgres (`01007` / `01006`),
|
||||
/// which then carries on having changed nothing; returning drops the transaction, rolling back
|
||||
/// everything before it.
|
||||
///
|
||||
/// Authorization: none. Runs `statements` as the connection `tx` is on; callers MUST have authorized
|
||||
/// changing that database's access, and built the statements themselves.
|
||||
pub(crate) async fn execute_acl_statements(
|
||||
tx: &tokio_postgres::Transaction<'_>,
|
||||
notices: &mut mpsc::UnboundedReceiver<DbError>,
|
||||
statements: &[String],
|
||||
) -> Result<()> {
|
||||
while notices.try_recv().is_ok() {}
|
||||
for statement in statements {
|
||||
tx.batch_execute(statement).await.map_err(|e| {
|
||||
Error::ExecutionErr(format!(
|
||||
"Failed to run `{statement}`: {}",
|
||||
pg_error_message(&e)
|
||||
))
|
||||
})?;
|
||||
while let Ok(notice) = notices.try_recv() {
|
||||
if *notice.code() == SqlState::WARNING_PRIVILEGE_NOT_GRANTED
|
||||
|| *notice.code() == SqlState::WARNING_PRIVILEGE_NOT_REVOKED
|
||||
{
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"`{statement}` did not take effect ({}), so nothing was applied",
|
||||
notice.message()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An object whose ownership follows the schema's.
|
||||
@@ -399,10 +462,12 @@ macro_rules! schema_owned_objects {
|
||||
AND x.refobjsubid <> 0)))"
|
||||
};
|
||||
}
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use schema_owned_objects;
|
||||
|
||||
/// The keyword `ALTER ... OWNER TO` takes for a kind of object, as `pg_identify_object` names the
|
||||
/// kind. A kind missing here is refused rather than skipped, which would leave it behind.
|
||||
fn owned_keyword(kind: &str) -> Option<&'static str> {
|
||||
pub(crate) fn owned_keyword(kind: &str) -> Option<&'static str> {
|
||||
Some(match kind {
|
||||
"table" => "TABLE",
|
||||
"view" => "VIEW",
|
||||
@@ -1054,6 +1119,7 @@ async fn get_datatable_acl(
|
||||
owner: role_name_of(&owner),
|
||||
roles,
|
||||
editable,
|
||||
clone: governing.governor.is_some(),
|
||||
supports_maintain,
|
||||
dbname,
|
||||
grants,
|
||||
@@ -1625,27 +1691,7 @@ async fn apply_datatable_acl(
|
||||
pg_error_message(&e)
|
||||
))
|
||||
})?;
|
||||
for statement in &plan.statements {
|
||||
pg_tx.batch_execute(statement).await.map_err(|e| {
|
||||
Error::ExecutionErr(format!(
|
||||
"Failed to run `{statement}`: {}",
|
||||
pg_error_message(&e)
|
||||
))
|
||||
})?;
|
||||
// A privilege the connection cannot pass on is only a warning to Postgres, which then
|
||||
// carries on having changed nothing. Returning drops the transaction, rolling back
|
||||
// everything before it.
|
||||
while let Ok(notice) = notices.try_recv() {
|
||||
if *notice.code() == SqlState::WARNING_PRIVILEGE_NOT_GRANTED
|
||||
|| *notice.code() == SqlState::WARNING_PRIVILEGE_NOT_REVOKED
|
||||
{
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"`{statement}` did not take effect ({}), so nothing was applied",
|
||||
notice.message()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
execute_acl_statements(&pg_tx, &mut notices, &plan.statements).await?;
|
||||
|
||||
// A schema's objects were listed before the transaction opened; one committed since would stay
|
||||
// with its old owner. One created while this transaction is still open can still slip past, as
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Copying a data table's database for the fork being created.
|
||||
//!
|
||||
//! The fork request makes its copies before it writes the fork: each is created, filled and — for a
|
||||
//! data table under roles — given the source's owners and grants. What each copy was made from
|
||||
//! stays in the request ([`MadeCopy`]), so the fork's transaction checks it against the source as it
|
||||
//! is then, and a fork that fails drops the instance copies it made ([`drop_copies_after`]) and names
|
||||
//! the others, which live on servers of the workspace's own.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::datatable_roles::{lock_role_catalog, read_role_catalog_tx};
|
||||
use windmill_common::error::{pg_error_message, Error, Result};
|
||||
use windmill_common::utils::require_admin;
|
||||
use windmill_common::worker::CLOUD_HOSTED;
|
||||
use windmill_common::workspaces::{
|
||||
get_datatable_resource_from_db_unchecked, DataTableDatabase, DataTableForkBehavior,
|
||||
GoverningDatatable,
|
||||
};
|
||||
use windmill_common::{PgDatabase, DB};
|
||||
|
||||
use crate::datatable_acl::connect_with_notices;
|
||||
use crate::datatable_permissions::ensure_reaches_governing_datatable;
|
||||
use crate::workspaces::{
|
||||
create_database_on_server, ensure_datatable_is_clonable, pg_dump_database, pg_import_dump,
|
||||
DumpFile, PgDumpOptions,
|
||||
};
|
||||
|
||||
/// A database this request created and filled for one data table of the fork.
|
||||
pub(crate) struct MadeCopy {
|
||||
/// The data table's name, in the parent and in the fork.
|
||||
pub(crate) name: String,
|
||||
pub(crate) dbname: String,
|
||||
pub(crate) behavior: DataTableForkBehavior,
|
||||
/// The database the source resolved to when it was copied.
|
||||
pub(crate) source_database: DataTableDatabase,
|
||||
/// Whether the source's owners and grants were replayed into the copy: it was under roles.
|
||||
pub(crate) replayed: bool,
|
||||
/// For a resource-backed copy, the resource and variables its connection was resolved from.
|
||||
pub(crate) connection: Option<ConnectionSnapshot>,
|
||||
}
|
||||
|
||||
/// What a resource-backed data table's connection is resolved from: its resource, and every
|
||||
/// resource and variable that one references, as stored — and, for a secret kept in an external
|
||||
/// backend, the value that backend holds. The connection is resolved from the snapshot itself
|
||||
/// ([`ConnectionSnapshot::resolve`]), so it is exactly the one these rows describe.
|
||||
#[derive(PartialEq)]
|
||||
pub(crate) struct ConnectionSnapshot {
|
||||
root: String,
|
||||
resources: std::collections::BTreeMap<String, Option<serde_json::Value>>,
|
||||
/// Stored value and whether it is secret.
|
||||
variables: std::collections::BTreeMap<String, Option<(String, bool)>>,
|
||||
external_secrets: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Read the [`ConnectionSnapshot`] of resource `resource_path` in workspace `w_id`.
|
||||
///
|
||||
/// Authorization: none, and it holds secret values. Callers MUST have authorized using that
|
||||
/// resource, and never disclose the snapshot or what it resolves to.
|
||||
pub(crate) async fn connection_snapshot(
|
||||
db: &DB,
|
||||
conn: &mut sqlx::PgConnection,
|
||||
w_id: &str,
|
||||
resource_path: &str,
|
||||
) -> Result<ConnectionSnapshot> {
|
||||
let root = resource_path.trim_start_matches("$res:").to_string();
|
||||
let mut snapshot = ConnectionSnapshot {
|
||||
root: root.clone(),
|
||||
resources: Default::default(),
|
||||
variables: Default::default(),
|
||||
external_secrets: Default::default(),
|
||||
};
|
||||
let mut pending = vec![root];
|
||||
while let Some(path) = pending.pop() {
|
||||
if snapshot.resources.contains_key(&path) {
|
||||
continue;
|
||||
}
|
||||
let value: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT value FROM resource WHERE workspace_id = $1 AND path = $2")
|
||||
.bind(w_id)
|
||||
.bind(&path)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
.flatten();
|
||||
let mut strings = vec![];
|
||||
collect_strings(value.as_ref(), &mut strings);
|
||||
for reference in strings {
|
||||
if let Some(var) = reference.strip_prefix("$var:") {
|
||||
if snapshot.variables.contains_key(var) {
|
||||
continue;
|
||||
}
|
||||
let row: Option<(String, bool)> = sqlx::query_as(
|
||||
"SELECT value, is_secret FROM variable WHERE workspace_id = $1 AND path = $2",
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(var)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
if let Some((stored, true)) = &row {
|
||||
if windmill_common::secret_backend::is_external_stored_value(stored) {
|
||||
let secret = windmill_common::secret_backend::get_secret_value(
|
||||
db, w_id, var, stored,
|
||||
)
|
||||
.await?;
|
||||
snapshot.external_secrets.insert(var.to_string(), secret);
|
||||
}
|
||||
}
|
||||
snapshot.variables.insert(var.to_string(), row);
|
||||
} else if let Some(res) = reference.strip_prefix("$res:") {
|
||||
pending.push(res.to_string());
|
||||
}
|
||||
}
|
||||
snapshot.resources.insert(path, value);
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
impl ConnectionSnapshot {
|
||||
/// The connection these rows resolve to, as the data table's own resolution would: references
|
||||
/// substituted, secrets decrypted.
|
||||
pub(crate) async fn resolve(&self, db: &DB, w_id: &str) -> Result<serde_json::Value> {
|
||||
let root = self.resource(&self.root)?;
|
||||
self.substitute(db, w_id, root, 0).await
|
||||
}
|
||||
|
||||
/// Whether the resource itself holds the connection's fields, which the fork repoints by setting
|
||||
/// its `dbname`, rather than being a reference to another resource.
|
||||
pub(crate) fn root_holds_connection(&self) -> bool {
|
||||
self.resource(&self.root).is_ok_and(|v| v.is_object())
|
||||
}
|
||||
|
||||
fn resource(&self, path: &str) -> Result<&serde_json::Value> {
|
||||
self.resources
|
||||
.get(path)
|
||||
.and_then(|v| v.as_ref())
|
||||
.ok_or_else(|| Error::NotFound(format!("resource {path} does not exist")))
|
||||
}
|
||||
|
||||
fn substitute<'a>(
|
||||
&'a self,
|
||||
db: &'a DB,
|
||||
w_id: &'a str,
|
||||
value: &'a serde_json::Value,
|
||||
depth: usize,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<serde_json::Value>> + Send + 'a>>
|
||||
{
|
||||
Box::pin(async move {
|
||||
if depth > 32 {
|
||||
return Err(Error::BadRequest(
|
||||
"resource references nest too deeply".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (key, val) in map {
|
||||
out.insert(key.clone(), self.substitute(db, w_id, val, depth).await?);
|
||||
}
|
||||
serde_json::Value::Object(out)
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
let mut out = vec![];
|
||||
for val in items {
|
||||
out.push(self.substitute(db, w_id, val, depth).await?);
|
||||
}
|
||||
serde_json::Value::Array(out)
|
||||
}
|
||||
serde_json::Value::String(s) if s.starts_with("$res:") => {
|
||||
let path = &s[5..];
|
||||
self.substitute(db, w_id, self.resource(path)?, depth + 1)
|
||||
.await?
|
||||
}
|
||||
serde_json::Value::String(s) if s.starts_with("$var:") => {
|
||||
let path = &s[5..];
|
||||
let (stored, secret) = self
|
||||
.variables
|
||||
.get(path)
|
||||
.and_then(|v| v.as_ref())
|
||||
.ok_or_else(|| {
|
||||
Error::NotFound(format!("variable {path} does not exist"))
|
||||
})?;
|
||||
serde_json::Value::String(match (secret, self.external_secrets.get(path)) {
|
||||
(false, _) => stored.clone(),
|
||||
(true, Some(external)) => external.clone(),
|
||||
(true, None) => windmill_common::variables::decrypt(
|
||||
&windmill_common::variables::build_crypt(db, w_id).await?,
|
||||
stored.clone(),
|
||||
)
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!("Error decrypting variable {s}: {e}"))
|
||||
})?,
|
||||
})
|
||||
}
|
||||
other => other.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_strings<'a>(value: Option<&'a serde_json::Value>, out: &mut Vec<&'a str>) {
|
||||
match value {
|
||||
Some(serde_json::Value::String(s)) => out.push(s),
|
||||
Some(serde_json::Value::Array(items)) => {
|
||||
items.iter().for_each(|v| collect_strings(Some(v), out))
|
||||
}
|
||||
Some(serde_json::Value::Object(map)) => {
|
||||
map.values().for_each(|v| collect_strings(Some(v), out))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a data table of the fork should be copied as.
|
||||
pub(crate) struct CopyRequest<'a> {
|
||||
pub(crate) name: &'a str,
|
||||
pub(crate) dbname: &'a str,
|
||||
pub(crate) behavior: DataTableForkBehavior,
|
||||
}
|
||||
|
||||
/// Check that `authed` may copy each data table of `parent_w_id` as asked, before anything is
|
||||
/// created.
|
||||
///
|
||||
/// Who may copy is what it was before data table roles — anyone for the schema, an admin of the
|
||||
/// workspace for the rows — narrowed under roles to whoever may connect as one of them: even the
|
||||
/// schema alone lists every table, which under roles only role holders can read in the parent.
|
||||
pub(crate) async fn authorize_copies(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
parent_w_id: &str,
|
||||
requests: &[CopyRequest<'_>],
|
||||
) -> Result<()> {
|
||||
for request in requests {
|
||||
match request.behavior {
|
||||
DataTableForkBehavior::KeepOriginal => {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{}' is kept, which copies nothing",
|
||||
request.name
|
||||
)))
|
||||
}
|
||||
DataTableForkBehavior::SchemaOnly => {}
|
||||
DataTableForkBehavior::SchemaAndData => {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
if *CLOUD_HOSTED {
|
||||
return Err(Error::BadRequest(
|
||||
"Cloning schema and data is not available on cloud".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
windmill_common::validate_dbname(request.dbname)?;
|
||||
if !request.dbname.starts_with("wm_fork_") {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Forked datatable database name '{}' must start with 'wm_fork_'",
|
||||
request.dbname
|
||||
)));
|
||||
}
|
||||
let governing = ensure_datatable_is_clonable(db, parent_w_id, request.name).await?;
|
||||
ensure_reaches_governing_datatable(db, parent_w_id, request.name, &governing, authed)
|
||||
.await?;
|
||||
if governing.datatable.permissions.is_some() {
|
||||
crate::datatable_replay_oss::ensure_replay()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make every copy in `requests`, in order, once [`authorize_copies`] allows them all. When one
|
||||
/// fails, the copies already made are dropped before the error is returned.
|
||||
pub(crate) async fn make_copies(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
parent_w_id: &str,
|
||||
requests: &[CopyRequest<'_>],
|
||||
) -> Result<Vec<MadeCopy>> {
|
||||
authorize_copies(db, authed, parent_w_id, requests).await?;
|
||||
let mut copies = Vec::with_capacity(requests.len());
|
||||
for request in requests {
|
||||
match make_copy(db, authed, parent_w_id, request).await {
|
||||
Ok(copy) => copies.push(copy),
|
||||
Err(e) => return Err(drop_copies_after(db, copies, e).await),
|
||||
}
|
||||
}
|
||||
Ok(copies)
|
||||
}
|
||||
|
||||
/// Drop every copy, returning `error` — with what could not be dropped appended, since nothing
|
||||
/// else will name those databases again.
|
||||
pub(crate) async fn drop_copies_after(db: &DB, copies: Vec<MadeCopy>, error: Error) -> Error {
|
||||
let mut stranded = Vec::new();
|
||||
for copy in copies {
|
||||
if let Err(e) = drop_copy(db, ©.source_database, ©.dbname).await {
|
||||
tracing::error!("Could not drop '{}' after a failed fork: {e}", copy.dbname);
|
||||
stranded.push(format!("'{}' ({e})", copy.dbname));
|
||||
}
|
||||
}
|
||||
if stranded.is_empty() {
|
||||
error
|
||||
} else {
|
||||
Error::ExecutionErr(format!(
|
||||
"{error}. These databases created for the fork could not be dropped: {}",
|
||||
stranded.join(", ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn drop_copy(db: &DB, source_database: &DataTableDatabase, dbname: &str) -> Result<()> {
|
||||
if source_database.resource_type
|
||||
== windmill_common::workspaces::DataTableCatalogResourceType::Instance
|
||||
{
|
||||
match windmill_common::drop_unused_instance_database(db, dbname).await? {
|
||||
windmill_common::Cleanup::Dropped => Ok(()),
|
||||
windmill_common::Cleanup::InUse(users) => Err(Error::BadRequest(format!(
|
||||
"kept, since workspaces {} now use it",
|
||||
users.join(", ")
|
||||
))),
|
||||
windmill_common::Cleanup::Waiter(pid) => Err(Error::BadRequest(format!(
|
||||
"kept, since a request (pid {pid}) is still waiting to name it"
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
// On a server of the workspace's own, where a resource edited meanwhile can already name it
|
||||
// and nothing locks such an edit: dropping it could take someone's data.
|
||||
Err(Error::BadRequest(
|
||||
"kept on its PostgreSQL server, where it may already be in use; drop it there once it \
|
||||
is not, before forking the same data table under this id again"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_copy(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
parent_w_id: &str,
|
||||
request: &CopyRequest<'_>,
|
||||
) -> Result<MadeCopy> {
|
||||
let governing = ensure_datatable_is_clonable(db, parent_w_id, request.name).await?;
|
||||
let source_database = governing.datatable.database.clone().ok_or_else(|| {
|
||||
Error::internal_err(format!(
|
||||
"Data table '{}' resolves to an entry that owns no database",
|
||||
request.name
|
||||
))
|
||||
})?;
|
||||
let is_instance = governing.is_instance();
|
||||
// Resolved from the snapshot, not read again: the connection the copy is made on is then
|
||||
// exactly what these rows describe, which the fork's own clone of them is checked against.
|
||||
let (server, connection): (PgDatabase, Option<ConnectionSnapshot>) = if is_instance {
|
||||
let server = serde_json::from_value(
|
||||
get_datatable_resource_from_db_unchecked(db, parent_w_id, request.name).await?,
|
||||
)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {e}")))?;
|
||||
(server, None)
|
||||
} else {
|
||||
let snapshot = connection_snapshot(
|
||||
db,
|
||||
&mut *db.acquire().await?,
|
||||
&governing.workspace_id,
|
||||
&source_database.resource_path,
|
||||
)
|
||||
.await?;
|
||||
if !snapshot.root_holds_connection() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{}' uses resource '{}', which only refers to another resource: the \
|
||||
fork's copy of it could not be pointed at the copied database. Point the data \
|
||||
table at the resource holding the connection, then fork again.",
|
||||
request.name, source_database.resource_path
|
||||
)));
|
||||
}
|
||||
let server = serde_json::from_value(snapshot.resolve(db, &governing.workspace_id).await?)
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!("Failed to parse database credentials: {e}"))
|
||||
})?;
|
||||
(server, Some(snapshot))
|
||||
};
|
||||
|
||||
// Dumped before the database exists, so a source that cannot be read leaves nothing behind.
|
||||
// Ownership never carries over: the restore runs as the target's connection user. Grants do,
|
||||
// except on the instance, where the replay below is what reproduces them.
|
||||
let dump = pg_dump_database(
|
||||
&server,
|
||||
PgDumpOptions {
|
||||
schema_only: request.behavior == DataTableForkBehavior::SchemaOnly,
|
||||
no_owner: true,
|
||||
no_acl: is_instance,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
if is_instance {
|
||||
windmill_common::create_custom_instance_database(
|
||||
db,
|
||||
request.dbname,
|
||||
"datatable",
|
||||
Some(parent_w_id),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
create_database_on_server(db, &server, request.dbname).await?;
|
||||
}
|
||||
|
||||
let target = PgDatabase { dbname: request.dbname.to_string(), ..server.clone() };
|
||||
let filled = fill(
|
||||
db,
|
||||
authed,
|
||||
parent_w_id,
|
||||
request.name,
|
||||
&governing,
|
||||
&server,
|
||||
&target,
|
||||
&dump,
|
||||
)
|
||||
.await;
|
||||
match filled {
|
||||
Ok(replayed) => Ok(MadeCopy {
|
||||
name: request.name.to_string(),
|
||||
dbname: request.dbname.to_string(),
|
||||
behavior: request.behavior,
|
||||
source_database,
|
||||
replayed,
|
||||
connection,
|
||||
}),
|
||||
Err(e) => {
|
||||
if let Err(drop_err) = drop_copy(db, &source_database, request.dbname).await {
|
||||
tracing::error!(
|
||||
"Could not drop '{}' after a failed copy of data table '{}': {drop_err}",
|
||||
request.dbname,
|
||||
request.name
|
||||
);
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"{e}. The database '{}' created for the copy could not be dropped: {drop_err}",
|
||||
request.dbname
|
||||
)));
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the dump into the new database and, for a data table under roles, replay the source's
|
||||
/// owners and grants into it. Returns whether it replayed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn fill(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
parent_w_id: &str,
|
||||
name: &str,
|
||||
governing: &GoverningDatatable,
|
||||
source: &PgDatabase,
|
||||
target: &PgDatabase,
|
||||
dump: &DumpFile,
|
||||
) -> Result<bool> {
|
||||
pg_import_dump(target, dump).await?;
|
||||
|
||||
// Held until the replay commits: a role renamed or dropped meanwhile would change what the
|
||||
// replay names, and a settings save could move the source onto another database or put it under
|
||||
// roles. Taken in the same order as the permissions save and the ACL apply.
|
||||
// On a connection of its own: the checks below take theirs from the pool, and a lock holder
|
||||
// drawn from the pool too could leave a small one with nothing to give them.
|
||||
let database_url = windmill_common::get_database_url().await?;
|
||||
let mut lock_holder = <sqlx::PgConnection as sqlx::Connection>::connect_with(
|
||||
&database_url.connect_options().await?,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to connect to the database: {e}")))?;
|
||||
let mut tx = sqlx::Connection::begin(&mut lock_holder).await?;
|
||||
lock_role_catalog(&mut tx).await?;
|
||||
lock_settings_rows(&mut tx, parent_w_id, name).await?;
|
||||
|
||||
// Everything so far was decided before the locks.
|
||||
let now = ensure_datatable_is_clonable(db, parent_w_id, name).await?;
|
||||
ensure_reaches_governing_datatable(db, parent_w_id, name, &now, authed).await?;
|
||||
if !same_database(&now.datatable.database, &governing.datatable.database) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{name}' moved to another database while it was being copied; fork again"
|
||||
)));
|
||||
}
|
||||
|
||||
let replayed = now.datatable.permissions.is_some();
|
||||
if replayed {
|
||||
crate::datatable_replay_oss::ensure_replay()?;
|
||||
let catalog = read_role_catalog_tx(&mut tx).await?;
|
||||
// The replay leaves `CONNECT` to the catalog, and creating the database only tried to set
|
||||
// it: a copy `PUBLIC` could still connect to would admit logins the source turns away.
|
||||
windmill_common::datatable_roles::converge_connect_grants_with(
|
||||
db,
|
||||
&target.dbname,
|
||||
&catalog,
|
||||
)
|
||||
.await?;
|
||||
let catalog_roles: BTreeSet<String> = catalog.values().map(|r| r.name.clone()).collect();
|
||||
let (source, _source_notices) = connect_with_notices(db, source).await?;
|
||||
let (mut target, mut notices) = connect_with_notices(db, target).await?;
|
||||
// One transaction on the copy: a replay that stops halfway leaves objects owned by one role
|
||||
// and granted as another.
|
||||
let pg_tx = target.transaction().await.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to open a transaction on the copy: {}",
|
||||
pg_error_message(&e)
|
||||
))
|
||||
})?;
|
||||
crate::datatable_replay_oss::replay_owners_and_grants(
|
||||
&source,
|
||||
&pg_tx,
|
||||
&mut notices,
|
||||
&catalog_roles,
|
||||
)
|
||||
.await?;
|
||||
pg_tx.commit().await.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to commit the replayed grants: {}",
|
||||
pg_error_message(&e)
|
||||
))
|
||||
})?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(replayed)
|
||||
}
|
||||
|
||||
/// Lock every settings row the resolution of data table `name` of `start_w_id` passes through, in
|
||||
/// the order it passes them — each pointer, the entry that owns the database, and each workspace its
|
||||
/// roles come from — and return them. Deleting or repointing any of them would leave a copy made
|
||||
/// for the resolution answering for another one.
|
||||
pub(crate) async fn lock_settings_rows(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
start_w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut path: Vec<(String, String)> = vec![];
|
||||
let (mut w_id, mut datatable) = (start_w_id.to_string(), name.to_string());
|
||||
loop {
|
||||
if path.contains(&(w_id.clone(), datatable.clone())) || path.len() > 32 {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{name}' of workspace '{start_w_id}' resolves through a cycle"
|
||||
)));
|
||||
}
|
||||
let entry: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT datatable->'datatables'->$2 FROM workspace_settings
|
||||
WHERE workspace_id = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.bind(&datatable)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten();
|
||||
path.push((w_id.clone(), datatable.clone()));
|
||||
let entry: Option<windmill_common::workspaces::DataTable> =
|
||||
entry.and_then(|e| serde_json::from_value(e).ok());
|
||||
match entry.and_then(|e| e.reference.or(e.governed_by)) {
|
||||
Some(next) => (w_id, datatable) = (next.workspace_id, next.datatable),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(path.into_iter().map(|(w_id, _)| w_id).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn same_database(a: &Option<DataTableDatabase>, b: &Option<DataTableDatabase>) -> bool {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => {
|
||||
a.resource_type == b.resource_type && a.resource_path == b.resource_path
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -107,26 +107,63 @@ async fn ensure_migration_role_allowed(
|
||||
datatable_name: &str,
|
||||
authed: &ApiAuthed,
|
||||
sql: &str,
|
||||
timestamp: i64,
|
||||
name: &str,
|
||||
migration: &str,
|
||||
) -> Result<()> {
|
||||
let context = format!("Migration {timestamp} ({name})");
|
||||
let access = DatatableAccess::Authed(authed.to_authed_ref());
|
||||
match SqlAnnotations::datatable_role(sql)? {
|
||||
Some(role) => {
|
||||
ensure_can_use_datatable_role(db, w_id, datatable_name, Some(&role), &access, &context)
|
||||
ensure_can_use_datatable_role(db, w_id, datatable_name, Some(&role), &access, migration)
|
||||
.await
|
||||
}
|
||||
None => ensure_datatable_admin_access(db, w_id, datatable_name, &access)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::NotAuthorized(format!(
|
||||
"{context} declares no role, so it would run as admin. {e}"
|
||||
"{migration} declares no role, so it would run as admin. {e}"
|
||||
))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse writing or deleting a migration definition unless this caller may run both its up and its
|
||||
/// down. A definition is run later by whoever applies or rolls back pending migrations, as the role
|
||||
/// it declares, so gating only the run would let anyone plant SQL for a data table's admin to run.
|
||||
async fn ensure_may_edit_migration(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
authed: &ApiAuthed,
|
||||
migration: &str,
|
||||
code_up: &str,
|
||||
code_down: Option<&str>,
|
||||
) -> Result<()> {
|
||||
// Admin access reaches everything a role can, so it edits any definition without reading its
|
||||
// annotation, which one saved before annotations were parsed may no longer pass. A refusal
|
||||
// there would leave such a definition impossible to delete or fix.
|
||||
let access = DatatableAccess::Authed(authed.to_authed_ref());
|
||||
if ensure_datatable_admin_access(db, w_id, datatable_name, &access)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
for sql in std::iter::once(code_up).chain(code_down) {
|
||||
ensure_migration_role_allowed(db, w_id, datatable_name, authed, sql, migration).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Operators run deployed code only, and a migration is SQL written or run by hand. Checked before
|
||||
/// anything else, including the role checks: on a data table not under roles those pass everyone.
|
||||
fn ensure_not_operator(authed: &ApiAuthed) -> Result<()> {
|
||||
if authed.is_operator {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Operators cannot manage or run data table migrations".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AppliedMigration {
|
||||
version: i64,
|
||||
@@ -436,6 +473,7 @@ async fn run_datatable_migrations(
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
Query(query): Query<RunDatatableMigrationsQuery>,
|
||||
) -> JsonResult<RunDatatableMigrationsResult> {
|
||||
ensure_not_operator(&authed)?;
|
||||
// Before the admin connection is opened at all: the bookkeeping below is created and read
|
||||
// through it, so a caller no role covers must be refused here rather than after the fact.
|
||||
crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed)
|
||||
@@ -503,8 +541,7 @@ async fn run_datatable_migrations(
|
||||
&datatable_name,
|
||||
&authed,
|
||||
&m.code_up,
|
||||
m.timestamp,
|
||||
&m.name,
|
||||
&format!("Migration {} ({})", m.timestamp, m.name),
|
||||
)
|
||||
.await?;
|
||||
run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &m.code_up)
|
||||
@@ -573,6 +610,7 @@ async fn rollback_datatable_migrations(
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
Query(query): Query<RollbackDatatableMigrationsQuery>,
|
||||
) -> JsonResult<RollbackDatatableMigrationsResult> {
|
||||
ensure_not_operator(&authed)?;
|
||||
// Before the admin connection is opened at all: the bookkeeping below is created and read
|
||||
// through it, so a caller no role covers must be refused here rather than after the fact.
|
||||
crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed)
|
||||
@@ -666,8 +704,7 @@ async fn rollback_datatable_migrations(
|
||||
&datatable_name,
|
||||
&authed,
|
||||
&code_down,
|
||||
version,
|
||||
&definition.name,
|
||||
&format!("Migration {} ({})", version, definition.name),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1213,9 +1250,20 @@ async fn create_datatable_migration(
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
Json(payload): Json<CreateDatatableMigration>,
|
||||
) -> JsonResult<DatatableMigration> {
|
||||
ensure_not_operator(&authed)?;
|
||||
validate_datatable_path_segment(&datatable_name)?;
|
||||
validate_migration_name(&payload.name)?;
|
||||
ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
|
||||
ensure_may_edit_migration(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&authed,
|
||||
&format!("Migration '{}'", payload.name),
|
||||
&payload.code_up,
|
||||
payload.code_down.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let timestamp = insert_datatable_migration_def(
|
||||
@@ -1267,6 +1315,34 @@ async fn delete_datatable_migration(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, datatable_name, timestamp)): Path<(String, String, i64)>,
|
||||
) -> Result<String> {
|
||||
ensure_not_operator(&authed)?;
|
||||
let deleted_message = format!("Deleted migration {} from {}", timestamp, datatable_name);
|
||||
|
||||
// A definition already gone is not an error: `wmill sync push` deletes whatever the repo
|
||||
// dropped, which may no longer exist here.
|
||||
let Some(definition) = sqlx::query!(
|
||||
"SELECT name, code_up, code_down FROM datatable_migrations \
|
||||
WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3",
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
timestamp,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
else {
|
||||
return Ok(deleted_message);
|
||||
};
|
||||
ensure_may_edit_migration(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&authed,
|
||||
&format!("Migration {} ({})", timestamp, definition.name),
|
||||
&definition.code_up,
|
||||
definition.code_down.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Hold the run-serialization lock across the applied-check and the delete: a
|
||||
// run snapshots a migration's SQL before recording its version, so an
|
||||
// unserialized delete could race it and leave `_wm_migrations` pointing at a
|
||||
@@ -1294,19 +1370,46 @@ async fn delete_datatable_migration(
|
||||
)));
|
||||
}
|
||||
|
||||
// Delete only the SQL the role check above cleared: it read the definition without the lock,
|
||||
// and a rewrite that landed since was never checked.
|
||||
let deleted_name = sqlx::query_scalar!(
|
||||
"DELETE FROM datatable_migrations \
|
||||
WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3 \
|
||||
AND code_up = $4 AND code_down IS NOT DISTINCT FROM $5 \
|
||||
RETURNING name",
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
timestamp,
|
||||
&definition.code_up,
|
||||
definition.code_down.as_deref(),
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
// The definition is removed; runs may resume (audit/deploy metadata below
|
||||
// don't need the lock).
|
||||
drop(lock_client);
|
||||
let Some(name) = deleted_name else {
|
||||
// Nothing matched either because another delete got there first, which is this one's
|
||||
// outcome too, or because the definition was rewritten.
|
||||
let still_defined = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM datatable_migrations \
|
||||
WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3)",
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
timestamp,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if !still_defined {
|
||||
return Ok(deleted_message);
|
||||
}
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Migration {} on data table '{}' changed while it was being deleted. Reload it and \
|
||||
retry.",
|
||||
timestamp, datatable_name
|
||||
)));
|
||||
};
|
||||
|
||||
audit_log(
|
||||
&db,
|
||||
@@ -1319,23 +1422,10 @@ async fn delete_datatable_migration(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Only tally a change if a migration was actually deleted.
|
||||
if let Some(name) = deleted_name {
|
||||
record_datatable_migration_deployment(
|
||||
&authed,
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
timestamp,
|
||||
&name,
|
||||
)
|
||||
record_datatable_migration_deployment(&authed, &db, &w_id, &datatable_name, timestamp, &name)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Deleted migration {} from {}",
|
||||
timestamp, datatable_name
|
||||
))
|
||||
Ok(deleted_message)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1356,6 +1446,7 @@ async fn upsert_datatable_migration(
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
Json(payload): Json<UpsertDatatableMigration>,
|
||||
) -> Result<String> {
|
||||
ensure_not_operator(&authed)?;
|
||||
validate_datatable_path_segment(&datatable_name)?;
|
||||
validate_migration_name(&payload.name)?;
|
||||
ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
|
||||
@@ -1381,9 +1472,9 @@ async fn upsert_datatable_migration(
|
||||
// run-serialization lock across the applied-check and the write below so an
|
||||
// in-flight run can't record a version for the SQL we're about to overwrite.
|
||||
// Held until the end of the handler (well past the write). The exempt
|
||||
// upserts need no lock: a new or unchanged one overwrites nothing, and one
|
||||
// that only adds a down leaves the `code_up` a concurrent run is recording
|
||||
// a version for untouched.
|
||||
// upserts need no lock: a new one overwrites nothing, and one that only adds
|
||||
// a down leaves the `code_up` a concurrent run is recording a version for
|
||||
// untouched.
|
||||
let only_adds_down = existing.as_ref().is_some_and(|existing| {
|
||||
existing.name == payload.name
|
||||
&& existing.code_up == payload.code_up
|
||||
@@ -1395,8 +1486,40 @@ async fn upsert_datatable_migration(
|
||||
&& existing.code_up == payload.code_up
|
||||
&& existing.code_down == payload.code_down
|
||||
});
|
||||
let upserted_message = format!(
|
||||
"Upserted migration {} in {}",
|
||||
payload.timestamp, datatable_name
|
||||
);
|
||||
// A re-push of the definition as stored changes nothing, so it is not checked, written, audited,
|
||||
// synced or counted. Writing it anyway would re-create a definition deleted since the read above.
|
||||
if unchanged {
|
||||
return Ok(upserted_message);
|
||||
}
|
||||
ensure_may_edit_migration(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&authed,
|
||||
&format!("Migration {} ({})", payload.timestamp, payload.name),
|
||||
&payload.code_up,
|
||||
payload.code_down.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
// Replacing a definition removes the one stored, so it answers to the same check as a delete.
|
||||
if let Some(existing) = existing.as_ref() {
|
||||
ensure_may_edit_migration(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&authed,
|
||||
&format!("Migration {} ({})", payload.timestamp, existing.name),
|
||||
&existing.code_up,
|
||||
existing.code_down.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let _run_lock = match existing.as_ref() {
|
||||
Some(_) if !only_adds_down && !unchanged => {
|
||||
Some(_) if !only_adds_down => {
|
||||
// Fail closed: if we can't lock/read the applied set (e.g. the
|
||||
// data-table database is temporarily unreachable), refuse the change
|
||||
// rather than risk overwriting a migration that has already run.
|
||||
@@ -1426,31 +1549,27 @@ async fn upsert_datatable_migration(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// An exempt upsert judged the row from an unlocked read and then writes
|
||||
// without the lock, so that whole read is re-tested here, where `ON CONFLICT
|
||||
// DO UPDATE` re-reads the row under a row lock. Otherwise a request working
|
||||
// from a stale definition silently reverts whatever changed in between — a
|
||||
// second addition's down, or a locked rewrite of the up whose new SQL a run
|
||||
// may already have recorded a version for. Reading no row at all is part of
|
||||
// the premise: the equality is NULL when `$8` is, so a version created in the
|
||||
// meantime is refused rather than overwritten.
|
||||
let recheck_observed = _run_lock.is_none();
|
||||
// The role checks, and the exemptions from the lock, all judged the row from the unlocked read
|
||||
// above, so that whole read is re-tested here, where `ON CONFLICT DO UPDATE` re-reads the row
|
||||
// under a row lock. Otherwise a request working from a stale definition silently replaces
|
||||
// whatever changed in between: SQL the role checks never saw, or a locked rewrite of the up
|
||||
// whose new SQL a run may already have recorded a version for. Reading no row at all is part of
|
||||
// the premise: the equality is NULL when `$7` is, so a version created in the meantime is
|
||||
// refused rather than overwritten.
|
||||
let written = sqlx::query!(
|
||||
"INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||||
ON CONFLICT (workspace_id, datatable, timestamp) DO UPDATE \
|
||||
SET name = EXCLUDED.name, code_up = EXCLUDED.code_up, code_down = EXCLUDED.code_down \
|
||||
WHERE NOT $7 \
|
||||
OR (datatable_migrations.name = $8::text \
|
||||
AND datatable_migrations.code_up = $9::text \
|
||||
AND datatable_migrations.code_down IS NOT DISTINCT FROM $10::text)",
|
||||
WHERE datatable_migrations.name = $7::text \
|
||||
AND datatable_migrations.code_up = $8::text \
|
||||
AND datatable_migrations.code_down IS NOT DISTINCT FROM $9::text",
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
payload.timestamp,
|
||||
&payload.name,
|
||||
&payload.code_up,
|
||||
payload.code_down.as_deref(),
|
||||
recheck_observed,
|
||||
existing.as_ref().map(|e| e.name.as_str()),
|
||||
existing.as_ref().map(|e| e.code_up.as_str()),
|
||||
existing.as_ref().and_then(|e| e.code_down.as_deref()),
|
||||
@@ -1489,24 +1608,17 @@ async fn upsert_datatable_migration(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// An unchanged re-push is not counted: `wmill sync push` sends every migration
|
||||
// on every sync, so counting those would swamp the definitions people write.
|
||||
if !unchanged {
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"datatable",
|
||||
"migration_created",
|
||||
if existing.is_none() {
|
||||
"synced"
|
||||
} else {
|
||||
"edited"
|
||||
},
|
||||
);
|
||||
}
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"datatable",
|
||||
"migration_created",
|
||||
if existing.is_none() {
|
||||
"synced"
|
||||
} else {
|
||||
"edited"
|
||||
},
|
||||
);
|
||||
|
||||
Ok(format!(
|
||||
"Upserted migration {} in {}",
|
||||
payload.timestamp, datatable_name
|
||||
))
|
||||
Ok(upserted_message)
|
||||
}
|
||||
|
||||
/// Generate the first migration for a data table by snapshotting its current
|
||||
@@ -1519,6 +1631,7 @@ async fn generate_initial_datatable_migration(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
) -> JsonResult<DatatableMigration> {
|
||||
ensure_not_operator(&authed)?;
|
||||
// Returns a `pg_dump` of the whole schema and writes into the data table's own bookkeeping, so
|
||||
// it answers to the workspace that governs it rather than to whoever is asking.
|
||||
ensure_datatable_admin_access(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Where the replay of a data table's owners and grants into its copy comes from: the enterprise
|
||||
//! one, or a refusal. Without it a data table under roles is not copied at all: its rows would
|
||||
//! arrive owned by the admin connection with no grant for any role.
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) use crate::datatable_replay_ee::replay_owners_and_grants;
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) fn ensure_replay() -> windmill_common::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
use {
|
||||
std::collections::BTreeSet,
|
||||
tokio::sync::mpsc,
|
||||
tokio_postgres::error::DbError,
|
||||
windmill_common::error::{Error, Result},
|
||||
};
|
||||
|
||||
/// Checked before anything is created.
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) fn ensure_replay() -> Result<()> {
|
||||
Err(Error::BadRequest(
|
||||
"Cloning a data table under roles is a Windmill Enterprise Edition feature: the copy \
|
||||
needs the source's owners and grants replayed. Fork it keeping the original database \
|
||||
instead."
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) async fn replay_owners_and_grants(
|
||||
_source: &tokio_postgres::Client,
|
||||
_target: &tokio_postgres::Transaction<'_>,
|
||||
_notices: &mut mpsc::UnboundedReceiver<DbError>,
|
||||
_catalog_roles: &BTreeSet<String>,
|
||||
) -> Result<()> {
|
||||
ensure_replay()
|
||||
}
|
||||
@@ -3,9 +3,11 @@ pub mod ai_session_backups;
|
||||
pub mod data_metrics;
|
||||
pub mod datatable_acl;
|
||||
pub mod datatable_acl_oss;
|
||||
pub mod datatable_clone;
|
||||
pub mod datatable_migrations;
|
||||
pub mod datatable_permissions;
|
||||
pub mod datatable_permissions_oss;
|
||||
pub mod datatable_replay_oss;
|
||||
pub mod deployment_requests;
|
||||
pub mod workspaces;
|
||||
pub mod workspaces_extra;
|
||||
@@ -16,6 +18,8 @@ pub mod workspaces_ee;
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub mod datatable_acl_ee;
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub mod datatable_replay_ee;
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub mod datatable_permissions_ee;
|
||||
|
||||
@@ -501,8 +501,8 @@ struct CreateWorkspaceFork {
|
||||
id: String,
|
||||
name: String,
|
||||
color: Option<String>,
|
||||
/// Datatable names that were forked. For each, the backend will update the
|
||||
/// forked workspace's datatable config to point to the new database.
|
||||
/// Data tables the fork gets a copy of rather than a pointer at the parent's. For each, the
|
||||
/// fork's entry is pointed at the new database.
|
||||
#[serde(default)]
|
||||
forked_datatables: Vec<ForkedDatatableInfo>,
|
||||
/// Lakes the user explicitly chose to SHARE with the parent (the fork then reads and
|
||||
@@ -534,6 +534,11 @@ struct CreateWorkspaceFork {
|
||||
struct ForkedDatatableInfo {
|
||||
name: String,
|
||||
new_dbname: String,
|
||||
/// What this request copies into `new_dbname`, which it creates. Optional only so that the
|
||||
/// entry older CLIs send — naming a database they created and filled themselves — is refused
|
||||
/// with a message rather than a parse error.
|
||||
#[serde(default)]
|
||||
fork_behavior: Option<DataTableForkBehavior>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -2233,8 +2238,8 @@ async fn list_datatables(
|
||||
name,
|
||||
resource_type: database.resource_type.as_ref().to_string(),
|
||||
resource_path: database.resource_path.clone(),
|
||||
governing_workspace_id: (governing.workspace_id != w_id)
|
||||
.then(|| governing.workspace_id.clone()),
|
||||
governing_workspace_id: (governing.governing_workspace_id() != w_id)
|
||||
.then(|| governing.governing_workspace_id().to_string()),
|
||||
permissioned: governing.datatable.permissions.is_some(),
|
||||
});
|
||||
}
|
||||
@@ -2916,6 +2921,18 @@ fn truncate_column_default(default: String) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_dev_workspace_copies_into_the_fork_database_namespace() {
|
||||
assert_eq!(
|
||||
forked_datatable_dbname("wm-fork-my-fork", "main"),
|
||||
"wm_fork_my_fork__main"
|
||||
);
|
||||
assert_eq!(
|
||||
forked_datatable_dbname("my-dev", "main"),
|
||||
"wm_fork_my_dev__main"
|
||||
);
|
||||
}
|
||||
|
||||
/// The header of a pg_dump, followed by an object whose body also holds a `SET`.
|
||||
const DUMP: &str = "--\n\
|
||||
-- PostgreSQL database dump\n\
|
||||
@@ -3043,6 +3060,25 @@ pub(crate) async fn resolve_pg_source_checked(
|
||||
w_id: &str,
|
||||
source: &str,
|
||||
) -> Result<PgDatabase> {
|
||||
Ok(
|
||||
resolve_pg_source_checked_with_kind(db, user_db, authed, w_id, source)
|
||||
.await?
|
||||
.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`resolve_pg_source_checked`], also reporting the kind of Windmill-managed database behind the
|
||||
/// source, `None` for a user resource. Callers that guard a managed connection MUST take the kind
|
||||
/// from here rather than ask separately: between two reads a save can flip the entry, leaving the guards of one kind
|
||||
/// applied to the connection of the other.
|
||||
pub(crate) async fn resolve_pg_source_checked_with_kind(
|
||||
db: &DB,
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
source: &str,
|
||||
) -> Result<(PgDatabase, Option<DataTableCatalogResourceType>)> {
|
||||
let mut managed_kind = None;
|
||||
let db_resource = if let Some(name) = source.strip_prefix("datatable://") {
|
||||
windmill_common::workspaces::ensure_datatable_admin_access(
|
||||
db,
|
||||
@@ -3051,7 +3087,13 @@ pub(crate) async fn resolve_pg_source_checked(
|
||||
&DatatableAccess::Authed(authed.to_authed_ref()),
|
||||
)
|
||||
.await?;
|
||||
get_datatable_resource_from_db_unchecked(db, w_id, name).await?
|
||||
let (connection, kind) =
|
||||
windmill_common::workspaces::get_datatable_connection_and_kind_unchecked(
|
||||
db, w_id, name,
|
||||
)
|
||||
.await?;
|
||||
managed_kind = kind;
|
||||
connection
|
||||
} else if let Some(path) = source.strip_prefix("$res:") {
|
||||
let db_with_authed = windmill_common::db::DbWithOptAuthed::from_authed(
|
||||
authed,
|
||||
@@ -3084,8 +3126,9 @@ pub(crate) async fn resolve_pg_source_checked(
|
||||
)));
|
||||
};
|
||||
|
||||
serde_json::from_value(db_resource)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))
|
||||
let pg: PgDatabase = serde_json::from_value(db_resource)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
|
||||
Ok((pg, managed_kind))
|
||||
}
|
||||
|
||||
/// The kind of the database backing the data table `name` when Windmill manages it (on its own
|
||||
@@ -3484,7 +3527,10 @@ async fn server_setting_names(pg_db: &PgDatabase) -> Result<HashSet<String>> {
|
||||
/// so a dump that breaks partway through imports partially and reads as a success.
|
||||
/// ON_ERROR_STOP surfaces the failure and --single-transaction makes the restore
|
||||
/// all-or-nothing, leaving the target as it was and the import retryable.
|
||||
async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) -> Result<()> {
|
||||
///
|
||||
/// Authorization: none. Writes into `target_db` with the credentials it carries; callers MUST have
|
||||
/// authorized writing to that database.
|
||||
pub(crate) async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) -> Result<()> {
|
||||
let supported_settings = server_setting_names(target_db).await?;
|
||||
comment_out_unsupported_settings(dump_file, &supported_settings).await?;
|
||||
|
||||
@@ -3534,7 +3580,7 @@ async fn create_pg_database(
|
||||
// exists rather than leaving an empty registered `wm_fork_…` behind.
|
||||
if let Some(reference) = req.source.strip_prefix("datatable://") {
|
||||
let (name, _) = parse_datatable_ref_for(&db, &w_id, reference).await?;
|
||||
ensure_datatable_is_clonable(&db, &w_id, &name).await?;
|
||||
ensure_copied_without_roles(&ensure_datatable_is_clonable(&db, &w_id, &name).await?)?;
|
||||
}
|
||||
|
||||
// Non-superadmin: restrict dbname to wm_fork_ prefix
|
||||
@@ -3582,50 +3628,62 @@ async fn create_pg_database(
|
||||
} else {
|
||||
let source_pg =
|
||||
resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||||
let (client, connection) = source_pg.connect(Some(&db)).await?;
|
||||
let join_handle = tokio::spawn(async move { connection.await });
|
||||
|
||||
let row = client
|
||||
.query_one(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
|
||||
&[&req.target_dbname],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to check database existence: {}",
|
||||
pg_error_message(&e)
|
||||
))
|
||||
})?;
|
||||
let db_exists: bool = row.get(0);
|
||||
|
||||
if db_exists {
|
||||
drop(client);
|
||||
let _ = windmill_common::shutdown_pg_connection(join_handle).await;
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Database '{}' already exists on the resource server",
|
||||
req.target_dbname
|
||||
)));
|
||||
}
|
||||
|
||||
client
|
||||
.execute(&format!("CREATE DATABASE \"{}\"", &req.target_dbname), &[])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to create database '{}': {}",
|
||||
req.target_dbname,
|
||||
pg_error_message(&e)
|
||||
))
|
||||
})?;
|
||||
|
||||
drop(client);
|
||||
windmill_common::shutdown_pg_connection(join_handle).await?;
|
||||
create_database_on_server(&db, &source_pg, &req.target_dbname).await?;
|
||||
}
|
||||
|
||||
Ok(format!("Created database '{}'", req.target_dbname))
|
||||
}
|
||||
|
||||
/// `CREATE DATABASE` on the server `server` connects to, refusing a name already taken there.
|
||||
///
|
||||
/// Authorization: none. Callers MUST have authorized creating a database on that server — resolved
|
||||
/// from a data table or resource the caller may administer.
|
||||
pub(crate) async fn create_database_on_server(
|
||||
db: &DB,
|
||||
server: &PgDatabase,
|
||||
dbname: &str,
|
||||
) -> Result<()> {
|
||||
windmill_common::validate_dbname(dbname)?;
|
||||
let (client, connection) = server.connect(Some(db)).await?;
|
||||
let join_handle = tokio::spawn(async move { connection.await });
|
||||
|
||||
let row = client
|
||||
.query_one(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
|
||||
&[&dbname],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to check database existence: {}",
|
||||
pg_error_message(&e)
|
||||
))
|
||||
})?;
|
||||
let db_exists: bool = row.get(0);
|
||||
|
||||
if db_exists {
|
||||
drop(client);
|
||||
let _ = windmill_common::shutdown_pg_connection(join_handle).await;
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Database '{dbname}' already exists on the resource server"
|
||||
)));
|
||||
}
|
||||
|
||||
client
|
||||
.execute(&format!("CREATE DATABASE \"{dbname}\""), &[])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to create database '{dbname}': {}",
|
||||
pg_error_message(&e)
|
||||
))
|
||||
})?;
|
||||
|
||||
drop(client);
|
||||
windmill_common::shutdown_pg_connection(join_handle).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ImportPgDatabaseRequest {
|
||||
source: String,
|
||||
@@ -3635,48 +3693,22 @@ struct ImportPgDatabaseRequest {
|
||||
fork_behavior: DataTableForkBehavior,
|
||||
}
|
||||
|
||||
/// Refuse to copy a data table that is under roles.
|
||||
/// Whether data table `name` of `w_id` has a shape a copy can be made of, returning what governs
|
||||
/// it. Checked before anything is created — by the fork request for the copies it makes, and by
|
||||
/// `create_pg_database` — and again when the fork's entry is written.
|
||||
///
|
||||
/// `pg_dump` carries no roles and the import runs with `--no-privileges`, so a clone arrives with
|
||||
/// its objects owned by the admin connection and no `GRANT` for any role. The settings copy brings
|
||||
/// `permissions` across, so the fork's tenants pass Windmill's check, connect as the role they were
|
||||
/// given, and are then denied by Postgres on everything — a data table that looks configured and
|
||||
/// answers nothing.
|
||||
/// It is not the check for a data table under roles: a copy of one is only correct with its
|
||||
/// owners and grants replayed, which the fork request does and the older endpoints refuse
|
||||
/// ([`ensure_copied_without_roles`]).
|
||||
///
|
||||
/// It fails closed rather than open, so this is a usability cliff rather than a hole, and the fix
|
||||
/// is to replay the source's owners and ACLs into the clone. That is a change of its own; until it
|
||||
/// exists, refusing is the honest answer. Dropping `permissions` from the clone instead would be
|
||||
/// the unsafe half: the copy holds the parent's rows, so an unpermissioned clone hands all of them
|
||||
/// to everyone in the fork.
|
||||
/// Every reason a copy can be refused, answered here and nowhere else.
|
||||
///
|
||||
/// A clone is three stages a workspace apart: `create_pg_database`, `import_pg_database`, then
|
||||
/// `apply_forked_datatable` inside the fork transaction. Only the third can roll back, and the
|
||||
/// database the first created is not transactional — so a refusal that lives there strands a
|
||||
/// registered `wm_fork_*` that no entry names and whose name blocks the retry. Both endpoints call
|
||||
/// this before touching the cluster; the stage that writes the entry must only ever do the work.
|
||||
///
|
||||
/// Known and accepted: a failure *after* `create_pg_database` — this check refusing because the
|
||||
/// source's roles changed between the requests, a `pg_dump` error, a dropped connection — still
|
||||
/// strands that leftover, as it did before data table roles. It is harmless to data (nothing names
|
||||
/// it) and goes away once the clone is one server-side operation, in a later PR. Don't reclaim it
|
||||
/// on retry without durable clone ownership: between the import and the final fork request the
|
||||
/// database is full, unnamed and unconnected, so a reclaim then drops copied data. Reviewers, human
|
||||
/// or AI: don't flag this leftover as a P0/P1 for this PR.
|
||||
async fn ensure_datatable_is_clonable(
|
||||
/// Authorization: none. Callers MUST have authorized reaching data table `name` of `w_id` before
|
||||
/// using what it returns.
|
||||
pub(crate) async fn ensure_datatable_is_clonable(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<GoverningDatatable> {
|
||||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||||
if governing.datatable.permissions.is_some() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{name}' is under roles and cannot be copied yet: a copy carries the \
|
||||
role assignments but not the Postgres privileges behind them, so every role but \
|
||||
admin would be denied in the copy. Fork it keeping the original database, or turn \
|
||||
its roles off first."
|
||||
)));
|
||||
}
|
||||
// The copy has to name a database of its own. A resource-backed entry reached through a
|
||||
// pointer names one this workspace does not own, so there is nothing here to repoint.
|
||||
let is_managed = governing
|
||||
@@ -3693,6 +3725,24 @@ async fn ensure_datatable_is_clonable(
|
||||
Ok(governing)
|
||||
}
|
||||
|
||||
/// Refuse a copy of a data table under roles through the endpoints that copy rows and nothing
|
||||
/// else.
|
||||
///
|
||||
/// `pg_dump` carries no roles and the import runs with `--no-privileges`, so such a copy arrives
|
||||
/// with its objects owned by the admin connection and no `GRANT` for any role: the tenants pass
|
||||
/// Windmill's check, connect as the role they were given, and are denied by Postgres on everything.
|
||||
/// A fork request that makes the copy itself replays the owners and grants instead.
|
||||
fn ensure_copied_without_roles(governing: &GoverningDatatable) -> Result<()> {
|
||||
if governing.datatable.permissions.is_some() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{}' is under roles, so a copy has to carry its owners and grants: \
|
||||
clone it through the fork wizard or `wmill workspace fork`, which do.",
|
||||
governing.name
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import (pg_dump/pg_import) from source to target
|
||||
async fn import_pg_database(
|
||||
authed: ApiAuthed,
|
||||
@@ -3707,7 +3757,7 @@ async fn import_pg_database(
|
||||
|
||||
if let Some(reference) = req.source.strip_prefix("datatable://") {
|
||||
let (name, _) = parse_datatable_ref_for(&db, &w_id, reference).await?;
|
||||
ensure_datatable_is_clonable(&db, &w_id, &name).await?;
|
||||
ensure_copied_without_roles(&ensure_datatable_is_clonable(&db, &w_id, &name).await?)?;
|
||||
}
|
||||
|
||||
if req.fork_behavior == DataTableForkBehavior::SchemaAndData {
|
||||
@@ -3721,9 +3771,10 @@ async fn import_pg_database(
|
||||
|
||||
let schema_only = req.fork_behavior == DataTableForkBehavior::SchemaOnly;
|
||||
let mut fork_lock: Option<Transaction<'_, Postgres>> = None;
|
||||
let source_pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||||
let mut target_pg =
|
||||
resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.target).await?;
|
||||
let (source_pg, source_kind) =
|
||||
resolve_pg_source_checked_with_kind(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||||
let (mut target_pg, target_kind) =
|
||||
resolve_pg_source_checked_with_kind(&db, &user_db, &authed, &w_id, &req.target).await?;
|
||||
|
||||
if let Some(ref override_dbname) = req.target_dbname_override {
|
||||
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
|
||||
@@ -3733,7 +3784,10 @@ async fn import_pg_database(
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(kind) = managed_datatable_source_kind(&db, &w_id, &req.target).await? {
|
||||
// The kind the connection above was built from, never a second read: an entry flipped
|
||||
// to a resource between the two would keep the managed connection here and lose the
|
||||
// check that decides which copy it may fill.
|
||||
if let Some(kind) = target_kind {
|
||||
// Held until the restore is done: fork finalization takes the first, and every
|
||||
// save newly naming a database, in any workspace, the second. Nothing may start
|
||||
// using this database while `psql` is still filling it.
|
||||
@@ -3762,12 +3816,7 @@ async fn import_pg_database(
|
||||
// what it creates it owns. Grants do, except around an instance data table — Windmill
|
||||
// plants `custom_instance_user` grants in one, which nothing else can replay. Elsewhere
|
||||
// the ACLs are user intent (`REVOKE ... FROM PUBLIC`) and dropping them widens access.
|
||||
let no_acl = managed_datatable_source_kind(&db, &w_id, &req.target)
|
||||
.await?
|
||||
.is_some()
|
||||
|| managed_datatable_source_kind(&db, &w_id, &req.source)
|
||||
.await?
|
||||
.is_some();
|
||||
let no_acl = target_kind.is_some() || source_kind.is_some();
|
||||
|
||||
let dump_file = pg_dump_database(
|
||||
&source_pg,
|
||||
@@ -3856,6 +3905,15 @@ async fn edit_ducklake_config(
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
windmill_common::lock_instance_databases(
|
||||
&mut tx,
|
||||
new_config.settings.ducklakes.values().filter_map(|lake| {
|
||||
(lake.catalog.resource_type
|
||||
== windmill_common::workspaces::DucklakeCatalogResourceType::Instance)
|
||||
.then_some(lake.catalog.resource_path.as_str())
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let args_for_audit = format!("{:?}", new_config.settings);
|
||||
audit_log(
|
||||
@@ -3977,6 +4035,16 @@ async fn edit_datatable_config(
|
||||
let mut tx = db.begin().await?;
|
||||
// Ahead of the settings row, as fork cleanup of this workspace takes the two.
|
||||
windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?;
|
||||
windmill_common::lock_instance_databases(
|
||||
&mut tx,
|
||||
new_config.settings.datatables.values().filter_map(|dt| {
|
||||
dt.database
|
||||
.as_ref()
|
||||
.filter(|d| d.resource_type == DataTableCatalogResourceType::Instance)
|
||||
.map(|d| d.resource_path.as_str())
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Read under the row lock this transaction will write with. `permissions`, `reference` and
|
||||
// `forked_from` are carried across from what this read returns, so a permissions save
|
||||
@@ -4103,12 +4171,28 @@ async fn edit_datatable_config(
|
||||
// hand the fork the database outright. `forked_from` is the clone stamp the fork flow
|
||||
// writes: whether an entry has one is carried the same way, since it is what marks the
|
||||
// database droppable, but the schema baseline inside it is the diff view's to advance.
|
||||
// `governed_by` is a clone's `reference` for its roles, and clearing it would hand the
|
||||
// fork the copied rows the same way.
|
||||
dt.permissions = old.and_then(|old| old.permissions.clone());
|
||||
dt.reference = old.and_then(|old| old.reference.clone());
|
||||
dt.governed_by = old.and_then(|old| old.governed_by.clone());
|
||||
dt.forked_from = match old.and_then(|old| old.forked_from.as_ref()) {
|
||||
Some(stored) => Some(dt.forked_from.take().unwrap_or_else(|| stored.clone())),
|
||||
None => None,
|
||||
};
|
||||
// A clone's roles and grants were replayed into the database it was copied into, and hold
|
||||
// for that database alone: whoever saves, it stays where it is.
|
||||
if dt.governed_by.is_some()
|
||||
&& !crate::datatable_clone::same_database(
|
||||
&dt.database,
|
||||
&old.and_then(|old| old.database.clone()),
|
||||
)
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{name}' is a clone taking its roles from the data table it was copied \
|
||||
from, and its grants hold for its own database only: it cannot be moved."
|
||||
)));
|
||||
}
|
||||
// Carrying the block onto a resource-backed entry would produce a data table the chokepoint
|
||||
// refuses on every job — a save that succeeds and breaks everything afterwards. Refuse it
|
||||
// instead: turning roles off first is one step, and it keeps discarding an access decision
|
||||
@@ -4148,6 +4232,10 @@ async fn edit_datatable_config(
|
||||
// here because it cannot be requested at all: `reference` is overwritten from the stored entry
|
||||
// above, for every caller. An unchanged entry is left alone either way, so a downgraded
|
||||
// instance can still save settings that already name an external instance database.
|
||||
//
|
||||
// Compared against the entry the carried fields came from, not the one stored under the same
|
||||
// name: otherwise swapping two names keeps each database in place while moving a clone's
|
||||
// `governed_by` off the copy it governs.
|
||||
for (name, dt) in new_config.settings.datatables.iter() {
|
||||
let Some(database) = dt
|
||||
.database
|
||||
@@ -4157,7 +4245,12 @@ async fn edit_datatable_config(
|
||||
continue;
|
||||
};
|
||||
let unchanged = old_datatables
|
||||
.get(name)
|
||||
.get(
|
||||
rename_src
|
||||
.get(name.as_str())
|
||||
.copied()
|
||||
.unwrap_or(name.as_str()),
|
||||
)
|
||||
.and_then(|o| o.database.as_ref())
|
||||
.is_some_and(|o| {
|
||||
o.resource_type == database.resource_type
|
||||
@@ -4204,7 +4297,8 @@ async fn edit_datatable_config(
|
||||
.settings
|
||||
.datatables
|
||||
.iter()
|
||||
.filter(|(_, dt)| dt.permissions.is_none())
|
||||
// A clone carries its roles through `governed_by` rather than `permissions`.
|
||||
.filter(|(_, dt)| dt.permissions.is_none() && dt.governed_by.is_none())
|
||||
.filter_map(|(name, dt)| {
|
||||
let db = dt
|
||||
.database
|
||||
@@ -4265,7 +4359,7 @@ async fn edit_datatable_config(
|
||||
sqlx::query_scalar(
|
||||
"SELECT DISTINCT dt.value->'database'->>'resource_path' FROM workspace_settings ws
|
||||
CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt
|
||||
WHERE ws.workspace_id <> $1 AND dt.value ? 'permissions'
|
||||
WHERE ws.workspace_id <> $1 AND (dt.value ? 'permissions' OR dt.value ? 'governed_by')
|
||||
AND dt.value->'database'->>'resource_type' = 'instance'",
|
||||
)
|
||||
.bind(&w_id)
|
||||
@@ -4274,7 +4368,7 @@ async fn edit_datatable_config(
|
||||
};
|
||||
for (name, dbname) in newly_pointed {
|
||||
let governed_here = old_datatables.values().any(|old| {
|
||||
old.permissions.is_some()
|
||||
(old.permissions.is_some() || old.governed_by.is_some())
|
||||
&& old.database.as_ref().is_some_and(|d| {
|
||||
d.resource_type == DataTableCatalogResourceType::Instance
|
||||
&& d.resource_path == dbname
|
||||
@@ -4334,8 +4428,11 @@ async fn edit_datatable_config(
|
||||
r#"SELECT ws.workspace_id AS "workspace_id!", dt.key AS "datatable!"
|
||||
FROM workspace_settings ws
|
||||
CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt
|
||||
WHERE dt.value->'reference'->>'workspace_id' = $1
|
||||
AND dt.value->'reference'->>'datatable' = $2"#,
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM (VALUES ('reference'), ('governed_by')) k(link)
|
||||
WHERE dt.value->k.link->>'workspace_id' = $1
|
||||
AND dt.value->k.link->>'datatable' = $2
|
||||
)"#,
|
||||
&w_id,
|
||||
name,
|
||||
)
|
||||
@@ -8463,6 +8560,7 @@ async fn create_workspace_fork_branch(
|
||||
// dangling branch on the synced repos.
|
||||
check_fork_w_id_conflict(&db, &nw.id).await?;
|
||||
purge_stale_fork_diff_state(&db, &nw.id).await?;
|
||||
validate_forked_datatables(&db, &authed, &w_id, &nw).await?;
|
||||
|
||||
Ok(Json(
|
||||
handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?,
|
||||
@@ -8502,8 +8600,9 @@ async fn snapshot_datatable_schema(
|
||||
/// a fork admin could then edit to widen their own access to it. A pointer has nothing local to
|
||||
/// edit: the parent's entry stays the only place the decision lives.
|
||||
///
|
||||
/// The cloned data tables are skipped: they own a fresh database of their own, and they keep the
|
||||
/// copied `permissions` as their starting point, which they then govern.
|
||||
/// The cloned data tables are skipped: they own a fresh database of their own, and
|
||||
/// `apply_forked_datatable` has already dropped their copied `permissions` — for a clone of a data
|
||||
/// table under roles, in favor of `governed_by`.
|
||||
async fn point_kept_datatables_at_parent(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
parent_w_id: &str,
|
||||
@@ -8559,6 +8658,7 @@ async fn point_kept_datatables_at_parent(
|
||||
workspace_id: parent_w_id.to_string(),
|
||||
datatable: name.clone(),
|
||||
}),
|
||||
governed_by: None,
|
||||
forked_from: None,
|
||||
migrations_enabled: dt.migrations_enabled,
|
||||
permissions: None,
|
||||
@@ -8579,7 +8679,8 @@ async fn point_kept_datatables_at_parent(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move every pointer in any workspace that names `(w_id, from)` to `(w_id, to)`.
|
||||
/// Move every pointer, and every clone's `governed_by`, in any workspace that names `(w_id, from)`
|
||||
/// to `(w_id, to)`.
|
||||
///
|
||||
/// `EXISTS` rather than a `LIKE` over the whole document: the update rewrites the row, so matching
|
||||
/// every workspace that holds any pointer would rewrite rows to a byte-identical value and hold an
|
||||
@@ -8595,17 +8696,22 @@ async fn repoint_datatable_references(
|
||||
SET datatable = (
|
||||
SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(
|
||||
dt.key,
|
||||
CASE WHEN dt.value->'reference'->>'workspace_id' = $1
|
||||
AND dt.value->'reference'->>'datatable' = $2
|
||||
THEN jsonb_set(dt.value, '{reference,datatable}', to_jsonb($3::text))
|
||||
ELSE dt.value END
|
||||
(SELECT CASE WHEN r.v->'governed_by'->>'workspace_id' = $1
|
||||
AND r.v->'governed_by'->>'datatable' = $2
|
||||
THEN jsonb_set(r.v, '{governed_by,datatable}', to_jsonb($3::text))
|
||||
ELSE r.v END
|
||||
FROM (SELECT CASE WHEN dt.value->'reference'->>'workspace_id' = $1
|
||||
AND dt.value->'reference'->>'datatable' = $2
|
||||
THEN jsonb_set(dt.value, '{reference,datatable}', to_jsonb($3::text))
|
||||
ELSE dt.value END AS v) r)
|
||||
))
|
||||
FROM jsonb_each(ws.datatable->'datatables') dt
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d
|
||||
WHERE d.value->'reference'->>'workspace_id' = $1
|
||||
AND d.value->'reference'->>'datatable' = $2
|
||||
SELECT 1 FROM jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d,
|
||||
LATERAL (VALUES ('reference'), ('governed_by')) k(link)
|
||||
WHERE d.value->k.link->>'workspace_id' = $1
|
||||
AND d.value->k.link->>'datatable' = $2
|
||||
)"#,
|
||||
w_id,
|
||||
from,
|
||||
@@ -8623,17 +8729,8 @@ async fn apply_forked_datatable(
|
||||
parent_w_id: &str,
|
||||
forked_w_id: &str,
|
||||
fdt: &ForkedDatatableInfo,
|
||||
copy: &crate::datatable_clone::MadeCopy,
|
||||
) -> Result<()> {
|
||||
// Cloning reads the parent's whole schema as admin and hands the copy to the fork, so it is
|
||||
// for the workspace that governs the data table — a fork can use one, never duplicate it.
|
||||
windmill_common::workspaces::ensure_datatable_admin_access(
|
||||
db,
|
||||
parent_w_id,
|
||||
&fdt.name,
|
||||
&DatatableAccess::Authed(authed.to_authed_ref()),
|
||||
)
|
||||
.await?;
|
||||
let governing = ensure_datatable_is_clonable(db, parent_w_id, &fdt.name).await?;
|
||||
windmill_common::validate_dbname(&fdt.new_dbname)?;
|
||||
if !fdt.new_dbname.starts_with("wm_fork_") {
|
||||
return Err(Error::BadRequest(format!(
|
||||
@@ -8641,6 +8738,52 @@ async fn apply_forked_datatable(
|
||||
fdt.new_dbname
|
||||
)));
|
||||
}
|
||||
// Held until the fork commits, as the permissions save holds them: the source moved onto
|
||||
// another database, or put under roles or taken off them, since its copy was made would link
|
||||
// the copy to roles its grants were not replayed for.
|
||||
let locked = crate::datatable_clone::lock_settings_rows(tx, parent_w_id, &fdt.name).await?;
|
||||
let governing = ensure_datatable_is_clonable(db, parent_w_id, &fdt.name).await?;
|
||||
let under_roles = governing.datatable.permissions.is_some();
|
||||
let unchanged = crate::datatable_clone::same_database(
|
||||
&governing.datatable.database,
|
||||
&Some(copy.source_database.clone()),
|
||||
) && copy.replayed == under_roles
|
||||
&& crate::datatable_clone::lock_settings_rows(tx, parent_w_id, &fdt.name).await? == locked;
|
||||
if !unchanged {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{}' changed while it was being copied for this fork — its database, or \
|
||||
whether it is under roles. Create the fork again.",
|
||||
fdt.name
|
||||
)));
|
||||
}
|
||||
// The fork keeps a snapshot of the source's schema, which under roles is only for those who may
|
||||
// connect as one of them — as the copy itself was.
|
||||
crate::datatable_permissions::ensure_reaches_governing_datatable(
|
||||
db,
|
||||
parent_w_id,
|
||||
&fdt.name,
|
||||
&governing,
|
||||
authed,
|
||||
)
|
||||
.await?;
|
||||
// Under roles, the copy holds rows the source's roles decide who reaches, so that entry keeps
|
||||
// deciding. Settled from the source as it resolves now: the settings clone may have handed the
|
||||
// fork a pointer, or a clone of its own. A copy of a data table without roles is the fork's,
|
||||
// as it was before roles existed: everyone reached all of it already.
|
||||
let governed_by = governing
|
||||
.datatable
|
||||
.permissions
|
||||
.is_some()
|
||||
.then(|| {
|
||||
serde_json::to_value(governing.governor.clone().unwrap_or_else(|| {
|
||||
windmill_common::workspaces::DataTableReference {
|
||||
workspace_id: governing.workspace_id.clone(),
|
||||
datatable: governing.name.clone(),
|
||||
}
|
||||
}))
|
||||
})
|
||||
.transpose()
|
||||
.map_err(|e| Error::internal_err(format!("serializing a clone's governor: {e}")))?;
|
||||
|
||||
// Snapshot the schema from the source (parent) datatable
|
||||
let schema = snapshot_datatable_schema(db, parent_w_id, &fdt.name).await?;
|
||||
@@ -8714,33 +8857,42 @@ async fn apply_forked_datatable(
|
||||
"resource_type": database.resource_type,
|
||||
"resource_path": &fdt.new_dbname,
|
||||
});
|
||||
sqlx::query!(
|
||||
sqlx::query(
|
||||
r#"UPDATE workspace_settings
|
||||
SET datatable = jsonb_set(
|
||||
jsonb_set(
|
||||
datatable #- ARRAY['datatables', $2, 'reference'],
|
||||
ARRAY['datatables', $2, 'database'], $3::jsonb),
|
||||
ARRAY['datatables', $2, 'forked_from'], $4::jsonb
|
||||
)
|
||||
datatable #- ARRAY['datatables', $2, 'reference'],
|
||||
ARRAY['datatables', $2, 'database'], $3::jsonb)
|
||||
WHERE workspace_id = $1"#,
|
||||
forked_w_id,
|
||||
&fdt.name,
|
||||
new_database,
|
||||
forked_from,
|
||||
)
|
||||
.bind(forked_w_id)
|
||||
.bind(&fdt.name)
|
||||
.bind(new_database)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
} else {
|
||||
// Resource: update the resource's dbname and mark as ws_specific
|
||||
// Resource: point it at the copy and mark it ws_specific. What the settings clone carried is
|
||||
// the source's resource and variables as they are now, which an edit made while the copy
|
||||
// was being made can have changed, and changed back: the clone has to be what the copy's
|
||||
// connection was resolved from.
|
||||
let resource_path = &database.resource_path;
|
||||
sqlx::query!(
|
||||
let cloned =
|
||||
crate::datatable_clone::connection_snapshot(db, &mut **tx, forked_w_id, resource_path)
|
||||
.await?;
|
||||
if copy.connection.as_ref() != Some(&cloned) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"The resource of data table '{}' changed while it was being copied for this \
|
||||
fork. Create the fork again.",
|
||||
fdt.name
|
||||
)));
|
||||
}
|
||||
sqlx::query(
|
||||
r#"UPDATE resource
|
||||
SET value = jsonb_set(value, '{dbname}', to_jsonb($3::text))
|
||||
WHERE workspace_id = $1 AND path = $2"#,
|
||||
forked_w_id,
|
||||
resource_path,
|
||||
&fdt.new_dbname,
|
||||
)
|
||||
.bind(forked_w_id)
|
||||
.bind(resource_path)
|
||||
.bind(&fdt.new_dbname)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
@@ -8751,20 +8903,31 @@ async fn apply_forked_datatable(
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// Set forked_from on the datatable config
|
||||
sqlx::query!(
|
||||
r#"UPDATE workspace_settings
|
||||
SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'forked_from'], $3::jsonb)
|
||||
WHERE workspace_id = $1"#,
|
||||
forked_w_id,
|
||||
&fdt.name,
|
||||
forked_from,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// The settings clone copied the source's `permissions` along, and a clone of a clone its
|
||||
// `governed_by`: a clone keeps no `permissions` of its own, and a link only under roles.
|
||||
sqlx::query(
|
||||
r#"UPDATE workspace_settings
|
||||
SET datatable = jsonb_set(
|
||||
CASE WHEN $3::jsonb IS NULL
|
||||
THEN datatable #- ARRAY['datatables', $2, 'permissions']
|
||||
#- ARRAY['datatables', $2, 'governed_by']
|
||||
ELSE jsonb_set(
|
||||
datatable #- ARRAY['datatables', $2, 'permissions'],
|
||||
ARRAY['datatables', $2, 'governed_by'], $3::jsonb)
|
||||
END,
|
||||
ARRAY['datatables', $2, 'forked_from'], $4::jsonb
|
||||
)
|
||||
WHERE workspace_id = $1"#,
|
||||
)
|
||||
.bind(forked_w_id)
|
||||
.bind(&fdt.name)
|
||||
.bind(governed_by)
|
||||
.bind(forked_from)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9099,7 +9262,169 @@ async fn create_workspace_fork(
|
||||
ensure_no_existing_dev_workspace(&db, &parent_workspace_id).await?;
|
||||
}
|
||||
|
||||
// Refused here, before any database exists; `make_copies` checks again under the locks.
|
||||
validate_forked_datatables(&db, &authed, &parent_workspace_id, &nw).await?;
|
||||
|
||||
// Detached from the request: a client that goes away while the copies are made must still end
|
||||
// with the fork created or no copy left behind, and dropping the handler's future would skip
|
||||
// that cleanup.
|
||||
tokio::spawn(async move {
|
||||
let fork_id = nw.id.clone();
|
||||
let copies = crate::datatable_clone::make_copies(
|
||||
&db,
|
||||
&authed,
|
||||
&parent_workspace_id,
|
||||
©_requests(&nw.forked_datatables),
|
||||
)
|
||||
.await?;
|
||||
let replayed: Vec<DataTableForkBehavior> = copies
|
||||
.iter()
|
||||
.filter(|c| c.replayed)
|
||||
.map(|c| c.behavior)
|
||||
.collect();
|
||||
match write_workspace_fork(
|
||||
db.clone(),
|
||||
authed,
|
||||
parent_workspace_id,
|
||||
nw,
|
||||
dev_workspace_label,
|
||||
&copies,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(message) => {
|
||||
for behavior in replayed {
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"datatable",
|
||||
"clone_replayed",
|
||||
match behavior {
|
||||
DataTableForkBehavior::SchemaOnly => "schema_only",
|
||||
_ => "schema_and_data",
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
// A commit whose acknowledgement was lost can still have committed, and a concurrent
|
||||
// request for the same id can have: once the write has settled, the cleanup keeps
|
||||
// whichever copies a committed fork names, and drops the rest.
|
||||
Err(e) => match wait_for_fork_write(&db, &fork_id).await {
|
||||
Ok(()) => Err(crate::datatable_clone::drop_copies_after(&db, copies, e).await),
|
||||
Err(settle) => {
|
||||
tracing::error!(
|
||||
"Could not tell whether fork '{fork_id}' was created, so its copies were \
|
||||
kept: {settle}"
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Creating the fork stopped unexpectedly: {e}")))?
|
||||
}
|
||||
|
||||
/// The database a data table of the fork or dev workspace `fork_id` is copied into, as the wizard
|
||||
/// and the CLI derive it. A dev workspace's id has no `wm-fork-` prefix, and its copies are dropped
|
||||
/// by the same `wm_fork_` rule as a fork's. Dev workspace `x` and fork `wm-fork-x` share a name, as
|
||||
/// their branches do: the second to copy a data table of that name is refused at `CREATE`.
|
||||
fn forked_datatable_dbname(fork_id: &str, datatable: &str) -> String {
|
||||
let suffix = fork_id
|
||||
.strip_prefix(windmill_common::workspaces::WM_FORK_PREFIX)
|
||||
.unwrap_or(fork_id);
|
||||
format!("wm_fork_{}__{datatable}", suffix.replace('-', "_"))
|
||||
}
|
||||
|
||||
/// Wait until no transaction writing fork `fork_id` is still running: the lock
|
||||
/// `write_workspace_fork` holds until its transaction ends is taken and released.
|
||||
async fn wait_for_fork_write(db: &DB, fork_id: &str) -> Result<()> {
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('fork:' || $1))")
|
||||
.bind(fork_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Everything about a fork's data tables that can be refused before a git branch or a database is
|
||||
/// created, and that the fork request re-checks.
|
||||
///
|
||||
/// Every data table is copied by the fork request, into a database it creates: an entry naming a
|
||||
/// database that already exists could reach a copy made for another fork — one left behind by a
|
||||
/// deleted fork included — without that copy's governance.
|
||||
async fn validate_forked_datatables(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
parent_w_id: &str,
|
||||
nw: &CreateWorkspaceFork,
|
||||
) -> Result<()> {
|
||||
let mut names = HashSet::new();
|
||||
for fdt in &nw.forked_datatables {
|
||||
if fdt.fork_behavior.is_none() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{}' names no `fork_behavior`: the fork request makes the copy of each \
|
||||
data table itself. Update the Windmill CLI.",
|
||||
fdt.name
|
||||
)));
|
||||
}
|
||||
let expected = forked_datatable_dbname(&nw.id, &fdt.name);
|
||||
if fdt.new_dbname != expected {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{}' of fork '{}' is copied into database '{expected}', not '{}'",
|
||||
fdt.name, nw.id, fdt.new_dbname
|
||||
)));
|
||||
}
|
||||
if !names.insert(fdt.name.as_str()) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{}' is named more than once in this fork",
|
||||
fdt.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
crate::datatable_clone::authorize_copies(
|
||||
db,
|
||||
authed,
|
||||
parent_w_id,
|
||||
©_requests(&nw.forked_datatables),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The copies a fork request asks this request to make. [`validate_forked_datatables`] refuses an
|
||||
/// entry without a `fork_behavior`.
|
||||
fn copy_requests(
|
||||
forked_datatables: &[ForkedDatatableInfo],
|
||||
) -> Vec<crate::datatable_clone::CopyRequest<'_>> {
|
||||
forked_datatables
|
||||
.iter()
|
||||
.filter_map(|fdt| {
|
||||
fdt.fork_behavior
|
||||
.map(|behavior| crate::datatable_clone::CopyRequest {
|
||||
name: &fdt.name,
|
||||
dbname: &fdt.new_dbname,
|
||||
behavior,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write the fork, with its data tables pointed at `copies`.
|
||||
async fn write_workspace_fork(
|
||||
db: DB,
|
||||
authed: ApiAuthed,
|
||||
parent_workspace_id: String,
|
||||
nw: CreateWorkspaceFork,
|
||||
dev_workspace_label: Option<String>,
|
||||
copies: &[crate::datatable_clone::MadeCopy],
|
||||
) -> Result<String> {
|
||||
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
|
||||
// Held until this transaction ends: after an error, the copies' cleanup waits on it, so it reads
|
||||
// whether the fork exists only once a commit still being resolved has settled.
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('fork:' || $1))")
|
||||
.bind(&nw.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// Before the settings clone reads the parent's data tables: a pointer this fork ends up with
|
||||
// must not be written after cleanup of the parent decided that nothing points at its copies.
|
||||
// Also before the external cluster's lifecycle lock, which finalizing an external copy takes:
|
||||
@@ -9230,10 +9555,41 @@ async fn create_workspace_fork(
|
||||
|
||||
repoint_unresolvable_cloned_identities(&mut tx, &forked_id, &authed).await?;
|
||||
|
||||
// Held until the fork commits, as settings saves naming these databases hold it: an entry
|
||||
// saved elsewhere while a copy was being made would reach it without the governance written
|
||||
// below, and the alias check on that save saw no governed entry yet.
|
||||
let instance_copies: Vec<&str> = copies
|
||||
.iter()
|
||||
.filter(|c| c.source_database.resource_type == DataTableCatalogResourceType::Instance)
|
||||
.map(|c| c.dbname.as_str())
|
||||
.collect();
|
||||
windmill_common::lock_instance_databases(&mut tx, instance_copies.iter().copied()).await?;
|
||||
for dbname in &instance_copies {
|
||||
let users = windmill_common::instance_database_users(&mut *tx, dbname).await?;
|
||||
if !users.is_empty() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Database '{dbname}' copied for this fork is already used by workspaces {}; \
|
||||
fork again",
|
||||
users.join(", ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Update forked datatable settings to point to new databases
|
||||
for fdt in &nw.forked_datatables {
|
||||
apply_forked_datatable(&db, &mut tx, &authed, &parent_workspace_id, &forked_id, fdt)
|
||||
.await?;
|
||||
let copy = copies.iter().find(|c| c.name == fdt.name).ok_or_else(|| {
|
||||
Error::internal_err(format!("No copy was made of data table '{}'", fdt.name))
|
||||
})?;
|
||||
apply_forked_datatable(
|
||||
&db,
|
||||
&mut tx,
|
||||
&authed,
|
||||
&parent_workspace_id,
|
||||
&forked_id,
|
||||
fdt,
|
||||
copy,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
point_kept_datatables_at_parent(
|
||||
@@ -9292,6 +9648,20 @@ async fn create_workspace_fork(
|
||||
.await?;
|
||||
}
|
||||
|
||||
let copied = copies
|
||||
.iter()
|
||||
.map(|c| {
|
||||
format!(
|
||||
"{} ({})",
|
||||
c.name,
|
||||
match c.behavior {
|
||||
DataTableForkBehavior::SchemaOnly => "schema_only",
|
||||
_ => "schema_and_data",
|
||||
}
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -9299,7 +9669,7 @@ async fn create_workspace_fork(
|
||||
ActionKind::Create,
|
||||
&forked_id,
|
||||
Some(nw.name.as_str()),
|
||||
None,
|
||||
(!copied.is_empty()).then(|| [("copied_datatables", copied.as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -507,21 +507,25 @@ pub(crate) async fn change_workspace_id(
|
||||
// A fork's data table entry names the workspace that governs it by id, so the rename has to
|
||||
// follow there too — anywhere, not just in the reparented children: a detached workspace can
|
||||
// point at this one without being its fork. Left behind, the pointer resolves to the archived
|
||||
// shell and every job through it stops.
|
||||
// shell and every job through it stops. A clone's `governed_by` names it the same way.
|
||||
info!("Re-pointing data table references to the new workspace id");
|
||||
sqlx::query!(
|
||||
r#"UPDATE workspace_settings ws
|
||||
SET datatable = (
|
||||
SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(
|
||||
dt.key,
|
||||
CASE WHEN dt.value->'reference'->>'workspace_id' = $2
|
||||
THEN jsonb_set(dt.value, '{reference,workspace_id}', to_jsonb($1::text))
|
||||
ELSE dt.value END
|
||||
(SELECT CASE WHEN r.v->'governed_by'->>'workspace_id' = $2
|
||||
THEN jsonb_set(r.v, '{governed_by,workspace_id}', to_jsonb($1::text))
|
||||
ELSE r.v END
|
||||
FROM (SELECT CASE WHEN dt.value->'reference'->>'workspace_id' = $2
|
||||
THEN jsonb_set(dt.value, '{reference,workspace_id}', to_jsonb($1::text))
|
||||
ELSE dt.value END AS v) r)
|
||||
))
|
||||
FROM jsonb_each(ws.datatable->'datatables') dt
|
||||
)
|
||||
WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'
|
||||
AND ws.datatable::text LIKE '%"reference"%'"#,
|
||||
AND (ws.datatable::text LIKE '%"reference"%'
|
||||
OR ws.datatable::text LIKE '%"governed_by"%')"#,
|
||||
&rw.new_id,
|
||||
&old_id,
|
||||
)
|
||||
@@ -1042,17 +1046,19 @@ pub(crate) async fn delete_workspace(
|
||||
// fails mid-way must never leave a live workspace with its fork data destroyed and no
|
||||
// registry row to retry from. Read-only: nothing is dropped here.
|
||||
// Read before the delete: another workspace's data table entry can point at one of this
|
||||
// workspace's, and deleting the workspace it names leaves that pointer resolving to nothing.
|
||||
// Nothing sweeps them — turning them back into copies would hand each fork the database
|
||||
// outright — so the deleter is told which data tables they just stranded.
|
||||
let stranded_pointers = sqlx::query!(
|
||||
r#"SELECT ws.workspace_id AS "workspace_id!", dt.key AS "datatable!"
|
||||
// workspace's, or be a clone taking its roles from one, and deleting the workspace it names
|
||||
// leaves it resolving to nothing. Nothing sweeps them — turning them back into copies would
|
||||
// hand each fork the database outright — so the deleter is told which data tables they just
|
||||
// stranded.
|
||||
let stranded_pointers = sqlx::query_as::<_, (String, String)>(
|
||||
r#"SELECT ws.workspace_id, dt.key
|
||||
FROM workspace_settings ws
|
||||
CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt
|
||||
WHERE dt.value->'reference'->>'workspace_id' = $1
|
||||
OR dt.value->'governed_by'->>'workspace_id' = $1
|
||||
ORDER BY ws.workspace_id, dt.key"#,
|
||||
&w_id,
|
||||
)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -1380,7 +1386,7 @@ pub(crate) async fn delete_workspace(
|
||||
} else {
|
||||
let stranded = stranded_pointers
|
||||
.iter()
|
||||
.map(|r| format!("{}/{}", r.workspace_id, r.datatable))
|
||||
.map(|(workspace_id, datatable)| format!("{workspace_id}/{datatable}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Ok(format!(
|
||||
|
||||
@@ -29094,6 +29094,15 @@ components:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/ModelPriceOverride"
|
||||
context_window_per_model:
|
||||
type: object
|
||||
description: >-
|
||||
Context window in tokens per `provider:model`, overriding the built-in
|
||||
table the AI chat uses to decide when to compact its history.
|
||||
additionalProperties:
|
||||
type: integer
|
||||
minimum: 1024
|
||||
maximum: 10000000
|
||||
copilot_disabled:
|
||||
type: boolean
|
||||
description: >-
|
||||
@@ -33920,6 +33929,8 @@ components:
|
||||
$ref: "#/components/schemas/LoggedWizardStatus"
|
||||
replication_user_error:
|
||||
type: string
|
||||
user_connect:
|
||||
$ref: "#/components/schemas/LoggedWizardStatus"
|
||||
|
||||
CustomInstanceDbTag:
|
||||
type: string
|
||||
@@ -34008,6 +34019,15 @@ components:
|
||||
$ref: "#/components/schemas/DatatableRoleTenants"
|
||||
governing_workspace_id:
|
||||
type: string
|
||||
clone_of:
|
||||
type: object
|
||||
description: for a clone, the data table whose roles it takes
|
||||
required: [workspace_id, datatable]
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
datatable:
|
||||
type: string
|
||||
editable:
|
||||
type: boolean
|
||||
available_roles:
|
||||
@@ -34246,7 +34266,7 @@ components:
|
||||
|
||||
DatatableAclInfo:
|
||||
type: object
|
||||
required: [owner, roles, editable, supports_maintain, dbname, grants, children]
|
||||
required: [owner, roles, editable, clone, supports_maintain, dbname, grants, children]
|
||||
properties:
|
||||
owner:
|
||||
type: string
|
||||
@@ -34258,6 +34278,9 @@ components:
|
||||
editable:
|
||||
type: boolean
|
||||
description: whether the caller may plan and apply changes
|
||||
clone:
|
||||
type: boolean
|
||||
description: whether this is a clone, whose grants stay as they were copied
|
||||
supports_maintain:
|
||||
type: boolean
|
||||
description: whether the server is Postgres 17+, which added the MAINTAIN table privilege
|
||||
@@ -35413,6 +35436,16 @@ components:
|
||||
new_dbname:
|
||||
type: string
|
||||
description: "New database name for the fork"
|
||||
fork_behavior:
|
||||
type: string
|
||||
enum:
|
||||
- schema_only
|
||||
- schema_and_data
|
||||
description: >-
|
||||
What the fork request copies into `new_dbname`, which it creates — with the
|
||||
owners and grants of a data table under roles. This server refuses an entry
|
||||
without it; servers predating it expect `new_dbname` created and filled
|
||||
beforehand.
|
||||
shared_ducklakes:
|
||||
type: array
|
||||
items:
|
||||
@@ -36377,6 +36410,17 @@ components:
|
||||
type: string
|
||||
datatable:
|
||||
type: string
|
||||
governed_by:
|
||||
description: >-
|
||||
On a clone, the data table it was copied from, whose roles it takes. Server-owned
|
||||
like `reference`.
|
||||
type: object
|
||||
required: [workspace_id, datatable]
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
datatable:
|
||||
type: string
|
||||
migrations_enabled:
|
||||
type: boolean
|
||||
description: Whether the SQL migrations feature is opted in for this data table
|
||||
|
||||
@@ -23,7 +23,7 @@ use windmill_ai::ai_cache::current_instance_ai_config_revision;
|
||||
use windmill_ai::ai_providers::{
|
||||
empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel,
|
||||
};
|
||||
use windmill_ai::ai_types::MAX_MODEL_RATE;
|
||||
use windmill_ai::ai_types::{validate_token_limit, CONTEXT_WINDOWS, MAX_MODEL_RATE, OUTPUT_LIMITS};
|
||||
use windmill_ai::credentials::ProviderCredentials;
|
||||
#[cfg(feature = "bedrock")]
|
||||
use windmill_ai::providers::bedrock::{
|
||||
@@ -445,6 +445,11 @@ pub struct AIConfig {
|
||||
/// Only models whose rates differ from the built-in table are stored.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_pricing: Option<HashMap<String, ModelPriceOverride>>,
|
||||
/// Per-model context windows the chat budgets its history against, keyed
|
||||
/// `provider:model` like `max_tokens_per_model`. Overrides the client's built-in
|
||||
/// table, which a model served through a custom endpoint is usually missing from.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_window_per_model: Option<HashMap<String, i32>>,
|
||||
/// Hides the Windmill AI assistant (chat, sessions, generation, completion, fixes) from
|
||||
/// the workspace UI. Only the workspace's own row is consulted: the flag holds even when
|
||||
/// the providers served come from the instance config or the free tier. AI agent steps
|
||||
@@ -508,6 +513,18 @@ impl AIConfig {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_token_limits(&self) -> Result<()> {
|
||||
for (entries, bounds) in [
|
||||
(&self.context_window_per_model, &CONTEXT_WINDOWS),
|
||||
(&self.max_tokens_per_model, &OUTPUT_LIMITS),
|
||||
] {
|
||||
for (key, tokens) in entries.iter().flatten() {
|
||||
validate_token_limit(bounds, key, i64::from(*tokens)).map_err(Error::BadRequest)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_sessions_retention(&self) -> Result<()> {
|
||||
match self.sessions_retention_days {
|
||||
Some(days) if !(1..=MAX_SESSIONS_RETENTION_DAYS).contains(&days) => {
|
||||
|
||||
@@ -108,6 +108,7 @@ async fn edit_copilot_config(
|
||||
}
|
||||
|
||||
ai_config.validate_model_pricing()?;
|
||||
ai_config.validate_token_limits()?;
|
||||
ai_config.validate_sessions_retention()?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
@@ -167,7 +167,13 @@ pub fn jwk_algorithms(jwk: &Jwk) -> Option<Vec<Algorithm>> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match (&jwk.algorithm, jwk.common.algorithm) {
|
||||
let alg = crate::jwt::jwk_algorithm(jwk);
|
||||
// A key that names an `alg` we cannot verify with (RSA-OAEP, ...) is not an alg-less
|
||||
// key to be tried against the whole family: it is published for something else.
|
||||
if alg.is_none() && jwk.common.key_algorithm.is_some() {
|
||||
return None;
|
||||
}
|
||||
match (&jwk.algorithm, alg) {
|
||||
(AlgorithmParameters::RSA(_), Some(alg)) if RSA_ALGORITHMS.contains(&alg) => {
|
||||
Some(vec![alg])
|
||||
}
|
||||
@@ -197,6 +203,9 @@ pub fn verify(
|
||||
let mut validation = Validation::new(algorithms[0]);
|
||||
validation.algorithms = algorithms.to_vec();
|
||||
validation.validate_nbf = true;
|
||||
// Audience is not part of the guest contract: issuers commonly stamp their own `aud`,
|
||||
// and no accepted audience is configured, so an `aud` claim must not fail the token.
|
||||
validation.validate_aud = false;
|
||||
let claims = jsonwebtoken::decode::<GuestJwtClaims>(token, key, &validation)
|
||||
.map_err(|e| Error::NotAuthorized(format!("guest JWT refused: {e}")))?
|
||||
.claims;
|
||||
@@ -648,6 +657,12 @@ mod tests {
|
||||
assert_eq!(jwk_algorithms(&k), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_naming_an_encryption_alg_is_refused() {
|
||||
let k = jwk(serde_json::json!({"kty":"RSA","alg":"RSA-OAEP","n":"aa","e":"AQAB"}));
|
||||
assert_eq!(jwk_algorithms(&k), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_marked_for_encryption_is_refused() {
|
||||
let k = jwk(serde_json::json!({"kty":"RSA","use":"enc","n":"aa","e":"AQAB"}));
|
||||
@@ -789,6 +804,50 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_carrying_an_audience_still_verifies() {
|
||||
// jsonwebtoken validates `aud` by default when the token has one, which would refuse
|
||||
// every issuer that stamps its own audience.
|
||||
let key = jsonwebtoken::DecodingKey::from_jwk(&jwk(serde_json::json!({
|
||||
"kty": "EC", "crv": "P-256", "kid": "k1", "x": PUB1_X, "y": PUB1_Y
|
||||
})))
|
||||
.unwrap();
|
||||
let token = jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::new(Algorithm::ES256),
|
||||
&serde_json::json!({
|
||||
"email": "g@example.com",
|
||||
"workspace_id": "ws",
|
||||
"app_path": "u/a/app",
|
||||
"aud": "https://issuer.example/api",
|
||||
"exp": jsonwebtoken::get_current_timestamp() + 600,
|
||||
}),
|
||||
&jsonwebtoken::EncodingKey::from_ec_pem(PRIV1.as_bytes()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
verify(&token, &key, &[Algorithm::ES256], "ws").expect("aud is not validated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_8192_bit_rsa_key_verifies() {
|
||||
// The largest modulus real issuers use; a crypto backend capped at 4096 bits rejects
|
||||
// every token signed with it while the key itself still loads.
|
||||
const PRIVATE: &str = include_str!("../tests/fixtures/guest_jwt_rsa8192_private.pem");
|
||||
const PUBLIC: &str = include_str!("../tests/fixtures/guest_jwt_rsa8192_public.pem");
|
||||
let (key, algs) = decoding_key_from_pem(PUBLIC).expect("8192-bit SPKI loads");
|
||||
let token = jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::new(Algorithm::RS256),
|
||||
&serde_json::json!({
|
||||
"email": "g@example.com",
|
||||
"workspace_id": "ws",
|
||||
"app_path": "u/a/app",
|
||||
"exp": jsonwebtoken::get_current_timestamp() + 600,
|
||||
}),
|
||||
&jsonwebtoken::EncodingKey::from_rsa_pem(PRIVATE.as_bytes()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
verify(&token, &key, algs, "ws").expect("8192-bit RSA signature verifies");
|
||||
}
|
||||
|
||||
// A real RSA public key (SPKI). Its private counterpart is RSA_PKCS1_PRIVATE below.
|
||||
const RSA_PUBLIC: &str = "-----BEGIN PUBLIC KEY-----\n\
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3J0fQcHp2ZlMI4rCVsY\n\
|
||||
|
||||
@@ -836,6 +836,8 @@ pub struct CustomInstanceDbLogs {
|
||||
pub replication_user: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub replication_user_error: Option<String>,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub user_connect: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,6 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashSet;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref JWT_SECRET: arc_swap::ArcSwap<String> = arc_swap::ArcSwap::from_pointee("".to_string());
|
||||
@@ -44,18 +43,8 @@ pub async fn decode_with_internal_secret<T: DeserializeOwned>(token: &str) -> er
|
||||
}
|
||||
|
||||
pub fn decode_without_verify<T: DeserializeOwned>(token: &str) -> anyhow::Result<T> {
|
||||
// Create a validation that skips all checks
|
||||
let mut validation = jsonwebtoken::Validation::default();
|
||||
validation.insecure_disable_signature_validation();
|
||||
validation.validate_exp = false;
|
||||
validation.validate_nbf = false;
|
||||
validation.required_spec_claims = HashSet::new();
|
||||
|
||||
// Use an empty key since we're not verifying
|
||||
let key = jsonwebtoken::DecodingKey::from_secret(&[]);
|
||||
|
||||
// Decode the token
|
||||
let token_data = jsonwebtoken::decode::<T>(token, &key, &validation)?;
|
||||
// No signature, expiry, nbf or required-claim checks: the caller wants the raw claims.
|
||||
let token_data = jsonwebtoken::dangerous::insecure_decode::<T>(token)?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
@@ -74,3 +63,36 @@ pub async fn generate_signature(header_and_payload: &str) -> anyhow::Result<Stri
|
||||
let result = mac.finalize().into_bytes();
|
||||
Ok(URL_SAFE_NO_PAD.encode(result))
|
||||
}
|
||||
|
||||
/// The signing algorithm a JWK names in its `alg`, or `None` when it names none (or one
|
||||
/// that is not a signing algorithm, e.g. `RSA-OAEP`). The only place that reads the
|
||||
/// field, so callers, including EE ones, stay agnostic of how `jsonwebtoken` models it.
|
||||
pub fn jwk_algorithm(jwk: &jsonwebtoken::jwk::Jwk) -> Option<jsonwebtoken::Algorithm> {
|
||||
// `alg` is a `KeyAlgorithm` (signing and encryption algorithms alike); only the
|
||||
// signing subset maps onto `Algorithm`.
|
||||
jwk.common
|
||||
.key_algorithm
|
||||
.and_then(|alg| alg.to_string().parse::<jsonwebtoken::Algorithm>().ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::jwk_algorithm;
|
||||
use jsonwebtoken::{jwk::Jwk, Algorithm};
|
||||
|
||||
#[test]
|
||||
fn jwk_algorithm_reads_alg_when_present() {
|
||||
let with: Jwk =
|
||||
serde_json::from_value(serde_json::json!({"kty":"RSA","alg":"RS256","n":"aa","e":"AQAB"}))
|
||||
.unwrap();
|
||||
assert_eq!(jwk_algorithm(&with), Some(Algorithm::RS256));
|
||||
let without: Jwk =
|
||||
serde_json::from_value(serde_json::json!({"kty":"RSA","n":"aa","e":"AQAB"})).unwrap();
|
||||
assert_eq!(jwk_algorithm(&without), None);
|
||||
let encryption: Jwk = serde_json::from_value(
|
||||
serde_json::json!({"kty":"RSA","alg":"RSA-OAEP","n":"aa","e":"AQAB"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(jwk_algorithm(&encryption), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1484,20 +1484,161 @@ pub fn validate_dbname(dbname: &str) -> error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lock the instance databases among `names` until `tx` ends, in a stable order. Taken by every
|
||||
/// settings save naming an instance database, and by [`drop_unused_instance_database`]: a save
|
||||
/// cannot start using a database between that drop's check that nothing does and the drop.
|
||||
pub async fn lock_instance_databases<'a>(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
names: impl IntoIterator<Item = &'a str>,
|
||||
) -> error::Result<()> {
|
||||
let names: std::collections::BTreeSet<&str> = names.into_iter().collect();
|
||||
for name in names {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('instance_database:' || $1))")
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop instance database `dbname`, which a request just created, unless a workspace names it as a
|
||||
/// data table or a ducklake catalog — then it is kept, and who keeps it is what comes back. A
|
||||
/// database is registered on the instance as soon as it is created, so a superadmin can point a
|
||||
/// workspace at it before the request that created it gives up on it.
|
||||
///
|
||||
/// Authorization: none. Callers MUST pass only a database the same request created and has not
|
||||
/// handed to anything yet.
|
||||
///
|
||||
/// The lock, the check and the drop share one connection: a second one taken from the pool while
|
||||
/// the first is held could wait forever on a small pool. That connection is closed rather than
|
||||
/// returned, since a session lock outlives the future holding it: a cancellation between taking
|
||||
/// the lock and releasing it would otherwise hand a locked session back to the pool, where every
|
||||
/// later settings save waits on it. Closing on drop covers the cancellation and still counts the
|
||||
/// connection against the pool, which detaching it would not.
|
||||
pub async fn drop_unused_instance_database(db: &DB, dbname: &str) -> error::Result<Cleanup> {
|
||||
let mut conn = db.acquire().await?;
|
||||
conn.close_on_drop();
|
||||
let key = format!("instance_database:{dbname}");
|
||||
// A save blocked on this lock is about to name the database, but it may also roll back — a
|
||||
// later validation of its own, a superadmin check, a cancelled request. So it is let through
|
||||
// and what it committed is read, rather than taken as a user: trusting it would strand the
|
||||
// database, whose name then blocks every retry. A fresh waiter can always arrive, so after a
|
||||
// few rounds the database is kept instead; the name is then a superadmin's to drop from
|
||||
// instance settings, since a retry fails on the name before reaching this cleanup.
|
||||
let mut rounds = 0;
|
||||
let dropped = loop {
|
||||
if let Err(e) = sqlx::query("SELECT pg_advisory_lock(hashtext($1))")
|
||||
.bind(&key)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
{
|
||||
break Err(e.into());
|
||||
}
|
||||
match drop_if_unused_on(&mut conn, dbname).await {
|
||||
Ok(Cleanup::Waiter(_)) if rounds < 2 => {
|
||||
rounds += 1;
|
||||
// Releasing hands the lock to the waiter; re-taking it above then waits for that
|
||||
// transaction to end, so the next round reads what it actually committed.
|
||||
if let Err(e) = unlock_instance_database(&mut conn, &key).await {
|
||||
break Err(e);
|
||||
}
|
||||
}
|
||||
Ok(outcome) => break Ok(outcome),
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
};
|
||||
// Dropping closes the connection, and the server releases the lock with the session, so
|
||||
// nothing here depends on an unlock landing.
|
||||
dropped
|
||||
}
|
||||
|
||||
async fn unlock_instance_database(conn: &mut sqlx::PgConnection, key: &str) -> error::Result<()> {
|
||||
sqlx::query("SELECT pg_advisory_unlock(hashtext($1))")
|
||||
.bind(key)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What [`drop_unused_instance_database`] did, and for whom when it kept the database.
|
||||
pub enum Cleanup {
|
||||
Dropped,
|
||||
/// Workspaces that name the database, as [`instance_database_users`] reports them.
|
||||
InUse(Vec<String>),
|
||||
/// A transaction still blocked on the lock after the rounds above, so what it will commit
|
||||
/// stays unknown, named as its `pid`.
|
||||
Waiter(i32),
|
||||
}
|
||||
|
||||
async fn drop_if_unused_on(conn: &mut sqlx::PgConnection, dbname: &str) -> error::Result<Cleanup> {
|
||||
let users = instance_database_users(conn, dbname).await?;
|
||||
if !users.is_empty() {
|
||||
return Ok(Cleanup::InUse(users));
|
||||
}
|
||||
// A settings save naming this database takes the same lock, so one waiting on it would read
|
||||
// its own users after the drop and commit a reference to nothing.
|
||||
if let Some(waiter) = waiting_for_instance_database(conn, dbname).await? {
|
||||
return Ok(Cleanup::Waiter(waiter));
|
||||
}
|
||||
drop_custom_instance_database_on(conn, dbname).await?;
|
||||
Ok(Cleanup::Dropped)
|
||||
}
|
||||
|
||||
/// The `pid` of a transaction blocked on `dbname`'s instance-database lock. The lock is
|
||||
/// taken by key, so `pg_locks` reports it split across `classid` and `objid`, and it lists every
|
||||
/// database on the cluster — another Windmill on the same one holds its own locks under the same
|
||||
/// key.
|
||||
async fn waiting_for_instance_database(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
dbname: &str,
|
||||
) -> error::Result<Option<i32>> {
|
||||
let pid: Option<i32> = sqlx::query_scalar(
|
||||
"SELECT l.pid FROM pg_locks l
|
||||
WHERE l.locktype = 'advisory' AND NOT l.granted
|
||||
AND l.database = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||
AND l.classid = ((hashtext('instance_database:' || $1)::bigint >> 32) & 4294967295)::oid
|
||||
AND l.objid = (hashtext('instance_database:' || $1)::bigint & 4294967295)::oid
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(dbname)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
Ok(pid)
|
||||
}
|
||||
|
||||
/// The workspaces naming instance database `dbname` as a data table or a ducklake catalog. Taken
|
||||
/// under [`lock_instance_databases`] for `dbname`, the answer holds until that lock is released.
|
||||
///
|
||||
/// Authorization: none, and it reads every workspace's settings. Callers MUST pass only a database
|
||||
/// their own request created, and may name the workspaces returned only to a caller allowed to
|
||||
/// create or drop instance databases.
|
||||
pub async fn instance_database_users(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
dbname: &str,
|
||||
) -> error::Result<Vec<String>> {
|
||||
Ok(sqlx::query_scalar(
|
||||
"SELECT DISTINCT ws.workspace_id FROM workspace_settings ws
|
||||
WHERE EXISTS (SELECT 1 FROM jsonb_each(CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object'
|
||||
THEN ws.datatable->'datatables' ELSE '{}'::jsonb END) dt
|
||||
WHERE dt.value->'database'->>'resource_type' = 'instance'
|
||||
AND dt.value->'database'->>'resource_path' = $1)
|
||||
OR EXISTS (SELECT 1 FROM jsonb_each(CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object'
|
||||
THEN ws.ducklake->'ducklakes' ELSE '{}'::jsonb END) dl
|
||||
WHERE dl.value->'catalog'->>'resource_type' = 'instance'
|
||||
AND dl.value->'catalog'->>'resource_path' = $1)",
|
||||
)
|
||||
.bind(dbname)
|
||||
.fetch_all(conn)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Drop a custom instance database: validate, terminate connections, DROP DATABASE, remove from global_settings.
|
||||
///
|
||||
/// Authorization: drops any instance database but Windmill's own and checks nothing. Callers MUST
|
||||
/// be superadmin, or have established the caller may drop this one — a fork's owner cleaning up
|
||||
/// its own copy that nothing else uses.
|
||||
pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Result<()> {
|
||||
drop_custom_instance_database_keep_entry(db, dbname).await?;
|
||||
sqlx::query!(
|
||||
r#"UPDATE global_settings SET value = value #- ARRAY['databases', $1] WHERE name = 'custom_instance_pg_databases'"#,
|
||||
dbname.trim()
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
drop_custom_instance_database_on(&mut *db.acquire().await?, dbname).await
|
||||
}
|
||||
|
||||
/// [`drop_custom_instance_database`] leaving its registry entry, for a caller holding row locks in
|
||||
@@ -1505,6 +1646,29 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu
|
||||
/// connection for a lock the transaction's own peers hold is a deadlock Postgres cannot see. Same
|
||||
/// authorization contract.
|
||||
pub async fn drop_custom_instance_database_keep_entry(db: &DB, dbname: &str) -> error::Result<()> {
|
||||
drop_instance_database_keep_entry_on(&mut *db.acquire().await?, dbname).await
|
||||
}
|
||||
|
||||
async fn drop_custom_instance_database_on(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
dbname: &str,
|
||||
) -> error::Result<()> {
|
||||
let dbname = dbname.trim();
|
||||
drop_instance_database_keep_entry_on(&mut *conn, dbname).await?;
|
||||
// Always remove from global_settings
|
||||
sqlx::query!(
|
||||
r#"UPDATE global_settings SET value = value #- ARRAY['databases', $1] WHERE name = 'custom_instance_pg_databases'"#,
|
||||
dbname
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn drop_instance_database_keep_entry_on(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
dbname: &str,
|
||||
) -> error::Result<()> {
|
||||
let dbname = dbname.trim();
|
||||
validate_dbname(dbname)?;
|
||||
|
||||
@@ -1519,7 +1683,7 @@ pub async fn drop_custom_instance_database_keep_entry(db: &DB, dbname: &str) ->
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
|
||||
dbname
|
||||
)
|
||||
.fetch_one(db)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -1530,7 +1694,7 @@ pub async fn drop_custom_instance_database_keep_entry(db: &DB, dbname: &str) ->
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}' AND pid <> pg_backend_pid()",
|
||||
dbname.replace('\'', "''")
|
||||
))
|
||||
.execute(db)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to terminate connections to '{}': {}", dbname, e);
|
||||
@@ -1539,7 +1703,7 @@ pub async fn drop_custom_instance_database_keep_entry(db: &DB, dbname: &str) ->
|
||||
// Drop the database
|
||||
// SAFETY: `dbname` has been validated via validate_dbname() before reaching this point.
|
||||
sqlx::query(&format!("DROP DATABASE IF EXISTS \"{}\"", dbname))
|
||||
.execute(db)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error::Error::internal_err(format!("Failed to drop database '{}': {}", dbname, e))
|
||||
@@ -1623,6 +1787,44 @@ pub async fn create_custom_instance_database(
|
||||
error::Error::internal_err(format!("Failed to create database '{}': {}", dbname, e))
|
||||
})?;
|
||||
|
||||
// Nothing names a database that failed past this point, and its name blocks the retry: drop it
|
||||
// rather than leave it behind.
|
||||
if let Err(e) = finish_custom_instance_database(db, dbname, tag, for_workspace).await {
|
||||
match drop_unused_instance_database(db, dbname).await {
|
||||
Ok(Cleanup::InUse(users)) => tracing::warn!(
|
||||
"Kept '{dbname}' after failing to set it up: workspaces {} use it",
|
||||
users.join(", ")
|
||||
),
|
||||
Ok(Cleanup::Waiter(pid)) => tracing::warn!(
|
||||
"Kept '{dbname}' after failing to set it up: a request (pid {pid}) is still \
|
||||
waiting to name it. Drop it from instance settings once it is unused."
|
||||
),
|
||||
Ok(Cleanup::Dropped) => {}
|
||||
Err(drop_err) => {
|
||||
tracing::error!("Could not drop '{dbname}' after failing to set it up: {drop_err}")
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// A data table role can only reach a database it may CONNECT to, and PUBLIC's default CONNECT
|
||||
// would otherwise let every role in regardless of what this instance defines. Best-effort: a
|
||||
// failure here leaves the database usable as `admin`, and the next role change repairs it.
|
||||
if let Err(e) = crate::datatable_roles::converge_connect_grants(db, dbname).await {
|
||||
tracing::warn!("Could not set CONNECT grants on instance database '{dbname}': {e}");
|
||||
}
|
||||
|
||||
tracing::info!("Created custom instance database '{}'", dbname);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Grant `custom_instance_user` its privileges on a database just created, and register it.
|
||||
async fn finish_custom_instance_database(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
tag: &str,
|
||||
for_workspace: Option<&str>,
|
||||
) -> error::Result<()> {
|
||||
// Grant permissions to custom_instance_user
|
||||
let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
|
||||
let new_pg_creds = PgDatabase { dbname: dbname.to_string(), ..wmill_pg_creds };
|
||||
@@ -1658,15 +1860,6 @@ pub async fn create_custom_instance_database(
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
// A data table role can only reach a database it may CONNECT to, and PUBLIC's default CONNECT
|
||||
// would otherwise let every role in regardless of what this instance defines. Best-effort: a
|
||||
// failure here leaves the database usable as `admin`, and the next role change repairs it.
|
||||
if let Err(e) = crate::datatable_roles::converge_connect_grants(db, dbname).await {
|
||||
tracing::warn!("Could not set CONNECT grants on instance database '{dbname}': {e}");
|
||||
}
|
||||
|
||||
tracing::info!("Created custom instance database '{}'", dbname);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1304,6 +1304,12 @@ pub struct DataTable {
|
||||
/// nothing local for a fork admin to widen.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reference: Option<DataTableReference>,
|
||||
/// Set on a *clone* — a terminal entry whose database was copied from the entry this names. The
|
||||
/// copy holds that entry's rows, so who may connect as which role stays that entry's decision:
|
||||
/// the clone carries no `permissions` of its own and is governed like a pointer, while
|
||||
/// connecting to its own database.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub governed_by: Option<DataTableReference>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forked_from: Option<DataTableForkedFrom>,
|
||||
/// Whether the SQL-migrations feature is opted in for this data table.
|
||||
@@ -1362,9 +1368,16 @@ pub const DATATABLE_TENANT_WILDCARD: &str = "*";
|
||||
/// enough to survive a fork of a fork.
|
||||
const DATATABLE_REFERENCE_MAX_DEPTH: usize = 20;
|
||||
|
||||
/// Exactly one of `database` and `reference` must be set. Called wherever an entry is persisted,
|
||||
/// so nothing downstream has to handle an entry that is both or neither.
|
||||
/// Exactly one of `database` and `reference` must be set, and a clone's `governed_by` leaves no
|
||||
/// `permissions` beside it. Called wherever an entry is persisted, so nothing downstream has to
|
||||
/// handle an entry that is both or neither, or a clone with a decision of its own.
|
||||
pub fn validate_datatable_shape(name: &str, dt: &DataTable) -> Result<()> {
|
||||
if dt.governed_by.is_some() && (dt.database.is_none() || dt.permissions.is_some()) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{name}' is a clone, which owns a database and takes its roles from the \
|
||||
data table it was cloned from"
|
||||
)));
|
||||
}
|
||||
match (&dt.database, &dt.reference) {
|
||||
(Some(_), None) | (None, Some(_)) => Ok(()),
|
||||
(Some(_), Some(_)) => Err(Error::BadRequest(format!(
|
||||
@@ -1464,23 +1477,28 @@ pub async fn read_datatable_entry(db: &DB, w_id: &str, name: &str) -> Result<Dat
|
||||
Ok(serde_json::from_value::<DataTable>(datatable.clone())?)
|
||||
}
|
||||
|
||||
/// The terminal entry a reference chain lands on: the workspace that governs the data table, the
|
||||
/// entry name there, and the entry itself. A terminal entry resolves to itself.
|
||||
/// The terminal entry a reference chain lands on, and what governs it: the workspace and name of
|
||||
/// the entry that owns the database, the entry itself, and — for a clone — the entry its
|
||||
/// `governed_by` chain lands on. A terminal entry that is not a clone resolves to itself and
|
||||
/// governs itself.
|
||||
///
|
||||
/// Every decision downstream — which database to connect to, whose `permissions` apply, whose
|
||||
/// members tenants are evaluated against, who may administer it — is taken on this, never on the
|
||||
/// entry the caller named.
|
||||
/// Every decision downstream is taken on this, never on the entry the caller named. Which database
|
||||
/// to connect to comes from `workspace_id` / `name` / `datatable.database`; whose `permissions`
|
||||
/// apply (already in `datatable.permissions`), whose members tenants are evaluated against and who
|
||||
/// may administer it come from [`GoverningDatatable::governing_workspace_id`].
|
||||
///
|
||||
/// Authorization: resolving deliberately crosses into the governing workspace, so it answers for a
|
||||
/// workspace the caller may not belong to and checks nothing itself. It is the input to the
|
||||
/// checks, not one of them: callers MUST pass what it returns to
|
||||
/// [`can_use_datatable_role_in_governing_workspace`] or [`ensure_datatable_admin_access`] before
|
||||
/// acting on it, and MUST NOT return its `permissions` or `workspace_id` to a caller from
|
||||
/// acting on it, and MUST NOT return its `permissions` or workspace ids to a caller from
|
||||
/// elsewhere without gating on the answer.
|
||||
pub struct GoverningDatatable {
|
||||
pub workspace_id: String,
|
||||
pub name: String,
|
||||
pub datatable: DataTable,
|
||||
/// For a clone, the entry whose `permissions` govern it. `None` when the entry governs itself.
|
||||
pub governor: Option<DataTableReference>,
|
||||
}
|
||||
|
||||
/// Everything still using the Windmill-managed database `dbname`, one description per use: data
|
||||
@@ -1559,6 +1577,13 @@ impl GoverningDatatable {
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance)
|
||||
}
|
||||
|
||||
/// The workspace whose admins administer the data table and whose members its tenants are.
|
||||
pub fn governing_workspace_id(&self) -> &str {
|
||||
self.governor
|
||||
.as_ref()
|
||||
.map_or(&self.workspace_id, |g| &g.workspace_id)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_governing_datatable(
|
||||
@@ -1569,6 +1594,9 @@ pub async fn resolve_governing_datatable(
|
||||
let mut workspace_id = w_id.to_string();
|
||||
let mut name = name.to_string();
|
||||
let mut hops = 0;
|
||||
// The clone a `governed_by` chain started from: it keeps its database, and takes the
|
||||
// `permissions` of wherever the chain lands.
|
||||
let mut clone: Option<(String, String, DataTable)> = None;
|
||||
for _ in 0..DATATABLE_REFERENCE_MAX_DEPTH {
|
||||
let datatable = read_datatable_entry(db, &workspace_id, &name)
|
||||
.await
|
||||
@@ -1579,21 +1607,48 @@ pub async fn resolve_governing_datatable(
|
||||
// A pointer outlives the workspace it names: deleting one only nulls the fork
|
||||
// lineage, it does not sweep the entries that pointed at it. Say which one is
|
||||
// gone rather than reporting a data table this workspace never had.
|
||||
Error::NotFound(format!(
|
||||
"Data table '{name}' of workspace '{workspace_id}' governs this one and no \
|
||||
longer exists. A superadmin can point this data table somewhere else."
|
||||
))
|
||||
if clone.is_some() {
|
||||
Error::NotFound(format!(
|
||||
"Data table '{name}' of workspace '{workspace_id}', which this clone \
|
||||
takes its roles from, no longer exists, so nobody is let into the copy."
|
||||
))
|
||||
} else {
|
||||
Error::NotFound(format!(
|
||||
"Data table '{name}' of workspace '{workspace_id}' governs this one and \
|
||||
no longer exists. A superadmin can point this data table somewhere \
|
||||
else."
|
||||
))
|
||||
}
|
||||
}
|
||||
})?;
|
||||
hops += 1;
|
||||
validate_datatable_shape(&name, &datatable)?;
|
||||
match &datatable.reference {
|
||||
None => return Ok(GoverningDatatable { workspace_id, name, datatable }),
|
||||
Some(reference) => {
|
||||
workspace_id = reference.workspace_id.clone();
|
||||
name = reference.datatable.clone();
|
||||
let next = match (&datatable.reference, &datatable.governed_by) {
|
||||
(Some(reference), _) => reference.clone(),
|
||||
(None, Some(governed_by)) => {
|
||||
let governed_by = governed_by.clone();
|
||||
if clone.is_none() {
|
||||
clone = Some((workspace_id.clone(), name.clone(), datatable));
|
||||
}
|
||||
governed_by
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
return Ok(match clone {
|
||||
None => GoverningDatatable { workspace_id, name, datatable, governor: None },
|
||||
Some((clone_w_id, clone_name, mut clone_datatable)) => {
|
||||
clone_datatable.permissions = datatable.permissions;
|
||||
GoverningDatatable {
|
||||
workspace_id: clone_w_id,
|
||||
name: clone_name,
|
||||
datatable: clone_datatable,
|
||||
governor: Some(DataTableReference { workspace_id, datatable: name }),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
workspace_id = next.workspace_id;
|
||||
name = next.datatable;
|
||||
}
|
||||
Err(Error::BadRequest(format!(
|
||||
"Data table '{name}' points at another data table through more than \
|
||||
@@ -1633,17 +1688,20 @@ pub async fn resolve_workspace_governing_datatables(
|
||||
|
||||
let mut entries = Entries::new();
|
||||
let listed = load(db, &[w_id.to_string()], &mut entries).await?;
|
||||
// (index into `listed`, workspace, entry name) still to be followed.
|
||||
let mut cursors: Vec<(usize, String, String)> = listed
|
||||
// (index into `listed`, workspace, entry name, the clone the chain started from) still to be
|
||||
// followed. A clone keeps its own database and takes the `permissions` of wherever its
|
||||
// `governed_by` chain lands, as the single resolution does.
|
||||
type Clone = Option<(String, String, DataTable)>;
|
||||
let mut cursors: Vec<(usize, String, String, Clone)> = listed
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| (i, w_id.to_string(), name.clone()))
|
||||
.map(|(i, name)| (i, w_id.to_string(), name.clone(), None))
|
||||
.collect();
|
||||
let mut resolved: Vec<(usize, GoverningDatatable)> = vec![];
|
||||
|
||||
for _ in 0..DATATABLE_REFERENCE_MAX_DEPTH {
|
||||
let mut next = vec![];
|
||||
for (i, ws, name) in cursors.drain(..) {
|
||||
for (i, ws, name, clone) in cursors.drain(..) {
|
||||
let Some(value) = entries
|
||||
.get(&ws)
|
||||
.and_then(|m| m.get(&name))
|
||||
@@ -1657,14 +1715,41 @@ pub async fn resolve_workspace_governing_datatables(
|
||||
if validate_datatable_shape(&name, &datatable).is_err() {
|
||||
continue;
|
||||
}
|
||||
match &datatable.reference {
|
||||
None => {
|
||||
resolved.push((i, GoverningDatatable { workspace_id: ws, name, datatable }))
|
||||
}
|
||||
Some(reference) => next.push((
|
||||
match (&datatable.reference, &datatable.governed_by) {
|
||||
(Some(reference), _) => next.push((
|
||||
i,
|
||||
reference.workspace_id.clone(),
|
||||
reference.datatable.clone(),
|
||||
clone,
|
||||
)),
|
||||
(None, Some(governed_by)) => {
|
||||
let (governor_ws, governor_name) =
|
||||
(governed_by.workspace_id.clone(), governed_by.datatable.clone());
|
||||
let clone = clone.or(Some((ws, name, datatable)));
|
||||
next.push((i, governor_ws, governor_name, clone));
|
||||
}
|
||||
(None, None) => resolved.push((
|
||||
i,
|
||||
match clone {
|
||||
None => GoverningDatatable {
|
||||
workspace_id: ws,
|
||||
name,
|
||||
datatable,
|
||||
governor: None,
|
||||
},
|
||||
Some((clone_ws, clone_name, mut clone_datatable)) => {
|
||||
clone_datatable.permissions = datatable.permissions;
|
||||
GoverningDatatable {
|
||||
workspace_id: clone_ws,
|
||||
name: clone_name,
|
||||
datatable: clone_datatable,
|
||||
governor: Some(DataTableReference {
|
||||
workspace_id: ws,
|
||||
datatable: name,
|
||||
}),
|
||||
}
|
||||
}
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -1673,7 +1758,7 @@ pub async fn resolve_workspace_governing_datatables(
|
||||
}
|
||||
let to_load: Vec<String> = next
|
||||
.iter()
|
||||
.map(|(_, ws, _)| ws.clone())
|
||||
.map(|(_, ws, _, _)| ws.clone())
|
||||
.filter(|ws| !entries.contains_key(ws))
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
@@ -1751,8 +1836,31 @@ pub async fn get_datatable_resource_from_db_unchecked(
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
Ok(get_datatable_connection_and_kind_unchecked(db, w_id, name)
|
||||
.await?
|
||||
.0)
|
||||
}
|
||||
|
||||
/// As [`get_datatable_resource_from_db_unchecked`], also reporting the kind of database Windmill
|
||||
/// manages behind it, `None` for a user resource. One resolution answers both: a caller reading the
|
||||
/// kind separately can be handed one kind's connection and the other kind's checks by a save
|
||||
/// landing between the two, and the entry a pointer lands on is another workspace's to change.
|
||||
///
|
||||
/// Same authorization contract: the connection reaches every role.
|
||||
pub async fn get_datatable_connection_and_kind_unchecked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<(serde_json::Value, Option<DataTableCatalogResourceType>)> {
|
||||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||||
resolve_datatable_connection_unchecked(db, &governing, false).await
|
||||
let kind = governing
|
||||
.datatable
|
||||
.database
|
||||
.as_ref()
|
||||
.map(|d| d.resource_type)
|
||||
.filter(|kind| kind.is_windmill_managed());
|
||||
let connection = resolve_datatable_connection_unchecked(db, &governing, false).await?;
|
||||
Ok((connection, kind))
|
||||
}
|
||||
|
||||
/// Same as [`get_datatable_resource_from_db_unchecked`] but for postgres trigger
|
||||
@@ -3445,6 +3553,7 @@ mod tests {
|
||||
resource_path: "dt_main".to_string(),
|
||||
}),
|
||||
reference: None,
|
||||
governed_by: None,
|
||||
forked_from: None,
|
||||
migrations_enabled: None,
|
||||
permissions: None,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIISQgIBADANBgkqhkiG9w0BAQEFAASCEiwwghIoAgEAAoIEAQC6deMC6KWwkWV2
|
||||
9xb4bXLt6ceNGQD3gJH3sYqpulWsZB0L3TdqXwnzL3C/8Uf3K8hLZ4Tw+llHCxaS
|
||||
Z7aTEYyeLDJuYuJPVS8DAgVnXDluoDg7B1UtvoUa1/MjLQm4f/Qp322i7l0SH9kx
|
||||
NRb7UZp/xx1tL/kiq9NIv1inK4ZC0DbleybBOoLlwP9Zqhazw3MJdZAyey4FAXTx
|
||||
bZcjG3UtiZ2COXVP1qdtqhLrQQQsicoLqZuxAfXdIU6xgcYgGapVQNTVbo7w8pHn
|
||||
+6RzlPb9rqyBs2IiKLFSb467mtZeM9dfbyD4DICy42O/JKAbTZw/f3trbs+tu+sK
|
||||
rj7ysk+lGzuXj2/irOuvOSbzpxERdZuDyLOhlMej5+aJXkrmVhVPf/3wt/6oKCzo
|
||||
7Ta24OvGaz6pxK1iDOQvzkCqJpaTjSfpcscHyU3ztXCUINtsq7s78XoOxJZkerfq
|
||||
LDxuutYviFiB9pQDzYdErL8XMUkRXXY22hpnLHt8GV+lNCJs2KxcECzkgwfjIn9X
|
||||
Y66U4iO+k9EvY/FHyI25ur0eKWDnJAPL6PypVhx9NOuBkot6nwUugzWzM/MKJ9Hf
|
||||
1n9pwVNlMam8zgMQ5Ivu3+RiOV3P0iuAaw7d1sZRSSX11l/EyjGFDpwmnFNjleEK
|
||||
vnop6rCftWAsd2XJHXiD1/80QIgKJHdflpHOcy6Su5w/CRYc/jC2rJjUr9W+hBwg
|
||||
fnayip8MTMa4cAXzbFmSc5NCzSsqHkVq/4RdfdMephubGmWcPDQnkUf/n/RvoZNi
|
||||
VktW34lX3YIe97600vSWPfWahj/EUnfI8m+4Qzuz2o6N9basCNftKPDIc5ej3PMS
|
||||
RVIgBhEXUh1wVFdEUs8RKHg/QV0xrN2qbKcS8XvV3fFfLuFZo4a66nZ5zgGGJUgL
|
||||
ED41cEqf/sQObfhGgIw25v/315ARiv9thRF3tUMLGQZNQ8tSs2VMuU6szBQciplx
|
||||
y2y4JzXdv+G0PvheE7tVdsGsMipSHTZDdNZo51MOBSgsNTyNmWMZlsfoZTTZ2o2y
|
||||
XtKv8xUP01KnlwR6Z8hY1CAPxM1QKDU5nKp9T/df6GEhohps9gSt6cs6SHlH8piq
|
||||
pyR0xiDvS+vcPN/IbOaRgE9WmfM5py72SNZbetiUHdUTmAC1dd0IH3opte1uVpQQ
|
||||
sauKVWZuzV8Bcd2O06QFOVJDwoxm00YaF0rqhaisppah5eQMN/ctlqWVDHXR8laV
|
||||
CNkzWYE/jxAU9dGuCMCIZ7BSs5U9mZ7pWstagXWEFTI9IugjvNnaZDijZQJSPmAZ
|
||||
gQtAxH/z8R9e/PihW9dDQc/nKBC9Lpta3oFGq74fc7Y+Wu/Uskjf8qtFOOVrvfZx
|
||||
kwlRR0s5AgMBAAECggQAEEeerHBXlxT6wOpN7R7RPAh/TchzFyj4laHAs5T7SfPr
|
||||
vU9xOS5XFg58T6XnXKNL95lwOAIDrXpAXEQF4s1Tc7P0K0Mi8z5CvoZ8pkudEBWZ
|
||||
Ez5Op5HM2U0lg1q5UIyLauSBFAqvwZdaRTuXaBX14VYnMZNQs0BBUEa0nNPmAwte
|
||||
36Zi+df7+cdiBBgfp05XSuoizuWGXRWFelbRLVWGY5VyVR5jKbdaIu8Ie9XPYXwe
|
||||
UepbF5dOOiJgVos4GzFid9MTQhdHD65+Ir0L3LOS5Q9NLH3XAZh/8uyej66zLMJl
|
||||
+YUgYcflrLjByryN5hReogSlZ/ni1A5mNSaFhHchEQ9Roqf5YtcqNMBOsFNT4Cxe
|
||||
iaEKXf85dwwWSsQa0gWOEsTgDRZje5cWZLfhYRI+Q7K1Y+kyIi9x9OKTLsfzuapH
|
||||
Y9CLpBhlX/Plgmlj/xb3YAJzX7q2TsuQJzf5hsl1Te0fnSlEPxjAyLQ0cvfwoz1/
|
||||
xsZAK+XsWid0/F2S6+H3GoqnAMSX+bLXX2/qnEEqUcdiKjsVo+jUxvSQ8oM2XLf+
|
||||
RN1wwfAsyJnIPiXnW6tqrmbyV1Uf0TEs19hIg5lHiLxPT5bFGcJ6nT6bGCBeGnkF
|
||||
J08xmxjXc91pHQ+8iNgGftGJvK1FcppxbZi33dUJ2sfhP8Ck+GxES26eb5d6yTfD
|
||||
oZRQmESvikHPU5shm+YbjPrYhEbEgLZKO3wUhCAvoFLN2TG0i2v//aaCSI/XfNWt
|
||||
1XyE+uN/o6kQEPvaUy/0uHBuzoC4pZ8or22ywmeftCOHuT9YE9LIJuU81qMJvjP7
|
||||
BJ6YkcTO6G89iD0g6HHYRiD9I+pgih8B1NPq4VmFuJrvaqgAX9srmmMt7FXBrcqa
|
||||
JSyLS+WeHTLlOiHN1DqOgpgq0gq6vC1i53xPqSUkFzAKpDOWgGNMVAABFN9EOVmM
|
||||
zvPnJThz5PHDAuud9eaLjZ0V76IVXmRTz22warkdKhBW5BzDzy+zNp8mF5SMH9mZ
|
||||
+JQvCGTc2itiKGKMDzvY99pBzHRlrlFL7n5SHLqxclBfnEJcU9wPvFyZ+6k8MGim
|
||||
0kFrK63NqiceNWp5vdU/6V+boCYoFG8PDbABUxCbb+lhtnYx5AUcKfKX74Uv4gpX
|
||||
AM0gsaY8U+4ATJbgoCTqplTKvXtyw4zcpJAXCMnhygKyDKNOk+Lckfquwx9r/TQN
|
||||
hbbjjvwgBkzJtAlylCVYYEJ/03lazRpGR5osCksECxNU4TMDRD2Hd+zwUxdeRunm
|
||||
7EusRSrKq8swi8LFyV7E8ISwwXegqVarHqXjCHhcn/5Ozw6PkVk39Ug2UE4ibesC
|
||||
MzVP3lfP67gXgGCzYrImIUuqHGTyLFs8/B8+2OmdyQKCAgEA6PCozdeeQS0P/+ah
|
||||
E015KECxB0aGbrvdpczXX5szHYb8KhejvHmVV2OLFisL7GyMeBhEZXAeKtMZwd66
|
||||
FMORgdFACpckR/a/aVEnebHx5fOKtAQfhLLH/3YE5dFIWxu9SKNZBrX0lIBFXfoK
|
||||
lZYM7cGm7vN55AK99bYfCAqFs+HI9sDQLjbaxR41UOfZwPjA9VjBQ1vBr0N3UoOT
|
||||
xf8Rw6hlAd+ED7iJavn6KUb64D5eB4eGSZNXM5HCp2ZtUFN/4bU5lbHPCSK29+Wd
|
||||
mM5flZtIKQ7PzGyXqv4Kbtz9o1hPOQPeH0faDEcYJLHV/LAxHc1AkqJDXU8HANhI
|
||||
m/XI44RlJesNntOdJuCumHtTAsreWAYoydXurOWHkDIpx6OLi0F3eJrOV6dhwCCG
|
||||
v3bX4PIBjYZKyfHukcoCzGetXpu5bDKC1uwwZLJmF/QTO3AeVpWNrbWkBfj/PCio
|
||||
8RodjVlPkDaoCrDmpca22fNo+93IM2m1SWdSnTlXvgg58cLUrlMUbg24Lo1cyySF
|
||||
1AnRw0uvaFLdH8iMtKZIA3zgCZyUKV1DcGUKsdcL/9IYD6EO5bCcA6p5gcxxBOan
|
||||
6MEHIuJKycmZMGutN5rm3uDHQgmNCs25ma3fWQF0s/dRJ2lg947qKUKevqBkd4gt
|
||||
BuB8VXUGsHXGDWHfz/eLjWYA3k8CggIBAMzrTqnmT27U1SXPwEXI0kc6A44D8skD
|
||||
a4rqxOMBTGvu+HeVdEBMcxYnGeJV2QtZY96KXwadiWuqdEr4Pc7JWnAa2f9yWNCb
|
||||
QI0YJSWSNcDuPsSV1K15MM7uxKk/oNcXdbjrAfVLzCmEYDg085QV80rl7Q6WRaYv
|
||||
acvipeZY82LuGhtAJAN6S8VWmDqZ5vBDd61F09AlBrpIFoSBRnYVkEwaXXwFJauz
|
||||
S+kEmCIb97tBf7nr08ldoNvDdecRtm+s9odfUhm+ONroZf7OEyTCOXB666oDogpI
|
||||
hktuliNUVpXeZ5QtkzpxP2SBbHNWZ9LiAGpTv2wa7z7k1PqCVR6ABGMkTkge41iy
|
||||
+v82KYi4qRnUfVlFcn9xLyBDaaCWJYrsux8OmAW48cVN1Og0tKu/T+bFUvHs0etZ
|
||||
oijYoYZaf9ERO0X0tuMXKIuT0QcPnkcT+KXFY6uzOC1UmF5ITCeKogGwzkFbvInA
|
||||
w8OiFYBkgXZ41tE/oOARm1AhuaNc/ZfWujLECjuJgvw0uhdUyihGw8ZJv91WiIjX
|
||||
Jqq1BNrhvDQqTPSnDvhcBBDGdQA6HJRGAerIx7PgCKzS2WbrfmsFzjfZ3zbrUSNf
|
||||
e8CM37aDzte25g7HmvFuwOLmE9DXNnE+cWGpb/S1l4ACahnqKF9zj3uKVpN1kG+l
|
||||
xFH+ADFgNCP3AoICAQDW+fuf9QVdezwP3IX9bXuKVaHLaB/7JDBbqt99oZsb/g7M
|
||||
vgJjmAXmn7ymGdYoPMmqsCK3RzDgLvDPhkFPlmm8WCR1YaWfMK7YgIgg/MOViB0T
|
||||
I9b+vYxsTDwa6PmQ89nNpssrsVNmlbbvEysfgFqwNg9c6aEteAZeNNUQs71PFOpG
|
||||
KA45CHEAiG+GGmNRf4F9LIs4If9vQ6Fkqn//s/ZuDwQhJWoYpCNWchuYaqRvvb9D
|
||||
zaR2VJ+evT/bBGGQR293G6xoZa//62TXBbdbZeK7DJMdKUlHjVSv/0IbrKH8VSzQ
|
||||
HqypS8OFnzs96k3lpam+qkWUzmHDRILfGbxxCvV6XB6JTxPD5+JaFfOsT+x9YEKx
|
||||
FAYit+hM8owY3OLIFzxaqXGcAaq7JIPHL32MSB7VwCMEvNJa64dUTw4kHO7YqHkI
|
||||
zNkJhvLmi+pC4wO40DF/kQgvL7qPtIvBtnisAoERiUIvgvG05CwJBIqOPOE/yR/R
|
||||
qxYfB1csONasT6VCZJ+pvozVQJ4hgF8KL8u+s80+IqRgcFEY2xpYQjml1mJhHHt0
|
||||
bVkm6mljVWcCkp+YFPjNrB9gFr2Kt+X6MvD1kueGHzgMYJybHlS/Q5scSHA8ltnv
|
||||
BYvrs5az6u8MhAwGhm9yKRgcOhqNLTNrSWbcuOKmb5lb02WTxicrJHdLaL8WawKC
|
||||
AgAvXHdK51MWj5RP7+tFdbu8AJmPujPj56l0avxkZwltrMa6hM1giwrSZOkjmanX
|
||||
aKqfZaW+1qxLORM1vJg0ce84F8yosJn8ZNWPhrQXMerAk4/oSaTlDyyLtz22zGD1
|
||||
NZPc0FwcgI9ITTMb6okdgdFAT1Hx3CNu3Gh8Wf7z9T0TcWqRP8rTDvHyQyhd6vIo
|
||||
N7+ySYBCDDy40y55I5PQeP1nyXQv1NS3EXKLAafTwlkIa4QEyY+ia8Rg5pUaecqL
|
||||
lxNHiG1mS8PCPJJOSPy9szIpYA+VtM8/xzFav81tjvGQI94Wrk3n5QK8zPju01qd
|
||||
r+RersqARt1i0Oq0l2jq6rWY4GxDWPg8qOlAERmaYuuab7Uk69shv1wf/dSs6+bz
|
||||
a/F3pRzXvb4LVwjNPlk/lLD36kVYat69oXLMM/1pGwONo4jqizu10sVpNRnLov12
|
||||
+hOu5Kc7qOlj+LhamiUXwn/dGishif1SbT4RuiV0DkapxhYIOMeK4ibeZ10Ld9OY
|
||||
w2U3lH208vxWJC2adNtgYbKf4q24j2ZxEiD6tWnQ2PlxqREuPmpEVqgN5IBoNAYk
|
||||
8uVAiXU2iqIWp4jds7H9NFNML0va2ppfLZNhlm2iNmaZDsmvQsz53howlvKfgw4v
|
||||
MDpuUwXwLgQ87tHs+WSpYwEXkYqe/D4Mv+hW9vi+cLi8UwKCAgAsehP6DOrt9bA3
|
||||
pVdtHJG1a9fm/rXC9TQS2oSE2paXRZpxhbk917Gdl2njo7tnoGXXBpQOb51/HUXX
|
||||
QTARdp2LP1VFn9RJP3CwaK+Sxs1IBa9r1/p2ePDR85QR1HxN+Gj98EcEfKl1RSe6
|
||||
RmLOkLIg4pkNrDhwWyYJ1H5//d/yUQlZ015fQAKaHCdaoz7L3+0jOyFXDGrDr+Gc
|
||||
680SyVqPfRx5dAm8TrfyXW6Q2uZEhIqxbcxTdG98Pxi0qLLAS/nD16IHC1iljHsI
|
||||
QrfUvFvGg5v7kxeXrP0VPMRM0/Y8vno3Bbi04I0Pk3Wko8UhWuiUMjs+TVbEPKox
|
||||
8zG/G+R5EpHKDxNyfUUwZ9+lyjFmBZOcVd3dze23VQYCzV/W04yiQdEl2C/sED2m
|
||||
NycJ3GN/ByqpJJZY3u+B/WAiHPLe1ivIEeIjwdBt2w/3L/rKoRBR/P8LSRXLIIh4
|
||||
Xk1BMNygJxHTwsvFGhflx89+1BO/n8HrR0s/2G1QanxGdgso9bwmXMDrmJ+XxrUY
|
||||
qBmDCU8RkBdUHKOuJlWWn2JaIswsEE5kAk7MgNNaBTv91VBYCDs50NW8VBaKnJjN
|
||||
j+hXI80qKi7WqeoonFKFLDgUYbKnmzKsX+T0x/H99v+7Or5PmAPN+hRwj54/LEVR
|
||||
MsjrTlnoCGXKgPQQ5ItWviGjqoCTiQ==
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,25 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIEIjANBgkqhkiG9w0BAQEFAAOCBA8AMIIECgKCBAEAunXjAuilsJFldvcW+G1y
|
||||
7enHjRkA94CR97GKqbpVrGQdC903al8J8y9wv/FH9yvIS2eE8PpZRwsWkme2kxGM
|
||||
niwybmLiT1UvAwIFZ1w5bqA4OwdVLb6FGtfzIy0JuH/0Kd9tou5dEh/ZMTUW+1Ga
|
||||
f8cdbS/5IqvTSL9YpyuGQtA25XsmwTqC5cD/WaoWs8NzCXWQMnsuBQF08W2XIxt1
|
||||
LYmdgjl1T9anbaoS60EELInKC6mbsQH13SFOsYHGIBmqVUDU1W6O8PKR5/ukc5T2
|
||||
/a6sgbNiIiixUm+Ou5rWXjPXX28g+AyAsuNjvySgG02cP397a27PrbvrCq4+8rJP
|
||||
pRs7l49v4qzrrzkm86cREXWbg8izoZTHo+fmiV5K5lYVT3/98Lf+qCgs6O02tuDr
|
||||
xms+qcStYgzkL85AqiaWk40n6XLHB8lN87VwlCDbbKu7O/F6DsSWZHq36iw8brrW
|
||||
L4hYgfaUA82HRKy/FzFJEV12NtoaZyx7fBlfpTQibNisXBAs5IMH4yJ/V2OulOIj
|
||||
vpPRL2PxR8iNubq9Hilg5yQDy+j8qVYcfTTrgZKLep8FLoM1szPzCifR39Z/acFT
|
||||
ZTGpvM4DEOSL7t/kYjldz9IrgGsO3dbGUUkl9dZfxMoxhQ6cJpxTY5XhCr56Keqw
|
||||
n7VgLHdlyR14g9f/NECICiR3X5aRznMukrucPwkWHP4wtqyY1K/VvoQcIH52soqf
|
||||
DEzGuHAF82xZknOTQs0rKh5Fav+EXX3THqYbmxplnDw0J5FH/5/0b6GTYlZLVt+J
|
||||
V92CHve+tNL0lj31moY/xFJ3yPJvuEM7s9qOjfW2rAjX7SjwyHOXo9zzEkVSIAYR
|
||||
F1IdcFRXRFLPESh4P0FdMazdqmynEvF71d3xXy7hWaOGuup2ec4BhiVICxA+NXBK
|
||||
n/7EDm34RoCMNub/99eQEYr/bYURd7VDCxkGTUPLUrNlTLlOrMwUHIqZcctsuCc1
|
||||
3b/htD74XhO7VXbBrDIqUh02Q3TWaOdTDgUoLDU8jZljGZbH6GU02dqNsl7Sr/MV
|
||||
D9NSp5cEemfIWNQgD8TNUCg1OZyqfU/3X+hhIaIabPYErenLOkh5R/KYqqckdMYg
|
||||
70vr3DzfyGzmkYBPVpnzOacu9kjWW3rYlB3VE5gAtXXdCB96KbXtblaUELGrilVm
|
||||
bs1fAXHdjtOkBTlSQ8KMZtNGGhdK6oWorKaWoeXkDDf3LZallQx10fJWlQjZM1mB
|
||||
P48QFPXRrgjAiGewUrOVPZme6VrLWoF1hBUyPSLoI7zZ2mQ4o2UCUj5gGYELQMR/
|
||||
8/EfXvz4oVvXQ0HP5ygQvS6bWt6BRqu+H3O2Plrv1LJI3/KrRTjla732cZMJUUdL
|
||||
OQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -239,6 +239,7 @@ async function createWorkspaceFork(
|
||||
interface ForkedDatatableInfo {
|
||||
name: string;
|
||||
new_dbname: string;
|
||||
fork_behavior?: "schema_only" | "schema_and_data";
|
||||
}
|
||||
const forkedDatatables: ForkedDatatableInfo[] = [];
|
||||
|
||||
@@ -285,6 +286,23 @@ async function createWorkspaceFork(
|
||||
|
||||
const newDbName = `${trueWorkspaceId.replace(/-/g, "_")}__${dt.name}`;
|
||||
|
||||
const forkBehavior = dtBehavior as "schema_only" | "schema_and_data";
|
||||
// A server that reports `permissioned` copies each data table in the fork request itself,
|
||||
// and refuses a database copied beforehand. An older one only takes a database copied here.
|
||||
if (typeof dt.permissioned === "boolean") {
|
||||
log.info(
|
||||
colors.blue(
|
||||
` Datatable "${dt.name}" will be cloned (${forkBehavior === "schema_only" ? "schema" : "schema + data"}) into "${newDbName}" when the fork is created.`
|
||||
)
|
||||
);
|
||||
forkedDatatables.push({
|
||||
name: dt.name,
|
||||
new_dbname: newDbName,
|
||||
fork_behavior: forkBehavior,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info(
|
||||
colors.blue(` Creating database "${newDbName}" for datatable "${dt.name}"...`)
|
||||
@@ -300,7 +318,7 @@ async function createWorkspaceFork(
|
||||
|
||||
log.info(
|
||||
colors.blue(
|
||||
` Importing ${dtBehavior === "schema_only" ? "schema" : "schema + data"}...`
|
||||
` Importing ${forkBehavior === "schema_only" ? "schema" : "schema + data"}...`
|
||||
)
|
||||
);
|
||||
|
||||
@@ -310,7 +328,7 @@ async function createWorkspaceFork(
|
||||
source: `datatable://${dt.name}`,
|
||||
target: `datatable://${dt.name}`,
|
||||
target_dbname_override: newDbName,
|
||||
fork_behavior: dtBehavior as "schema_only" | "schema_and_data",
|
||||
fork_behavior: forkBehavior,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -336,6 +354,8 @@ async function createWorkspaceFork(
|
||||
id: trueWorkspaceId,
|
||||
name: opts.createWorkspaceName ?? workspaceName ?? trueWorkspaceId,
|
||||
color: forkColor,
|
||||
// So a clone the fork would refuse is refused before any branch is created.
|
||||
forked_datatables: forkedDatatables,
|
||||
},
|
||||
});
|
||||
if (gitSyncJobIds && gitSyncJobIds.length > 0) {
|
||||
|
||||
Generated
+12
-37
@@ -19,8 +19,10 @@
|
||||
"@codingame/monaco-vscode-standalone-json-language-features": "=25.0.0",
|
||||
"@codingame/monaco-vscode-standalone-languages": "=25.0.0",
|
||||
"@codingame/monaco-vscode-standalone-typescript-language-features": "=25.0.0",
|
||||
"@floating-ui/core": "^1.3.1",
|
||||
"@json2csv/plainjs": "^7.0.6",
|
||||
"@leeoniya/ufuzzy": "^1.0.8",
|
||||
"@melt-ui/svelte": "^0.86.2",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@redocly/json-to-json-schema": "^0.0.1",
|
||||
"@scalar/openapi-parser": "^0.15.0",
|
||||
@@ -29,6 +31,7 @@
|
||||
"@windmill-labs/svelte-dnd-action": "^0.9.44",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xyflow/svelte": "^1.0.0",
|
||||
"@zerodevx/svelte-toast": "^0.9.6",
|
||||
"acorn": "^8.15.0",
|
||||
"ag-charts-community": "^9.0.1",
|
||||
"ag-charts-enterprise": "^9.0.1",
|
||||
@@ -39,6 +42,7 @@
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-zoom": "^2.0.0",
|
||||
"clone": "^2.1.2",
|
||||
"d3-dag": "^0.11.5",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"diff": "^8.0.3",
|
||||
@@ -73,9 +77,17 @@
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rfc4648": "^1.5.3",
|
||||
"runed": "^0.36.0",
|
||||
"style-to-object": "^0.4.1",
|
||||
"svelte": "^5.55.7",
|
||||
"svelte-awesome-color-picker": "^3.0.4",
|
||||
"svelte-carousel": "^1.0.25",
|
||||
"svelte-exmarkdown": "^5.0.0",
|
||||
"svelte-floating-ui": "^1.5.8",
|
||||
"svelte-highlight": "^7.6.0",
|
||||
"svelte-infinite-loading": "^1.4.0",
|
||||
"svelte-popperjs": "^1.3.2",
|
||||
"svelte-range-slider-pips": "^2.3.1",
|
||||
"svelte-splitpanes": "^8.0.9",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0",
|
||||
@@ -107,10 +119,8 @@
|
||||
"zod": "^4.1.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@floating-ui/core": "^1.3.1",
|
||||
"@hey-api/openapi-ts": "^0.43.0",
|
||||
"@melt-ui/pp": "^0.3.2",
|
||||
"@melt-ui/svelte": "^0.86.2",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.69.1",
|
||||
@@ -124,10 +134,8 @@
|
||||
"@types/vscode": "^1.83.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.8",
|
||||
"@typescript-eslint/parser": "^5.60.0",
|
||||
"@zerodevx/svelte-toast": "^0.9.6",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"cssnano": "^6.0.1",
|
||||
"d3-dag": "^0.11.5",
|
||||
"dts-bundle-generator": "^9.5.1",
|
||||
"eslint": "^8.47.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
@@ -141,18 +149,10 @@
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"prettier": "^3.1.0",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"style-to-object": "^0.4.1",
|
||||
"stylelint-config-recommended": "^13.0.0",
|
||||
"svelte": "^5.55.7",
|
||||
"svelte-awesome-color-picker": "^3.0.4",
|
||||
"svelte-check": "^4.4.3",
|
||||
"svelte-fast-check": "^0.4.5",
|
||||
"svelte-floating-ui": "^1.5.8",
|
||||
"svelte-highlight": "^7.6.0",
|
||||
"svelte-popperjs": "^1.3.2",
|
||||
"svelte-preprocess": "^6.0.0",
|
||||
"svelte-range-slider-pips": "^2.3.1",
|
||||
"svelte-splitpanes": "^8.0.9",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tar": "^7.5.18",
|
||||
"tslib": "^2.6.1",
|
||||
@@ -1219,7 +1219,6 @@
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
@@ -1229,7 +1228,6 @@
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
@@ -1240,7 +1238,6 @@
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@glideapps/ts-necessities": {
|
||||
@@ -1344,7 +1341,6 @@
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
|
||||
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
@@ -1576,7 +1572,6 @@
|
||||
"version": "0.86.6",
|
||||
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.86.6.tgz",
|
||||
"integrity": "sha512-Jer+M7DgIwT5IHfTayb4Iw/fkkxWNmC/mqn/nMh9JrbPbkxmyabfLQnhJ+JDn5HK77f84j34lubO3iqFtYAfMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.3.1",
|
||||
@@ -2160,7 +2155,6 @@
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
@@ -3146,7 +3140,6 @@
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@zerodevx/svelte-toast/-/svelte-toast-0.9.6.tgz",
|
||||
"integrity": "sha512-nHlTrCjverlPK9yukK6fqbG3e/R+f10ldrc4nJHOe2qNDScuPTuYVSFEk2dDDtzWAwTN5pmdEXgA3M2RbT8jiw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.57.0 || ^4.0.0 || ^5.0.0"
|
||||
@@ -4134,7 +4127,6 @@
|
||||
"version": "2.9.3",
|
||||
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
|
||||
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
@@ -4660,7 +4652,6 @@
|
||||
"version": "0.11.5",
|
||||
"resolved": "https://registry.npmjs.org/d3-dag/-/d3-dag-0.11.5.tgz",
|
||||
"integrity": "sha512-sNHvYqjzDlvV2fyEkoOCSuLs2GeWliIg7pJcAiKXgtUSxl0kIX0C2q1J8JzzA9CQWptKxYtzxFCXiKptTW8qsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.1.6",
|
||||
@@ -5986,7 +5977,6 @@
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esm-env-robust/-/esm-env-robust-0.0.3.tgz",
|
||||
"integrity": "sha512-90Gnuw2DALOqlL1581VxP3GHPUNHX9U+fQ+8FNcTTFClhY5gEggAAnJ3q1b2Oq23knRsjv8YpNeMRPaMLUymOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esm-env": "^1.0.0"
|
||||
@@ -6272,7 +6262,6 @@
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/fastpriorityqueue/-/fastpriorityqueue-0.7.2.tgz",
|
||||
"integrity": "sha512-5DtIKh6vtOmEGkYdEPNNb+mxeYCnBiKbK3s4gq52l6cX8I5QaTDWWw0Wx/iYo80fVOblSycHu1/iJeqeNxG8Jw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
@@ -6354,7 +6343,6 @@
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.5.tgz",
|
||||
"integrity": "sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tabbable": "^6.2.0"
|
||||
@@ -7223,7 +7211,6 @@
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
|
||||
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
@@ -7411,7 +7398,6 @@
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/javascript-lp-solver/-/javascript-lp-solver-0.4.24.tgz",
|
||||
"integrity": "sha512-5edoDKnMrt/u3M6GnZKDDIPxOyFOg+WrwDv8mjNiMC2DePhy2H9/FFQgf4ggywaXT1utvkxusJcjQUER72cZmA==",
|
||||
"dev": true,
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
@@ -9767,7 +9753,6 @@
|
||||
"version": "5.1.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
|
||||
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -11409,7 +11394,6 @@
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/quadprog/-/quadprog-1.6.1.tgz",
|
||||
"integrity": "sha512-fN5Jkcjlln/b3pJkseDKREf89JkKIyu6cKIVXisgL6ocKPQ0yTp9n6NZUAq3otEPPw78WZMG9K0o9WsfKyMWJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.x"
|
||||
@@ -12562,7 +12546,6 @@
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz",
|
||||
"integrity": "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inline-style-parser": "0.1.1"
|
||||
@@ -12936,7 +12919,6 @@
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte-awesome-color-picker/-/svelte-awesome-color-picker-3.1.4.tgz",
|
||||
"integrity": "sha512-tiFakxvSpCwodOSFW6CflnLVSldSlDY77rDipHfW7hvB+4gTP3y6uO+Dm9O9e7P5N7K+CNyNjjdjbZOHYkMyNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"colord": "^2.9.3",
|
||||
@@ -12950,7 +12932,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/svelte-awesome-slider/-/svelte-awesome-slider-1.1.2.tgz",
|
||||
"integrity": "sha512-HFIWwq6rtX6aXyc6ns2R0P6pDLYIr6nRjTPyAddAiTAgZqsohAoEfOEwKKqMmZ6Q+CH48zzcFdyo6lFkBJvyQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.54.0 || ^4.0.0 || ^5.0.0"
|
||||
@@ -13123,7 +13104,6 @@
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/svelte-floating-ui/-/svelte-floating-ui-1.6.2.tgz",
|
||||
"integrity": "sha512-EC+DZtBey50P6l3NSzNQWon3cip8a1bzwdpmCdc45kymqEWL4BKhPemAq7SQ9QLebDPaMECW6YodxFbs2d+O/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13"
|
||||
@@ -13133,7 +13113,6 @@
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte-highlight/-/svelte-highlight-7.8.4.tgz",
|
||||
"integrity": "sha512-aVp+Q0hH9kI7PlSDrklmFTF4Uj7wYj7UGuqkREnkXlqpEffxr2g6esZcMMaTgECdg5rb2mJqM88+nwS10ecoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"highlight.js": "11.11.1"
|
||||
@@ -13149,7 +13128,6 @@
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/svelte-popperjs/-/svelte-popperjs-1.3.2.tgz",
|
||||
"integrity": "sha512-fwrErlkvngL876WXRnL3OLlfk/n9YkZwwLxuKRpZOYCJLt1zrwhoKTXS+/sRgDveD/zd6GQ35hV89EOip+NBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@popperjs/core": ">=2",
|
||||
@@ -13216,14 +13194,12 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte-range-slider-pips/-/svelte-range-slider-pips-2.3.1.tgz",
|
||||
"integrity": "sha512-P29PNqHld+SiaDuHzf98rLvhSYWXb3TVL9p7U5RG9UX8emUgypZgp9zuIIwpmIXysGQC6JG8duMc5FuaPnSVdg==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
"node_modules/svelte-splitpanes": {
|
||||
"version": "8.0.9",
|
||||
"resolved": "https://registry.npmjs.org/svelte-splitpanes/-/svelte-splitpanes-8.0.9.tgz",
|
||||
"integrity": "sha512-L3oLXTC99M191FInTXJ/f/2i0welRql1QuVbPaU8iy6nvCR6X9VyjHCsCpLqKGWHwqkWo/AM9CQ1c0nzlb+MkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esm-env-robust": "0.0.3"
|
||||
@@ -13333,7 +13309,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/table": {
|
||||
|
||||
+12
-12
@@ -22,10 +22,8 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@floating-ui/core": "^1.3.1",
|
||||
"@hey-api/openapi-ts": "^0.43.0",
|
||||
"@melt-ui/pp": "^0.3.2",
|
||||
"@melt-ui/svelte": "^0.86.2",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.69.1",
|
||||
@@ -39,10 +37,8 @@
|
||||
"@types/vscode": "^1.83.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.8",
|
||||
"@typescript-eslint/parser": "^5.60.0",
|
||||
"@zerodevx/svelte-toast": "^0.9.6",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"cssnano": "^6.0.1",
|
||||
"d3-dag": "^0.11.5",
|
||||
"dts-bundle-generator": "^9.5.1",
|
||||
"eslint": "^8.47.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
@@ -56,18 +52,10 @@
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"prettier": "^3.1.0",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"style-to-object": "^0.4.1",
|
||||
"stylelint-config-recommended": "^13.0.0",
|
||||
"svelte": "^5.55.7",
|
||||
"svelte-awesome-color-picker": "^3.0.4",
|
||||
"svelte-check": "^4.4.3",
|
||||
"svelte-fast-check": "^0.4.5",
|
||||
"svelte-floating-ui": "^1.5.8",
|
||||
"svelte-highlight": "^7.6.0",
|
||||
"svelte-popperjs": "^1.3.2",
|
||||
"svelte-preprocess": "^6.0.0",
|
||||
"svelte-range-slider-pips": "^2.3.1",
|
||||
"svelte-splitpanes": "^8.0.9",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tar": "^7.5.18",
|
||||
"tslib": "^2.6.1",
|
||||
@@ -97,8 +85,10 @@
|
||||
"@codingame/monaco-vscode-standalone-json-language-features": "=25.0.0",
|
||||
"@codingame/monaco-vscode-standalone-languages": "=25.0.0",
|
||||
"@codingame/monaco-vscode-standalone-typescript-language-features": "=25.0.0",
|
||||
"@floating-ui/core": "^1.3.1",
|
||||
"@json2csv/plainjs": "^7.0.6",
|
||||
"@leeoniya/ufuzzy": "^1.0.8",
|
||||
"@melt-ui/svelte": "^0.86.2",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@redocly/json-to-json-schema": "^0.0.1",
|
||||
"@scalar/openapi-parser": "^0.15.0",
|
||||
@@ -107,6 +97,7 @@
|
||||
"@windmill-labs/svelte-dnd-action": "^0.9.44",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xyflow/svelte": "^1.0.0",
|
||||
"@zerodevx/svelte-toast": "^0.9.6",
|
||||
"acorn": "^8.15.0",
|
||||
"ag-charts-community": "^9.0.1",
|
||||
"ag-charts-enterprise": "^9.0.1",
|
||||
@@ -117,6 +108,7 @@
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-zoom": "^2.0.0",
|
||||
"clone": "^2.1.2",
|
||||
"d3-dag": "^0.11.5",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"diff": "^8.0.3",
|
||||
@@ -151,9 +143,17 @@
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rfc4648": "^1.5.3",
|
||||
"runed": "^0.36.0",
|
||||
"style-to-object": "^0.4.1",
|
||||
"svelte": "^5.55.7",
|
||||
"svelte-awesome-color-picker": "^3.0.4",
|
||||
"svelte-carousel": "^1.0.25",
|
||||
"svelte-exmarkdown": "^5.0.0",
|
||||
"svelte-floating-ui": "^1.5.8",
|
||||
"svelte-highlight": "^7.6.0",
|
||||
"svelte-infinite-loading": "^1.4.0",
|
||||
"svelte-popperjs": "^1.3.2",
|
||||
"svelte-range-slider-pips": "^2.3.1",
|
||||
"svelte-splitpanes": "^8.0.9",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0",
|
||||
|
||||
@@ -51,6 +51,8 @@ export const copilotInfo = writable<{
|
||||
aiModels: AIProviderModel[]
|
||||
customPrompts?: Record<string, string>
|
||||
maxTokensPerModel?: Record<string, number>
|
||||
/** Context windows per `provider:model`, overriding the built-in window table. */
|
||||
contextWindowPerModel?: Record<string, number>
|
||||
/** Negotiated rates per `provider:model`, overriding the built-in price table. */
|
||||
modelPricing?: Record<string, ModelPriceOverride>
|
||||
webSearchEnabledProviders?: Partial<Record<AIProvider, boolean>>
|
||||
@@ -67,6 +69,7 @@ export const copilotInfo = writable<{
|
||||
aiModels: [],
|
||||
customPrompts: {},
|
||||
maxTokensPerModel: {},
|
||||
contextWindowPerModel: {},
|
||||
modelPricing: {},
|
||||
webSearchEnabledProviders: {}
|
||||
})
|
||||
@@ -144,6 +147,7 @@ export function setCopilotInfo(aiConfig: AIConfig) {
|
||||
aiModels: aiModels,
|
||||
customPrompts: aiConfig.custom_prompts ?? {},
|
||||
maxTokensPerModel: aiConfig.max_tokens_per_model ?? {},
|
||||
contextWindowPerModel: aiConfig.context_window_per_model ?? {},
|
||||
webSearchEnabledProviders,
|
||||
modelPricing: aiConfig.model_pricing ?? {},
|
||||
freeTier: aiConfig.free_tier
|
||||
@@ -160,6 +164,7 @@ export function setCopilotInfo(aiConfig: AIConfig) {
|
||||
aiModels: [],
|
||||
customPrompts: {},
|
||||
maxTokensPerModel: {},
|
||||
contextWindowPerModel: {},
|
||||
webSearchEnabledProviders: {},
|
||||
modelPricing: {},
|
||||
// An exhausted free grant lands here — no providers, but the reason AI is off
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
if (!workspace || !datatable) return undefined
|
||||
try {
|
||||
return {
|
||||
workspace,
|
||||
datatable,
|
||||
...(await listUsableDatatableRoles(workspace, datatable))
|
||||
}
|
||||
@@ -65,16 +66,24 @@
|
||||
// Never leave the drawer waiting on this: fall back to the
|
||||
// unpermissioned shape so it opens and the server picks the role.
|
||||
console.error('Failed to load datatable roles:', e)
|
||||
return { datatable, permissioned: false, roles: [], default_role: ADMIN_DATATABLE_ROLE }
|
||||
return {
|
||||
workspace,
|
||||
datatable,
|
||||
permissioned: false,
|
||||
roles: [],
|
||||
default_role: ADMIN_DATATABLE_ROLE
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// A resource keeps its previous value while refetching, and roles are per data
|
||||
// table: settling from the last one's answer would connect to the new data
|
||||
// table as a role it may not even have.
|
||||
// A resource keeps its previous value while refetching, and roles are per data table of one
|
||||
// workspace: settling from another answer would connect to this data table as a role it may
|
||||
// not even have.
|
||||
const rolesOfCurrent = $derived(
|
||||
usableRoles.current?.datatable === selectedDatatable ? usableRoles.current : undefined
|
||||
usableRoles.current?.datatable === selectedDatatable && usableRoles.current?.workspace === ws
|
||||
? usableRoles.current
|
||||
: undefined
|
||||
)
|
||||
|
||||
// Nothing that connects runs until the role is settled: a first round sent without a role
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
|
||||
interface Props {
|
||||
/** Take the draft the server holds, replacing what is on screen. */
|
||||
onReload: () => void
|
||||
/** Write what is on screen over the server's draft. */
|
||||
onOverwrite: () => void
|
||||
/** A resolution is in flight: neither answer is offered again until it settles. */
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
let { onReload, onOverwrite, busy = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
<Alert type="warning" title="Your draft changed elsewhere">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div>
|
||||
It was saved from another tab or session since this one read it, so your changes here are no
|
||||
longer being saved.
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button unifiedSize="sm" variant="default" disabled={busy} onClick={onReload}>
|
||||
Load the other version
|
||||
</Button>
|
||||
<Button unifiedSize="sm" variant="default" disabled={busy} onClick={onOverwrite}>
|
||||
Keep mine
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
@@ -1094,9 +1094,10 @@
|
||||
the home page’s create menu and hub-project picker are opened and from which entry
|
||||
point, the name of any public hub project imported from the home page and how far that
|
||||
import got, whether a pre-approved trial offer was opened, whether data tables are put
|
||||
under roles and whether callers name a role or take the default, and which kinds of
|
||||
access change (grant, revoke, ownership, default privileges) are applied to data
|
||||
tables, last 30 days)</li
|
||||
under roles and whether callers name a role or take the default, which kinds of access
|
||||
change (grant, revoke, ownership, default privileges) are applied to data tables, and
|
||||
whether a data table under roles is cloned into a fork with its schema only or with
|
||||
its data, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
@@ -1163,9 +1164,10 @@
|
||||
the home page’s create menu and hub-project picker are opened and from which entry
|
||||
point, the name of any public hub project imported from the home page and how far that
|
||||
import got, whether a pre-approved trial offer was opened, whether data tables are put
|
||||
under roles and whether callers name a role or take the default, and which kinds of
|
||||
access change (grant, revoke, ownership, default privileges) are applied to data
|
||||
tables, last 30 days)</li
|
||||
under roles and whether callers name a role or take the default, which kinds of access
|
||||
change (grant, revoke, ownership, default privileges) are applied to data tables, and
|
||||
whether a data table under roles is cloned into a fork with its schema only or with
|
||||
its data, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { useDraftConflictSession } from '$lib/draftConflictSession.svelte'
|
||||
import DraftConflictAlert from './DraftConflictAlert.svelte'
|
||||
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { onUserInput } from '$lib/userDraftEditGate'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
@@ -48,6 +50,9 @@
|
||||
* so it can hide the banner's Discard button in read-only mode (matches
|
||||
* the trigger editors' `disabled={!can_write}` wiring). */
|
||||
onCanWriteChange?: (canWrite: boolean) => void
|
||||
/** The drawer renders the conflict alert: inside this scrollable form it can land above the
|
||||
* viewport on a long resource, leaving the fixed banner claiming the edits are saved. */
|
||||
onDraftConflictChange?: (state: { conflicted: boolean; busy: boolean }) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -61,7 +66,8 @@
|
||||
selected: selectedProp = $bindable(),
|
||||
viewJsonSchema = $bindable(),
|
||||
onDraftStateChange,
|
||||
onCanWriteChange
|
||||
onCanWriteChange,
|
||||
onDraftConflictChange
|
||||
}: Props = $props()
|
||||
|
||||
type ResourceState = {
|
||||
@@ -263,6 +269,132 @@
|
||||
)
|
||||
const anyDirty = $derived(dirtyWorkspaces.length > 0)
|
||||
|
||||
/** Scoped by `selected` so switching workspace-specific versions is a new session. Ending is
|
||||
* exported on top of that because the drawer, not this component, knows when its own session
|
||||
* is over: this component outlives it. */
|
||||
const conflictSession = useDraftConflictSession(() => selected)
|
||||
export function endEditingSession(): void {
|
||||
conflictSession.end()
|
||||
}
|
||||
onDestroy(endEditingSession)
|
||||
const resolvingConflict = $derived(conflictSession.busy)
|
||||
/** The server refused this tab's autosave because the row moved under it: another tab, or the
|
||||
* AI chat, which writes these drafts too. Nothing typed here reaches the server until the user
|
||||
* picks a version, and the unsaved-changes banner says the opposite — that the edits are held
|
||||
* as a draft — so without this they are told their work is safe while it is being dropped. */
|
||||
const draftConflict = $derived(
|
||||
selected && initialPath
|
||||
? UserDraftDbSyncer.getConflict({
|
||||
workspace: selected,
|
||||
itemKind: 'resource',
|
||||
path: initialPath
|
||||
}).conflict
|
||||
: undefined
|
||||
)
|
||||
|
||||
async function resolveDraftConflict(keepMine: boolean): Promise<void> {
|
||||
const ws = selected
|
||||
const p = initialPath
|
||||
if (!ws || !p || resolvingConflict) return
|
||||
const query = { workspace: ws, itemKind: 'resource' as const, path: p }
|
||||
const token = conflictSession.start()
|
||||
const stillOurs = () => conflictSession.holds(token) && selected === ws
|
||||
try {
|
||||
if (keepMine) {
|
||||
// Settle the key first: an ordinary autosave still queued would displace the forced
|
||||
// write below, and being conditional it would be refused — so "Keep mine" would
|
||||
// finish without keeping anything and leave the alert standing.
|
||||
await UserDraftDbSyncer.quiesce(query)
|
||||
// A resolution belongs to the session that started it. One that outlives its editor
|
||||
// stops here rather than writing on: whatever replaced it — another session on the
|
||||
// same draft, or its own resolution — owns the key now, and the edit this one was
|
||||
// keeping is still parked for a later flush either way.
|
||||
if (!stillOurs()) return
|
||||
// A parked `null` is this tab's "no draft any more" — a discard, or an edit that
|
||||
// landed back on the deployed value. Keeping that means removing the row, not
|
||||
// writing the baseline back as a draft with no dirty banner to discard it through.
|
||||
const parked = UserDraftDbSyncer.peekPending(query)
|
||||
const mine = parked?.value === null ? null : $state.snapshot(states[ws]?.draft)
|
||||
// Forced, so it goes over the row that refused us, and its response reseeds
|
||||
// `last_sync` so the next ordinary save is conditional again.
|
||||
if (mine !== undefined) await UserDraftDbSyncer.overwrite({ ...query, value: mine })
|
||||
// Say so rather than leave the alert up with no explanation: a write displaced by
|
||||
// something typed meanwhile can still lose the race.
|
||||
if (UserDraftDbSyncer.getConflict(query).conflict) {
|
||||
sendUserToast('Could not keep your version — try again', true)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Settle the key BEFORE reading, so what comes back is the version the server is left
|
||||
// holding: a write this tab started can still be in flight — a forced one it walked
|
||||
// away from included — and a response fetched past it describes a version about to be
|
||||
// replaced, which would then be seeded along with its already-stale `last_sync`.
|
||||
// Nothing is given up by waiting; `quiesce` only stops the pipeline.
|
||||
await UserDraftDbSyncer.quiesce(query)
|
||||
if (!stillOurs()) return
|
||||
// Read BEFORE giving anything up: until the server has answered, the refused payload is
|
||||
// still the only copy of this tab's edit, and the conflict is still true.
|
||||
const r = await ResourceService.getResource({ workspace: ws, path: p, getDraft: true })
|
||||
const deployedState: ResourceState = {
|
||||
path: r.path,
|
||||
args: (r.value ?? {}) as Record<string, any>,
|
||||
description: r.description ?? '',
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false
|
||||
}
|
||||
// Everything below writes shared editor state, so first make sure it is still this
|
||||
// resource's: the drawer stays closable while the read is out, and another resource
|
||||
// opened meanwhile would otherwise get this one's baseline — and with it this one's
|
||||
// path as its save target.
|
||||
if (!stillOurs()) return
|
||||
// Again, because the form stayed editable while the read was out: `quiesce` settles what
|
||||
// is running when it is called, not the key for the rest of the resolution, so a
|
||||
// keystroke since can have started a save of its own. Left running, its rejection lands
|
||||
// after the baseline below and raises the conflict this just resolved.
|
||||
await UserDraftDbSyncer.quiesce(query)
|
||||
if (!stillOurs()) return
|
||||
// Now, and not in `quiesce`: the refused payload belongs to the version being replaced,
|
||||
// but until this point it was still the only copy of the edit, and a resolution that
|
||||
// gave up before here has to leave it behind.
|
||||
UserDraftDbSyncer.dropPending(query)
|
||||
UserDraftDbSyncer.clearConflict(query)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
// Everything else the load path takes from this same response. The item can have been
|
||||
// deleted, recreated under another type, or had its permissions changed while the
|
||||
// conflict stood, and the fields below decide create-vs-update, the schema and write
|
||||
// access — so refreshing only what is displayed would leave those deciding on the
|
||||
// version the user just replaced.
|
||||
fetchedResources[ws] = r
|
||||
existedInitially[ws] = !(r as any).no_deployed
|
||||
if (ws === effectiveWorkspace) resource_type = r.resource_type
|
||||
UserDraftDbSyncer.recordRemoteSync(query, (r as any).draft_saved_at)
|
||||
const loadedDraft = (r as any).draft as ResourceState | undefined
|
||||
// Loading a draft makes this workspace one that opened with a draft, whatever it opened
|
||||
// with before — a refused deletion opens gated, and gated the settling absorber would
|
||||
// fold the version just loaded into the deployed baseline, leaving it silently clean
|
||||
// with Save disabled. Ungate here rather than leaving it to the effect, so no write
|
||||
// between the two is absorbed.
|
||||
openedOnDraft[ws] = !!loadedDraft
|
||||
if (loadedDraft) {
|
||||
setGated(ws, false)
|
||||
} else {
|
||||
// Accepting "there is no draft" has to shut the gate, the way discarding one does.
|
||||
// The edit that raised the conflict set `userEdited`, and left open, the form's
|
||||
// settling writes — schema defaults materializing over the loaded value — read as
|
||||
// the user's and recreate the draft just accepted away.
|
||||
userEdited[ws] = false
|
||||
setGated(ws, true)
|
||||
}
|
||||
UserDraft.seed('resource', p, loadedDraft ?? deployedState, { workspace: ws })
|
||||
} catch (e) {
|
||||
// Nothing was given up above, so the conflict stands and the edit is still here to
|
||||
// resolve again — which is the whole point of reading first.
|
||||
sendUserToast(`Could not load the other version: ${e}`, true)
|
||||
} finally {
|
||||
conflictSession.finish(token)
|
||||
}
|
||||
}
|
||||
|
||||
// The syncer owns the list-page `*` hint; the editor only CLEARS it when a
|
||||
// workspace is at the deployed baseline (so a draft discarded elsewhere
|
||||
// vanishes on reopen). Never SET here. See VariableEditor for the full note.
|
||||
@@ -338,15 +470,33 @@
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false
|
||||
}
|
||||
// Open with the saved draft if present, else the deployed.
|
||||
const s: ResourceState = savedDraftState ?? deployedState
|
||||
openedOnDraft[ws] = !!savedDraftState
|
||||
// A refused save leaves this tab's own version parked. `.draft` is the
|
||||
// version that refused it, so opening on that would quietly drop the edit
|
||||
// the alert is about and leave "Keep mine" offering to keep the other one.
|
||||
const conflictQuery = {
|
||||
workspace: ws,
|
||||
itemKind: 'resource' as const,
|
||||
path: initialPath
|
||||
}
|
||||
const refused = UserDraftDbSyncer.getConflict(conflictQuery).conflict
|
||||
? UserDraftDbSyncer.peekPending(conflictQuery)
|
||||
: undefined
|
||||
// A parked `null` is this tab's "no draft any more", which on screen is the
|
||||
// deployed value — so only a payload with content counts as a local draft.
|
||||
const refusedDraft = (refused?.value ?? undefined) as ResourceState | undefined
|
||||
const hasLocalDraft = refused ? refused.value !== null : !!savedDraftState
|
||||
// Open with this tab's refused version if there is one, else the saved
|
||||
// draft, else the deployed.
|
||||
const s: ResourceState = refused
|
||||
? (refusedDraft ?? deployedState)
|
||||
: (savedDraftState ?? deployedState)
|
||||
openedOnDraft[ws] = hasLocalDraft
|
||||
// Gate BEFORE the handle is acquired: `stopSync` queues on a
|
||||
// not-yet-live entry, and the form can settle before the effect
|
||||
// above gets a chance to run. Only worth doing when no draft exists
|
||||
// yet — where one does, there is no phantom to prevent and
|
||||
// suspending could only drop a write.
|
||||
if (!savedDraftState) setGated(ws, true)
|
||||
if (!hasLocalDraft) setGated(ws, true)
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
// Draft-only paths (`no_deployed`) have no row — saving must
|
||||
@@ -426,6 +576,13 @@
|
||||
$effect(() => {
|
||||
onCanWriteChange?.(can_write === true)
|
||||
})
|
||||
$effect(() => {
|
||||
onDraftConflictChange?.({ conflicted: !!draftConflict, busy: resolvingConflict })
|
||||
})
|
||||
|
||||
export function resolveDraftConflictFromBanner(keepMine: boolean): void {
|
||||
void resolveDraftConflict(keepMine)
|
||||
}
|
||||
|
||||
export function localDraftDeployed(): ResourceState | undefined {
|
||||
return selected ? initialStates[selected] : undefined
|
||||
@@ -539,6 +696,18 @@
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col gap-6 pb-2">
|
||||
<!-- Only when nobody above is showing it. A host that takes `onDraftConflictChange` puts it
|
||||
in its own fixed banner, where a long form cannot scroll it out of view; one that embeds
|
||||
this editor directly — the AI chat's MCP section, and the SDK surface — would otherwise
|
||||
get no warning at all, which is the very thing this alert exists to prevent. -->
|
||||
{#if draftConflict && !onDraftConflictChange}
|
||||
<DraftConflictAlert
|
||||
busy={resolvingConflict}
|
||||
onReload={() => void resolveDraftConflict(false)}
|
||||
onOverwrite={() => void resolveDraftConflict(true)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if otherDirty.length > 0}
|
||||
<Alert type="warning" title="Editing multiple workspaces">
|
||||
You are going to edit the value in: {otherDirty.join(', ')}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { isOwner } from '$lib/utils'
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
import DraftConflictAlert from './DraftConflictAlert.svelte'
|
||||
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
@@ -62,9 +63,12 @@
|
||||
localDraftDeployed: () => unknown
|
||||
localDraftCurrent: () => unknown
|
||||
discardLocalDraft: () => void
|
||||
endEditingSession: () => void
|
||||
resolveDraftConflictFromBanner: (keepMine: boolean) => void
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
let hasLocalDraft = $state(false)
|
||||
let draftConflict = $state({ conflicted: false, busy: false })
|
||||
let canWriteSelected = $state(true)
|
||||
|
||||
let path: string | undefined = $state(undefined)
|
||||
@@ -151,6 +155,9 @@
|
||||
size="50rem"
|
||||
{disableChatOffset}
|
||||
on:close={() => {
|
||||
// The editor outlives this drawer, so tell it the session is over: a conflict resolution
|
||||
// still in flight must not land on whatever the next opening shows.
|
||||
resourceEditor?.endEditingSession?.()
|
||||
if (keepAnchorOnClose) {
|
||||
keepAnchorOnClose = false
|
||||
return
|
||||
@@ -168,7 +175,15 @@
|
||||
bannerReserved={mode == 'edit'}
|
||||
hideClose={inline && !onClose}
|
||||
fullScreen={!inline}
|
||||
on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
|
||||
on:close={() => {
|
||||
// Inline has no drawer to emit a close, so the session ends here instead.
|
||||
if (inline) {
|
||||
resourceEditor?.endEditingSession?.()
|
||||
onClose?.()
|
||||
} else {
|
||||
drawer?.closeDrawer()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
{#if mode == 'new' && resource_type}
|
||||
@@ -189,10 +204,18 @@
|
||||
bind:selected
|
||||
bind:viewJsonSchema
|
||||
onDraftStateChange={(v) => (hasLocalDraft = v)}
|
||||
onDraftConflictChange={(v) => (draftConflict = v)}
|
||||
onCanWriteChange={(v) => (canWriteSelected = v)}
|
||||
/>
|
||||
{/await}
|
||||
{#snippet banner()}
|
||||
{#if draftConflict.conflicted}
|
||||
<DraftConflictAlert
|
||||
busy={draftConflict.busy}
|
||||
onReload={() => resourceEditor?.resolveDraftConflictFromBanner?.(false)}
|
||||
onOverwrite={() => resourceEditor?.resolveDraftConflictFromBanner?.(true)}
|
||||
/>
|
||||
{/if}
|
||||
<LocalDraftBanner
|
||||
show={hasLocalDraft}
|
||||
reserveSpace={mode == 'edit'}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { VariableService, WorkspaceService } from '$lib/gen'
|
||||
import { createEventDispatcher, untrack } from 'svelte'
|
||||
import { createEventDispatcher, onDestroy, untrack } from 'svelte'
|
||||
import { Button } from './common'
|
||||
import Drawer from './common/drawer/Drawer.svelte'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
@@ -23,6 +23,9 @@
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
import DraftConflictAlert from './DraftConflictAlert.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { useDraftConflictSession } from '$lib/draftConflictSession.svelte'
|
||||
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
|
||||
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
@@ -141,6 +144,126 @@
|
||||
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
|
||||
)
|
||||
|
||||
/** Scoped by `selected` so switching workspace-specific versions is a new session. Ended on top
|
||||
* of that by every entry point and by the teardown, since reopening the same variable reuses
|
||||
* this component. */
|
||||
const conflictSession = useDraftConflictSession(() => selected)
|
||||
function endEditingSession(): void {
|
||||
conflictSession.end()
|
||||
}
|
||||
onDestroy(endEditingSession)
|
||||
const resolvingConflict = $derived(conflictSession.busy)
|
||||
/** The server refused this tab's autosave because the row moved under it: another tab, or the
|
||||
* AI chat, which writes these drafts too. Nothing typed here reaches the server until the user
|
||||
* picks a version, and the unsaved-changes banner says the opposite — that the edits are held
|
||||
* as a draft — so without this they are told their work is safe while it is being dropped. */
|
||||
const draftConflict = $derived(
|
||||
edit && selected && editPath
|
||||
? UserDraftDbSyncer.getConflict({
|
||||
workspace: selected,
|
||||
itemKind: 'variable',
|
||||
path: editPath
|
||||
}).conflict
|
||||
: undefined
|
||||
)
|
||||
|
||||
async function resolveDraftConflict(keepMine: boolean): Promise<void> {
|
||||
const ws = selected
|
||||
const p = editPath
|
||||
if (!ws || !p || resolvingConflict) return
|
||||
const query = { workspace: ws, itemKind: 'variable' as const, path: p }
|
||||
const token = conflictSession.start()
|
||||
const stillOurs = () => conflictSession.holds(token) && selected === ws
|
||||
try {
|
||||
if (keepMine) {
|
||||
// Settle the key first: an ordinary autosave still queued would displace the forced
|
||||
// write below, and being conditional it would be refused — so "Keep mine" would
|
||||
// finish without keeping anything and leave the alert standing.
|
||||
await UserDraftDbSyncer.quiesce(query)
|
||||
// A resolution belongs to the session that started it. One that outlives its editor
|
||||
// stops here rather than writing on: whatever replaced it — another session on the
|
||||
// same draft, or its own resolution — owns the key now, and the edit this one was
|
||||
// keeping is still parked for a later flush either way.
|
||||
if (!stillOurs()) return
|
||||
// A parked `null` is this tab's "no draft any more" — a discard, or an edit that
|
||||
// landed back on the deployed value. Keeping that means removing the row, not
|
||||
// writing the baseline back as a draft with no dirty banner to discard it through.
|
||||
const parked = UserDraftDbSyncer.peekPending(query)
|
||||
const mine = parked?.value === null ? null : $state.snapshot(states[ws]?.draft)
|
||||
// Forced, so it goes over the row that refused us, and its response reseeds
|
||||
// `last_sync` so the next ordinary save is conditional again.
|
||||
if (mine !== undefined) await UserDraftDbSyncer.overwrite({ ...query, value: mine })
|
||||
// Say so rather than leave the alert up with no explanation: a write displaced by
|
||||
// something typed meanwhile can still lose the race.
|
||||
if (UserDraftDbSyncer.getConflict(query).conflict) {
|
||||
sendUserToast('Could not keep your version — try again', true)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Settle the key BEFORE reading, so what comes back is the version the server is left
|
||||
// holding: a write this tab started can still be in flight — a forced one it walked
|
||||
// away from included — and a response fetched past it describes a version about to be
|
||||
// replaced, which would then be seeded along with its already-stale `last_sync`.
|
||||
// Nothing is given up by waiting; `quiesce` only stops the pipeline.
|
||||
await UserDraftDbSyncer.quiesce(query)
|
||||
if (!stillOurs()) return
|
||||
// Read BEFORE giving anything up: until the server has answered, the refused payload is
|
||||
// still the only copy of this tab's edit, and the conflict is still true.
|
||||
const v = await VariableService.getVariable({
|
||||
workspace: ws,
|
||||
path: p,
|
||||
decryptSecret: false,
|
||||
getDraft: true
|
||||
})
|
||||
const deployedState: VariableState = {
|
||||
path: v.path,
|
||||
variable: {
|
||||
value: v.value ?? '',
|
||||
is_secret: v.is_secret,
|
||||
description: v.description ?? ''
|
||||
},
|
||||
labels: v.labels ?? undefined,
|
||||
wsSpecific: v.ws_specific ?? false
|
||||
}
|
||||
// Everything below writes shared editor state, so first make sure it is still this
|
||||
// variable's: the drawer stays closable while the read is out, and another variable
|
||||
// opened meanwhile would otherwise get this one's baseline — and with it this one's
|
||||
// path as its save target.
|
||||
if (!stillOurs()) return
|
||||
// Again, because the form stayed editable while the read was out: `quiesce` settles what
|
||||
// is running when it is called, not the key for the rest of the resolution, so a
|
||||
// keystroke since can have started a save of its own. Left running, its rejection lands
|
||||
// after the baseline below and raises the conflict this just resolved.
|
||||
await UserDraftDbSyncer.quiesce(query)
|
||||
if (!stillOurs()) return
|
||||
// Now, and not in `quiesce`: the refused payload belongs to the version being replaced,
|
||||
// but until this point it was still the only copy of the edit, and a resolution that
|
||||
// gave up before here has to leave it behind.
|
||||
UserDraftDbSyncer.dropPending(query)
|
||||
UserDraftDbSyncer.clearConflict(query)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
// Everything else the load path takes from this same response. The variable can have
|
||||
// been deleted, recreated, or had its permissions changed while the conflict stood,
|
||||
// and these decide create-vs-update and write access — so refreshing only what is
|
||||
// displayed would leave those deciding on the version the user just replaced.
|
||||
existedInitially[ws] = !(v as any).no_deployed
|
||||
extraPerms[ws] = v.extra_perms ?? {}
|
||||
UserDraftDbSyncer.recordRemoteSync(query, (v as any).draft_saved_at)
|
||||
UserDraft.seed(
|
||||
'variable',
|
||||
p,
|
||||
((v as any).draft as VariableState | undefined) ?? deployedState,
|
||||
{ workspace: ws }
|
||||
)
|
||||
} catch (e) {
|
||||
// Nothing was given up above, so the conflict stands and the edit is still here to
|
||||
// resolve again — which is the whole point of reading first.
|
||||
sendUserToast(`Could not load the other version: ${e}`, true)
|
||||
} finally {
|
||||
conflictSession.finish(token)
|
||||
}
|
||||
}
|
||||
|
||||
// The list-page `*` hint is owned by UserDraftDbSyncer (set on save, cleared
|
||||
// on delete). The editor only CLEARS it — a workspace at the deployed
|
||||
// baseline has no draft, so drop any stale hint (this is how a draft
|
||||
@@ -210,8 +333,21 @@
|
||||
labels: v.labels ?? undefined,
|
||||
wsSpecific: v.ws_specific ?? false
|
||||
}
|
||||
// Open with the saved draft if present, else the deployed.
|
||||
const s: VariableState = savedDraftState ?? deployedState
|
||||
// A refused save leaves this tab's own version parked. `.draft` is the version
|
||||
// that refused it, so opening on that would quietly drop the edit the alert is
|
||||
// about and leave "Keep mine" offering to keep the other one.
|
||||
const conflictQuery = { workspace: ws, itemKind: 'variable' as const, path: p }
|
||||
const refused = UserDraftDbSyncer.getConflict(conflictQuery).conflict
|
||||
? UserDraftDbSyncer.peekPending(conflictQuery)
|
||||
: undefined
|
||||
// A parked `null` is this tab's "no draft any more", which on screen is the
|
||||
// deployed value.
|
||||
const refusedDraft = (refused?.value ?? undefined) as VariableState | undefined
|
||||
// Open with this tab's refused version if there is one, else the saved draft,
|
||||
// else the deployed.
|
||||
const s: VariableState = refused
|
||||
? (refusedDraft ?? deployedState)
|
||||
: (savedDraftState ?? deployedState)
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
// Draft-only paths (`no_deployed`) have no row — saving must
|
||||
@@ -223,6 +359,8 @@
|
||||
})
|
||||
|
||||
function reset() {
|
||||
// A new session starts here, so anything still running for the last one is spent.
|
||||
endEditingSession()
|
||||
// Clearing workspaceSpecs triggers useMany's reconcile to release
|
||||
// every acquired entry. The $derived `states` then collapses to {}.
|
||||
workspaceSpecs = []
|
||||
@@ -334,7 +472,14 @@
|
||||
{#if inline}
|
||||
{@render content()}
|
||||
{:else}
|
||||
<Drawer bind:this={drawer} size="50rem" on:close={() => clearPageDrawerAnchor(VARIABLES_PATH)}>
|
||||
<Drawer
|
||||
bind:this={drawer}
|
||||
size="50rem"
|
||||
on:close={() => {
|
||||
endEditingSession()
|
||||
clearPageDrawerAnchor(VARIABLES_PATH)
|
||||
}}
|
||||
>
|
||||
{@render content()}
|
||||
</Drawer>
|
||||
{/if}
|
||||
@@ -345,9 +490,24 @@
|
||||
bannerReserved={edit}
|
||||
hideClose={inline && !onClose}
|
||||
fullScreen={!inline}
|
||||
on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
|
||||
on:close={() => {
|
||||
// Inline has no drawer to emit a close, so the session ends here instead.
|
||||
if (inline) {
|
||||
endEditingSession()
|
||||
onClose?.()
|
||||
} else {
|
||||
drawer?.closeDrawer()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet banner()}
|
||||
{#if draftConflict}
|
||||
<DraftConflictAlert
|
||||
busy={resolvingConflict}
|
||||
onReload={() => void resolveDraftConflict(false)}
|
||||
onOverwrite={() => void resolveDraftConflict(true)}
|
||||
/>
|
||||
{/if}
|
||||
<LocalDraftBanner
|
||||
show={edit && selectedDirty}
|
||||
reserveSpace={edit}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { editor as meditor, Position, languages, type IDisposable } from 'monaco
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { autocompleteRequest } from './request'
|
||||
import { FIM_MAX_TOKENS } from '../lib'
|
||||
import { getModelContextWindow } from '../modelConfig'
|
||||
import { ASSUMED_CONTEXT_WINDOW, getEffectiveModelContextWindow } from '../modelConfig'
|
||||
import { setGlobalCSS } from '../shared'
|
||||
import { supportsAutocomplete } from '../utils'
|
||||
import { get } from 'svelte/store'
|
||||
@@ -94,8 +94,14 @@ export class Autocompletor {
|
||||
|
||||
const deletionsCues = editor.createDecorationsCollection()
|
||||
|
||||
const completionModel = get(copilotInfo).codeCompletionModel
|
||||
this.#contextWindow = getModelContextWindow(completionModel?.model ?? '')
|
||||
const { codeCompletionModel: completionModel, contextWindowPerModel } = get(copilotInfo)
|
||||
this.#contextWindow = completionModel
|
||||
? getEffectiveModelContextWindow(
|
||||
completionModel.provider,
|
||||
completionModel.model,
|
||||
contextWindowPerModel
|
||||
)
|
||||
: ASSUMED_CONTEXT_WINDOW
|
||||
|
||||
this.#completionDisposable = languages.registerInlineCompletionsProvider(
|
||||
{ pattern: '**' },
|
||||
|
||||
@@ -55,7 +55,7 @@ import { sendUserToast } from '$lib/toast'
|
||||
import { workspaceAIClients, getNonStreamingCompletion } from '../lib'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import { modelSupportsVision } from '../modelConfig'
|
||||
import { getModelContextWindow } from '../modelConfig'
|
||||
import { getEffectiveModelContextWindow } from '../modelConfig'
|
||||
import {
|
||||
getCompactionSummaryPrompt,
|
||||
formatCompactSummary,
|
||||
@@ -3857,7 +3857,13 @@ export class AIChatManager implements ChatViewHost {
|
||||
// assumed window rather than no limit: without one the context grows
|
||||
// unbounded until the provider (or a proxy in front of it) times out.
|
||||
// Guessing low only compacts earlier, which is always recoverable.
|
||||
const contextWindow = model ? getModelContextWindow(model.model) : undefined
|
||||
const contextWindow = model
|
||||
? getEffectiveModelContextWindow(
|
||||
model.provider,
|
||||
model.model,
|
||||
get(copilotInfo).contextWindowPerModel
|
||||
)
|
||||
: undefined
|
||||
if (
|
||||
contextWindow !== undefined &&
|
||||
projectedContextTokens >= contextWindow * COMPACTION_TRIGGER_RATIO
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
|
||||
import { getKnownModelContextWindow, getModelContextWindow } from '../modelConfig'
|
||||
import { getConfiguredModelContextWindow, getEffectiveModelContextWindow } from '../modelConfig'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import UsageMeter from './UsageMeter.svelte'
|
||||
@@ -15,14 +15,26 @@
|
||||
let providerModel = $derived(
|
||||
$copilotSessionModel ?? $copilotInfo.defaultModel ?? $copilotInfo.aiModels[0]
|
||||
)
|
||||
// The same number the compaction trigger uses: the known window when the
|
||||
// model is listed, otherwise the conservative window the trigger assumes.
|
||||
// The tooltip marks the assumed case so the guess never reads as a spec.
|
||||
// The same number the compaction trigger uses: the workspace's override or
|
||||
// the known window when there is one, otherwise the conservative window the
|
||||
// trigger assumes. The tooltip marks the assumed case so the guess never
|
||||
// reads as a spec.
|
||||
let contextWindow = $derived(
|
||||
providerModel ? getModelContextWindow(providerModel.model) : undefined
|
||||
providerModel
|
||||
? getEffectiveModelContextWindow(
|
||||
providerModel.provider,
|
||||
providerModel.model,
|
||||
$copilotInfo.contextWindowPerModel
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
let windowIsAssumed = $derived(
|
||||
providerModel !== undefined && getKnownModelContextWindow(providerModel.model) === undefined
|
||||
providerModel !== undefined &&
|
||||
getConfiguredModelContextWindow(
|
||||
providerModel.provider,
|
||||
providerModel.model,
|
||||
$copilotInfo.contextWindowPerModel
|
||||
) === undefined
|
||||
)
|
||||
// The same number the compaction trigger uses: the provider's report when
|
||||
// one describes the current history (one turn stale by nature), otherwise
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { listMcpToolsMock } = vi.hoisted(() => ({
|
||||
listMcpToolsMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../shared', () => ({
|
||||
createToolDef: (_schema: unknown, name: string, description: string) => ({
|
||||
type: 'function',
|
||||
function: { name, description, parameters: {} }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('$lib/gen', () => ({
|
||||
McpService: {
|
||||
listMcpTools: listMcpToolsMock
|
||||
}
|
||||
}))
|
||||
|
||||
import { apiCatalogTools, clearApiCatalogCache } from './apiCatalogTools'
|
||||
|
||||
const CATALOG = [
|
||||
{
|
||||
name: 'listWorkers',
|
||||
description: 'List workers',
|
||||
instructions: 'List all workers with their ping status',
|
||||
path: '/workers/list',
|
||||
method: 'GET',
|
||||
query_params_schema: {
|
||||
type: 'object',
|
||||
properties: { page: { type: 'integer' }, per_page: { type: 'integer' } }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'listDataMetrics',
|
||||
description: 'List declared measures and dimensions',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/data_metrics/list',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
name: 'listQueue',
|
||||
description: 'List queued jobs',
|
||||
instructions: 'List the jobs waiting in the queue',
|
||||
path: '/w/{workspace}/jobs/queue/list',
|
||||
method: 'GET',
|
||||
query_params_schema: {
|
||||
type: 'object',
|
||||
properties: { running: { type: 'boolean' }, per_page: { type: 'integer' } }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'getJobUpdates',
|
||||
description: 'Get job updates',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/jobs_u/getupdate/{id}',
|
||||
method: 'GET',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, id: { type: 'string' } },
|
||||
required: ['workspace', 'id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'getJob',
|
||||
description: 'Get job details',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/jobs_u/get/{id}',
|
||||
method: 'GET',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, id: { type: 'string' } },
|
||||
required: ['workspace', 'id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'deleteSchedule',
|
||||
description: 'Delete a schedule',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/schedules/delete/{path}',
|
||||
method: 'DELETE',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
|
||||
required: ['workspace', 'path']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'createFlow',
|
||||
description: 'Create a flow',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/flows/create',
|
||||
method: 'POST'
|
||||
},
|
||||
{
|
||||
name: 'getVariable',
|
||||
description: 'Get variable',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/variables/get/{path}',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
name: 'getScriptByPath',
|
||||
description: 'Get script by path',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/scripts/get/p/{path}',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
name: 'deleteScriptByHash',
|
||||
description: 'Delete a script by hash',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/scripts/delete/h/{hash}',
|
||||
method: 'POST',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, hash: { type: 'string' } },
|
||||
required: ['workspace', 'hash']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'runScriptByPath',
|
||||
description: 'Run script by path',
|
||||
instructions: 'Trigger a run of a deployed script',
|
||||
path: '/w/{workspace}/jobs/run/p/{path}',
|
||||
method: 'POST',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
|
||||
required: ['workspace', 'path']
|
||||
},
|
||||
body_schema: { type: 'object', additionalProperties: true }
|
||||
},
|
||||
{
|
||||
name: 'cancelQueuedJob',
|
||||
description: 'Cancel a queued job',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/jobs_u/queue/cancel/{id}',
|
||||
method: 'POST',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, id: { type: 'string' } },
|
||||
required: ['workspace', 'id']
|
||||
},
|
||||
body_schema: { type: 'object', additionalProperties: true }
|
||||
},
|
||||
{
|
||||
name: 'runFlowByPath',
|
||||
description: 'Run flow by path',
|
||||
instructions: 'Trigger a run of a deployed flow',
|
||||
path: '/w/{workspace}/jobs/run/f/{path}',
|
||||
method: 'POST',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
|
||||
required: ['workspace', 'path']
|
||||
},
|
||||
body_schema: {
|
||||
type: 'object',
|
||||
properties: { args: { type: 'object' } }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
function createToolCallbacks() {
|
||||
return {
|
||||
setToolStatus: vi.fn(),
|
||||
removeToolStatus: vi.fn()
|
||||
} as any
|
||||
}
|
||||
|
||||
function getTool(name: string) {
|
||||
const tool = apiCatalogTools.find((entry) => entry.def.function.name === name)
|
||||
if (!tool) throw new Error(`${name} tool not found`)
|
||||
return tool
|
||||
}
|
||||
|
||||
async function run(name: string, args: Record<string, unknown>) {
|
||||
const raw = await getTool(name).fn({
|
||||
args,
|
||||
workspace: 'test-ws',
|
||||
helpers: {},
|
||||
toolCallbacks: createToolCallbacks(),
|
||||
toolId: 'tool-1'
|
||||
})
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clearApiCatalogCache()
|
||||
listMcpToolsMock.mockResolvedValue(CATALOG)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('search_api_endpoints', () => {
|
||||
it('matches on name/path tokens, plural-insensitively, and excludes covered endpoints', async () => {
|
||||
// Singular "job" matches the plural "jobs" path segment.
|
||||
const result = await run('search_api_endpoints', { query: 'list queued job' })
|
||||
expect(result.matches[0].name).toBe('listQueue')
|
||||
expect(result.matches[0].endpoint).toBe('GET /w/{workspace}/jobs/queue/list')
|
||||
expect(result.matches[0].params).toEqual(['running', 'per_page'])
|
||||
expect(result.matches[0].instructions).toContain('waiting in the queue')
|
||||
|
||||
const flows = await run('search_api_endpoints', { query: 'create flow' })
|
||||
expect(flows.matches.map((m: any) => m.name)).not.toContain('createFlow')
|
||||
expect(flows.covered_by_dedicated_tools).toContain('createFlow → use write_flow')
|
||||
})
|
||||
|
||||
it('returns endpoint categories when nothing matches', async () => {
|
||||
const result = await run('search_api_endpoints', { query: 'kubernetes' })
|
||||
expect(result.matches).toEqual([])
|
||||
expect(result.hint).toContain('jobs_u')
|
||||
// Categories are built from the uncovered endpoints only, so a covered one
|
||||
// must not be advertised as somewhere to retry.
|
||||
expect(result.hint).not.toContain('workers')
|
||||
expect(result.hint).not.toContain('data_metrics')
|
||||
})
|
||||
})
|
||||
|
||||
describe('call_api_get', () => {
|
||||
it('rejects covered, unknown, and non-GET endpoints with a pointer', async () => {
|
||||
const covered = await run('call_api_get', { name: 'createFlow' })
|
||||
expect(covered.error).toContain('write_flow')
|
||||
|
||||
const unknown = await run('call_api_get', { name: 'nope' })
|
||||
expect(unknown.error).toContain('search_api_endpoints')
|
||||
|
||||
const mutating = await run('call_api_get', { name: 'cancelQueuedJob' })
|
||||
expect(mutating.error).toContain('call_api_endpoint')
|
||||
|
||||
const job = await run('call_api_get', { name: 'getJob' })
|
||||
expect(job.error).toContain('get_run')
|
||||
|
||||
const deleting = await run('call_api_endpoint', { name: 'deleteSchedule' })
|
||||
expect(deleting.error).toContain('delete_workspace_item')
|
||||
|
||||
const byHash = await run('call_api_endpoint', { name: 'deleteScriptByHash' })
|
||||
expect(byHash.error).toContain('delete_workspace_item')
|
||||
const search = await run('search_api_endpoints', { query: 'delete script' })
|
||||
expect(search.matches.map((m: any) => m.name)).not.toContain('deleteScriptByHash')
|
||||
})
|
||||
|
||||
// Left reachable, these endpoints are the way around the argument form: they run the
|
||||
// deployed runnable on the model's arguments, unstripped and unshown.
|
||||
it('refuses a deployed run, pointing at run_script and run_flow', async () => {
|
||||
for (const [name, tool, query] of [
|
||||
['runScriptByPath', 'run_script', 'run deployed script'],
|
||||
['runFlowByPath', 'run_flow', 'run deployed flow']
|
||||
]) {
|
||||
const called = await run('call_api_endpoint', { name })
|
||||
expect(called.error).toContain(tool)
|
||||
expect(called.success).toBe(false)
|
||||
|
||||
// And it is gone from search, so the model is redirected before it ever calls.
|
||||
const search = await run('search_api_endpoints', { query })
|
||||
expect(search.matches.map((m: any) => m.name)).not.toContain(name)
|
||||
expect(search.covered_by_dedicated_tools?.join(' ')).toContain(tool)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses draft-blind item reads and lists, pointing at the draft-aware tools', async () => {
|
||||
for (const name of ['getScriptByPath', 'getResource', 'getSchedule']) {
|
||||
const result = await run('call_api_get', { name })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('read_workspace_item')
|
||||
}
|
||||
for (const name of ['listScripts', 'listFlows', 'listResource', 'listSchedules']) {
|
||||
const result = await run('call_api_get', { name })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('list_workspace_items')
|
||||
}
|
||||
|
||||
const search = await run('search_api_endpoints', { query: 'get script' })
|
||||
expect(search.matches.map((m: any) => m.name)).not.toContain('getScriptByPath')
|
||||
})
|
||||
|
||||
it('refuses the worker and data-metric reads, pointing at their dedicated tools', async () => {
|
||||
for (const [name, tool, query] of [
|
||||
['listWorkers', 'list_workers', 'workers'],
|
||||
['listDataMetrics', 'list_data_metrics', 'data metrics']
|
||||
]) {
|
||||
const result = await run('call_api_get', { name })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain(tool)
|
||||
|
||||
const search = await run('search_api_endpoints', { query })
|
||||
expect(search.matches.map((m: any) => m.name)).not.toContain(name)
|
||||
expect(search.covered_by_dedicated_tools?.join(' ')).toContain(tool)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses variable reads so variable values never reach the model', async () => {
|
||||
const result = await run('call_api_get', { name: 'getVariable' })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('never readable')
|
||||
|
||||
const search = await run('search_api_endpoints', { query: 'variable' })
|
||||
expect(search.matches.map((m: any) => m.name)).not.toContain('getVariable')
|
||||
})
|
||||
|
||||
it('returns the endpoint schema when a required path param is missing', async () => {
|
||||
const result = await run('call_api_get', { name: 'getJobUpdates' })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('id')
|
||||
expect(result.schema.path_params_schema.required).toContain('id')
|
||||
})
|
||||
|
||||
it('substitutes path params and sends the rest as query params', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: async () => [{ id: 'job-1' }]
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const result = await run('call_api_get', { name: 'listQueue', params: { running: true } })
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/w/test-ws/jobs/queue/list?running=true', {
|
||||
method: 'GET'
|
||||
})
|
||||
expect(result).toEqual({ success: true, data: [{ id: 'job-1' }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('call_api_endpoint', () => {
|
||||
it('requires confirmation and executes mutating endpoints with a body', async () => {
|
||||
expect(getTool('call_api_endpoint').requiresConfirmation).toBe(true)
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ 'content-type': 'text/plain' }),
|
||||
text: async () => 'job-id-1'
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const result = await run('call_api_endpoint', {
|
||||
name: 'cancelQueuedJob',
|
||||
params: { id: 'job/1' },
|
||||
body: { args: { n: 1 } }
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/w/test-ws/jobs_u/queue/cancel/job%2F1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ args: { n: 1 } })
|
||||
})
|
||||
expect(result).toEqual({ success: true, data: 'job-id-1' })
|
||||
})
|
||||
|
||||
it('redirects GET endpoints to call_api_get', async () => {
|
||||
const result = await run('call_api_endpoint', { name: 'listQueue' })
|
||||
expect(result.error).toContain('call_api_get')
|
||||
})
|
||||
})
|
||||
@@ -1,391 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { McpService, type EndpointTool } from '$lib/gen'
|
||||
import { createToolDef, type Tool } from '../shared'
|
||||
|
||||
/**
|
||||
* Generic access to the backend's MCP endpoint catalog (the endpoints marked
|
||||
* `x-mcp-tool` in openapi.yaml) as three small static tools — search, GET
|
||||
* call, mutating call — instead of one registered tool per endpoint. Search
|
||||
* results carry parameter names and usage instructions; full parameter schemas
|
||||
* enter the model's context only when a call fails validation. This keeps the
|
||||
* per-iteration tool-schema cost constant.
|
||||
*/
|
||||
|
||||
// Endpoints whose job a dedicated global tool already does, plus the variable
|
||||
// read endpoints. Hidden from search and refused at call time: the authoring
|
||||
// and delete ones would bypass the draft lifecycle (conflict detection,
|
||||
// explicit deploy, draft cleanup on delete), the variable reads would expose
|
||||
// variable values to the model (getVariable even decrypts secrets by default),
|
||||
// and the rest are exact duplicates that would fragment behavior across two
|
||||
// code paths.
|
||||
const COVERED_ENDPOINTS: Record<string, string> = {
|
||||
getVariable: 'read_workspace_item (variable values are never readable in chat)',
|
||||
listVariable: 'list_workspace_items (variable values are never readable in chat)',
|
||||
// The item read/list endpoints return deployed state only, blind to the user's
|
||||
// drafts; read_workspace_item / list_workspace_items merge drafts, and for
|
||||
// flows return the compact JSON that patch_flow_json matches against.
|
||||
getScriptByPath:
|
||||
'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
getFlowByPath:
|
||||
'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
getResource:
|
||||
'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
getSchedule:
|
||||
'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
listScripts: 'list_workspace_items (it includes your drafts)',
|
||||
listFlows: 'list_workspace_items (it includes your drafts)',
|
||||
listResource: 'list_workspace_items (it includes your drafts)',
|
||||
listSchedules: 'list_workspace_items (it includes your drafts)',
|
||||
runScriptByPath: 'run_script (it shows the user an argument form to confirm)',
|
||||
runFlowByPath: 'run_flow (it shows the user an argument form to confirm)',
|
||||
deleteScriptByPath: 'delete_workspace_item',
|
||||
deleteScriptByHash: 'delete_workspace_item',
|
||||
deleteFlowByPath: 'delete_workspace_item',
|
||||
deleteSchedule: 'delete_workspace_item',
|
||||
deleteVariable: 'delete_workspace_item',
|
||||
deleteResource: 'delete_workspace_item',
|
||||
createScript: 'write_script',
|
||||
createFlow: 'write_flow',
|
||||
updateFlow: 'patch_flow_json or write_flow',
|
||||
createApp: 'init_app and the app draft tools',
|
||||
updateApp: 'write_app_file / write_app_runnable',
|
||||
createVariable: 'write_variable',
|
||||
updateVariable: 'write_variable',
|
||||
createResource: 'write_resource',
|
||||
updateResource: 'write_resource',
|
||||
createSchedule: 'write_schedule',
|
||||
updateSchedule: 'write_schedule',
|
||||
searchDocs: 'search_docs',
|
||||
readDocsPage: 'read_docs_page',
|
||||
listJobs: 'list_runs',
|
||||
listWorkers: 'list_workers',
|
||||
listDataMetrics: 'list_data_metrics',
|
||||
getJob: 'get_run',
|
||||
getJobLogs: 'get_run',
|
||||
runScriptPreviewAndWaitResult: 'test_run_script'
|
||||
}
|
||||
|
||||
const MAX_SEARCH_RESULTS = 10
|
||||
const MAX_DESCRIPTION_CHARS = 200
|
||||
const MAX_INSTRUCTIONS_CHARS = 400
|
||||
const MAX_RESULT_CHARS = 20_000
|
||||
|
||||
let catalogCache: { workspace: string; endpoints: EndpointTool[] } | undefined
|
||||
|
||||
async function loadCatalog(workspace: string): Promise<EndpointTool[]> {
|
||||
if (catalogCache?.workspace !== workspace) {
|
||||
const endpoints = await McpService.listMcpTools({ workspace })
|
||||
catalogCache = { workspace, endpoints }
|
||||
}
|
||||
return catalogCache.endpoints
|
||||
}
|
||||
|
||||
export function clearApiCatalogCache() {
|
||||
catalogCache = undefined
|
||||
}
|
||||
|
||||
function tokenize(text: string): string[] {
|
||||
return text
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((t) => t.length > 1)
|
||||
}
|
||||
|
||||
// Cheap plural-insensitive comparison so "workers" matches "worker" and vice versa.
|
||||
function tokenMatches(token: string, queryToken: string): boolean {
|
||||
const strip = (t: string) => (t.length > 3 && t.endsWith('s') ? t.slice(0, -1) : t)
|
||||
return strip(token) === strip(queryToken)
|
||||
}
|
||||
|
||||
function pathSegments(path: string): string[] {
|
||||
return path.split('/').filter((seg) => seg && seg !== 'w' && !seg.startsWith('{'))
|
||||
}
|
||||
|
||||
// Name and path tokens identify the operation; description tokens only support it.
|
||||
function scoreEndpoint(endpoint: EndpointTool, queryTokens: string[]): number {
|
||||
const nameTokens = [...tokenize(endpoint.name), ...pathSegments(endpoint.path).flatMap(tokenize)]
|
||||
const descTokens = tokenize(`${endpoint.description} ${endpoint.instructions}`)
|
||||
let score = 0
|
||||
for (const qt of queryTokens) {
|
||||
if (nameTokens.some((t) => tokenMatches(t, qt))) score += 3
|
||||
else if (descTokens.some((t) => tokenMatches(t, qt))) score += 1
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
function schemaPropertyNames(schema: unknown): string[] {
|
||||
const properties = (schema as { properties?: Record<string, unknown> } | null | undefined)
|
||||
?.properties
|
||||
return properties ? Object.keys(properties).filter((k) => k !== 'workspace') : []
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? text.slice(0, max) + '…' : text
|
||||
}
|
||||
|
||||
function summarizeEndpoint(endpoint: EndpointTool) {
|
||||
const params = [
|
||||
...schemaPropertyNames(endpoint.path_params_schema),
|
||||
...schemaPropertyNames(endpoint.query_params_schema)
|
||||
]
|
||||
const bodyParams = schemaPropertyNames(endpoint.body_schema)
|
||||
const instructions = endpoint.instructions.trim()
|
||||
return {
|
||||
name: endpoint.name,
|
||||
endpoint: `${endpoint.method.toUpperCase()} ${endpoint.path}`,
|
||||
description: truncate(endpoint.description, MAX_DESCRIPTION_CHARS),
|
||||
...(instructions ? { instructions: truncate(instructions, MAX_INSTRUCTIONS_CHARS) } : {}),
|
||||
...(params.length > 0 ? { params } : {}),
|
||||
...(bodyParams.length > 0 ? { body_params: bodyParams } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function endpointSchemaHelp(endpoint: EndpointTool) {
|
||||
return {
|
||||
name: endpoint.name,
|
||||
endpoint: `${endpoint.method.toUpperCase()} ${endpoint.path}`,
|
||||
path_params_schema: endpoint.path_params_schema ?? undefined,
|
||||
query_params_schema: endpoint.query_params_schema ?? undefined,
|
||||
body_schema: endpoint.body_schema ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveEndpoint(
|
||||
workspace: string,
|
||||
name: string
|
||||
): Promise<{ endpoint: EndpointTool } | { error: string }> {
|
||||
const covered = COVERED_ENDPOINTS[name]
|
||||
if (covered) {
|
||||
return { error: `"${name}" is covered by the dedicated ${covered} tool — use it instead.` }
|
||||
}
|
||||
const catalog = await loadCatalog(workspace)
|
||||
const endpoint = catalog.find((e) => e.name === name)
|
||||
if (!endpoint) {
|
||||
return {
|
||||
error: `Unknown endpoint "${name}". Use search_api_endpoints to find the endpoint name.`
|
||||
}
|
||||
}
|
||||
return { endpoint }
|
||||
}
|
||||
|
||||
async function executeEndpoint(
|
||||
endpoint: EndpointTool,
|
||||
workspace: string,
|
||||
params: Record<string, unknown>,
|
||||
body?: unknown
|
||||
): Promise<string> {
|
||||
let url = `/api${endpoint.path.replace('{workspace}', encodeURIComponent(workspace))}`
|
||||
const queryParams = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (url.includes(`{${key}}`)) {
|
||||
url = url.replace(`{${key}}`, encodeURIComponent(String(value)))
|
||||
} else {
|
||||
queryParams.append(key, String(value))
|
||||
}
|
||||
}
|
||||
const unresolved = [...url.matchAll(/\{([^}]+)\}/g)].map((m) => m[1])
|
||||
if (unresolved.length > 0) {
|
||||
return JSON.stringify({
|
||||
success: false,
|
||||
error: `Missing required path parameter(s): ${unresolved.join(', ')}`,
|
||||
schema: endpointSchemaHelp(endpoint)
|
||||
})
|
||||
}
|
||||
const search = queryParams.toString()
|
||||
if (search) url += `?${search}`
|
||||
|
||||
const method = endpoint.method.toUpperCase()
|
||||
const fetchOptions: RequestInit = { method }
|
||||
if (body !== undefined && method !== 'GET') {
|
||||
fetchOptions.headers = { 'Content-Type': 'application/json' }
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
const raw = response.headers.get('content-type')?.includes('application/json')
|
||||
? await response.json()
|
||||
: await response.text()
|
||||
if (!response.ok) {
|
||||
return JSON.stringify({
|
||||
success: false,
|
||||
status: response.status,
|
||||
error: typeof raw === 'string' ? raw : JSON.stringify(raw),
|
||||
// 4xx usually means wrong arguments — echo the schema so the model can
|
||||
// self-correct in the next call without a separate schema-fetch tool.
|
||||
...(response.status >= 400 && response.status < 500
|
||||
? { schema: endpointSchemaHelp(endpoint) }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
const result = JSON.stringify({ success: true, data: raw })
|
||||
if (result.length <= MAX_RESULT_CHARS) return result
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
truncated: true,
|
||||
data: (typeof raw === 'string' ? raw : JSON.stringify(raw)).slice(0, MAX_RESULT_CHARS),
|
||||
note: `Result truncated to ${MAX_RESULT_CHARS} characters. Use filter or pagination parameters to narrow it.`
|
||||
})
|
||||
}
|
||||
|
||||
const searchApiEndpointsSchema = z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
'Keywords matched against endpoint names, paths, and descriptions (e.g. "queue", "run flow", "audit log"). Jobs are called "runs" in the UI.'
|
||||
)
|
||||
})
|
||||
|
||||
const callApiGetSchema = z.object({
|
||||
name: z.string().describe('Endpoint name as returned by search_api_endpoints'),
|
||||
params: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.describe(
|
||||
'Path and query parameter values, keyed by parameter name. The workspace parameter is filled automatically.'
|
||||
)
|
||||
})
|
||||
|
||||
const callApiEndpointSchema = z.object({
|
||||
name: z.string().describe('Endpoint name as returned by search_api_endpoints'),
|
||||
params: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.describe(
|
||||
'Path and query parameter values, keyed by parameter name. The workspace parameter is filled automatically.'
|
||||
),
|
||||
body: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.describe('JSON request body, when the endpoint takes one')
|
||||
})
|
||||
|
||||
export const apiCatalogTools: Tool<{}>[] = [
|
||||
{
|
||||
def: createToolDef(
|
||||
searchApiEndpointsSchema,
|
||||
'search_api_endpoints',
|
||||
'Search the Windmill REST API endpoint catalog for operations no dedicated tool covers (queue state, job details, running deployed items, deletions, ...). Returns endpoint names to pass to call_api_get or call_api_endpoint.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = searchApiEndpointsSchema.parse(args)
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Searching API endpoints...' })
|
||||
const catalog = await loadCatalog(workspace)
|
||||
const queryTokens = tokenize(parsed.query)
|
||||
const available = catalog.filter((e) => !COVERED_ENDPOINTS[e.name])
|
||||
const scored = available
|
||||
.map((endpoint) => ({ endpoint, score: scoreEndpoint(endpoint, queryTokens) }))
|
||||
.filter((s) => s.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.endpoint.name.localeCompare(b.endpoint.name))
|
||||
const coveredHits = catalog
|
||||
.filter((e) => COVERED_ENDPOINTS[e.name] && scoreEndpoint(e, queryTokens) > 0)
|
||||
.map((e) => `${e.name} → use ${COVERED_ENDPOINTS[e.name]}`)
|
||||
|
||||
if (scored.length === 0) {
|
||||
const categories = [...new Set(available.map((e) => pathSegments(e.path)[0]))].sort()
|
||||
const result = JSON.stringify(
|
||||
{
|
||||
matches: [],
|
||||
hint: `No endpoint matched. Available endpoint categories: ${categories.join(', ')}. Retry with different keywords, or use a dedicated tool if one covers the need.`,
|
||||
...(coveredHits.length > 0 ? { covered_by_dedicated_tools: coveredHits } : {})
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'No matching API endpoint', result })
|
||||
return result
|
||||
}
|
||||
const top = scored.slice(0, MAX_SEARCH_RESULTS)
|
||||
const result = JSON.stringify(
|
||||
{
|
||||
matches: top.map((s) => summarizeEndpoint(s.endpoint)),
|
||||
...(scored.length > top.length
|
||||
? {
|
||||
note: `${scored.length - top.length} more match(es) — refine the query to see them.`
|
||||
}
|
||||
: {}),
|
||||
...(coveredHits.length > 0 ? { covered_by_dedicated_tools: coveredHits } : {})
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Found ${top.length} API endpoint(s) for "${parsed.query}"`,
|
||||
result
|
||||
})
|
||||
return result
|
||||
}
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
callApiGetSchema,
|
||||
'call_api_get',
|
||||
'Call a read-only GET endpoint from the API catalog by name. Use search_api_endpoints first to find the endpoint name; a failed call returns the parameter schema.'
|
||||
),
|
||||
// Readonly rests on the method check below plus the catalog only exposing
|
||||
// side-effect-free GETs; never mark a mutating GET as an `x-mcp-tool`.
|
||||
planModeSafe: true,
|
||||
showDetails: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = callApiGetSchema.parse(args)
|
||||
const resolved = await resolveEndpoint(workspace, parsed.name)
|
||||
if ('error' in resolved) {
|
||||
toolCallbacks.setToolStatus(toolId, { content: resolved.error, error: resolved.error })
|
||||
return JSON.stringify({ success: false, error: resolved.error })
|
||||
}
|
||||
if (resolved.endpoint.method.toUpperCase() !== 'GET') {
|
||||
const error = `"${parsed.name}" is a ${resolved.endpoint.method.toUpperCase()} endpoint — use call_api_endpoint for mutating calls.`
|
||||
toolCallbacks.setToolStatus(toolId, { content: error, error })
|
||||
return JSON.stringify({ success: false, error })
|
||||
}
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Calling ${parsed.name}...` })
|
||||
const result = await executeEndpoint(resolved.endpoint, workspace, parsed.params ?? {})
|
||||
const ok = JSON.parse(result).success === true
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: ok ? `Called ${parsed.name}` : `Call to ${parsed.name} failed`,
|
||||
result,
|
||||
...(ok ? {} : { error: `Call to ${parsed.name} failed` })
|
||||
})
|
||||
return result
|
||||
}
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
callApiEndpointSchema,
|
||||
'call_api_endpoint',
|
||||
'Call a mutating (POST/PUT/PATCH/DELETE) endpoint from the API catalog by name; the user is asked to confirm. Use search_api_endpoints first to find the endpoint name; a failed call returns the parameter schema.'
|
||||
),
|
||||
requiresConfirmation: true,
|
||||
confirmationMessage: (args) => `Call API endpoint ${args?.name ?? ''}`,
|
||||
showDetails: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = callApiEndpointSchema.parse(args)
|
||||
const resolved = await resolveEndpoint(workspace, parsed.name)
|
||||
if ('error' in resolved) {
|
||||
toolCallbacks.setToolStatus(toolId, { content: resolved.error, error: resolved.error })
|
||||
return JSON.stringify({ success: false, error: resolved.error })
|
||||
}
|
||||
if (resolved.endpoint.method.toUpperCase() === 'GET') {
|
||||
const error = `"${parsed.name}" is a GET endpoint — use call_api_get (no confirmation needed).`
|
||||
toolCallbacks.setToolStatus(toolId, { content: error, error })
|
||||
return JSON.stringify({ success: false, error })
|
||||
}
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Calling ${parsed.name}...` })
|
||||
const result = await executeEndpoint(
|
||||
resolved.endpoint,
|
||||
workspace,
|
||||
parsed.params ?? {},
|
||||
parsed.body
|
||||
)
|
||||
const ok = JSON.parse(result).success === true
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: ok ? `Called ${parsed.name}` : `Call to ${parsed.name} failed`,
|
||||
result,
|
||||
...(ok ? {} : { error: `Call to ${parsed.name} failed` })
|
||||
})
|
||||
return result
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -259,6 +259,11 @@ vi.mock('$lib/gen', async () => {
|
||||
WorkerService: wrapService(actual.WorkerService, {
|
||||
listWorkers: vi.fn(async () => [])
|
||||
}),
|
||||
// Guests off by default, as a fresh workspace has them.
|
||||
WorkspaceService: wrapService(actual.WorkspaceService, {
|
||||
getGuestUsage: vi.fn(async () => ({ available: false, instance_enabled: false })),
|
||||
getPublicSettings: vi.fn(async () => ({ guest_access_enabled: false }))
|
||||
}),
|
||||
FolderService: wrapService(actual.FolderService, {
|
||||
createFolder: vi.fn(async () => 'created')
|
||||
}),
|
||||
@@ -404,7 +409,8 @@ import {
|
||||
ScriptService,
|
||||
UserService,
|
||||
VariableService,
|
||||
WorkerService
|
||||
WorkerService,
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import { devopsRole, superadmin, userStore, usersWorkspaceStore } from '$lib/stores'
|
||||
import { processSecretArgs } from '$lib/components/secretArgUtils'
|
||||
@@ -3590,6 +3596,26 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The draft carries the deployed policy from the fork, so nothing is fetched to answer.
|
||||
it('reports exposure for an app that has a draft over it', async () => {
|
||||
seedBackendDraft(
|
||||
'raw_app',
|
||||
'f/apps/drafted',
|
||||
{
|
||||
files: { '/src/App.tsx': 'x' },
|
||||
runnables: {},
|
||||
policy: { execution_mode: 'anonymous' }
|
||||
} as any,
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
|
||||
const read = await callGlobalTool('read_workspace_item', {
|
||||
type: 'app',
|
||||
path: 'f/apps/drafted'
|
||||
})
|
||||
expect(JSON.parse(read)).toMatchObject({ isDraft: true, executionMode: 'anonymous' })
|
||||
})
|
||||
|
||||
it('summarizes local raw app drafts in read_workspace_item', async () => {
|
||||
seedBackendDraft(
|
||||
'raw_app',
|
||||
@@ -3705,6 +3731,90 @@ describe('global AI tools', () => {
|
||||
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
// A low-code app has a grid, not files and runnables, so every app tool here would
|
||||
// otherwise report it as empty rather than say it is the wrong kind of app.
|
||||
it('reports a low-code app without its contents, and refuses to act on it', async () => {
|
||||
const lowCode = {
|
||||
path: 'f/apps/legacy',
|
||||
summary: 'legacy app',
|
||||
raw_app: false,
|
||||
value: { grid: [{ id: 'a', data: { type: 'buttoncomponent' } }] }
|
||||
} as any
|
||||
// ...Once per call: a persistent implementation would outlive this test and
|
||||
// disarm the factory's "mock not configured" guard for the rest of the file.
|
||||
for (let i = 0; i < 3; i++) vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(lowCode)
|
||||
|
||||
// The read answers "this app is drag-and-drop" rather than throwing — but it must not
|
||||
// summarize a grid as a file/runnable list, which reads as an empty app.
|
||||
const read = JSON.parse(
|
||||
await callGlobalTool('read_workspace_item', { type: 'app', path: 'f/apps/legacy' })
|
||||
)
|
||||
expect(read).toMatchObject({ type: 'app', path: 'f/apps/legacy', rawApp: false })
|
||||
expect(read).not.toHaveProperty('value')
|
||||
|
||||
// The tools that would convert it to files and runnables still refuse: a staged draft
|
||||
// would answer every later read in place of the app itself.
|
||||
for (const [tool, args] of [
|
||||
['read_app_file', { path: 'f/apps/legacy', file_path: '/index.tsx' }],
|
||||
['write_app_file', { path: 'f/apps/legacy', file_path: '/App.tsx', content: 'x' }]
|
||||
] as [string, any][]) {
|
||||
const raw = await callGlobalTool(tool, args).catch((e) => String(e))
|
||||
expect(raw).toContain('low-code app')
|
||||
}
|
||||
expect(getBackendDraft('raw_app', 'f/apps/legacy', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
// This read is the only way to ask who may open a deployed app. Both kinds answer: a
|
||||
// drag-and-drop app can be anonymous too, and no other tool here can inspect one.
|
||||
it('reports who may open an app, whichever kind it is', async () => {
|
||||
const readMode = async (app: any) => {
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(app)
|
||||
const read = await callGlobalTool('read_workspace_item', { type: 'app', path: app.path })
|
||||
return JSON.parse(read).executionMode
|
||||
}
|
||||
const guestApp = {
|
||||
path: 'f/apps/code',
|
||||
summary: 'Code app',
|
||||
raw_app: true,
|
||||
value: { files: {}, runnables: {} },
|
||||
policy: { execution_mode: 'guest' }
|
||||
}
|
||||
|
||||
vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({
|
||||
available: true,
|
||||
instance_enabled: true
|
||||
} as any)
|
||||
vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({
|
||||
guest_access_enabled: true
|
||||
} as any)
|
||||
expect(await readMode(guestApp)).toBe('guest')
|
||||
|
||||
expect(
|
||||
await readMode({
|
||||
path: 'f/apps/builder',
|
||||
summary: 'Builder app',
|
||||
raw_app: false,
|
||||
value: { grid: [] },
|
||||
policy: { execution_mode: 'anonymous' }
|
||||
})
|
||||
).toBe('anonymous')
|
||||
|
||||
// The same app, with each switch crossed in turn: either one alone admits nobody, so
|
||||
// reporting the mode bare would name an exposure the server refuses. Both off needs
|
||||
// no case of its own — whichever half of the check were dropped, one of these two
|
||||
// still catches it.
|
||||
vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({
|
||||
guest_access_enabled: true
|
||||
} as any)
|
||||
expect(await readMode(guestApp)).toContain('inert')
|
||||
|
||||
vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({
|
||||
available: true,
|
||||
instance_enabled: true
|
||||
} as any)
|
||||
expect(await readMode(guestApp)).toContain('inert')
|
||||
})
|
||||
|
||||
it('reads raw app files without creating a draft', async () => {
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
@@ -4361,6 +4471,69 @@ describe('global AI tools', () => {
|
||||
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
// Deploying is what makes an app's runnables reachable, so it is the one moment the
|
||||
// exposure is both true and known.
|
||||
it('discloses who can open an app after a deploy', async () => {
|
||||
const deployApp = async (path: string, policy: Record<string, unknown>) => {
|
||||
vi.mocked(AppService.existsApp).mockResolvedValueOnce(true)
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({} as any)
|
||||
seedBackendDraft(
|
||||
'raw_app',
|
||||
path,
|
||||
{
|
||||
summary: 'App',
|
||||
files: { '/index.tsx': 'x' },
|
||||
runnables: {},
|
||||
data: { tables: [] },
|
||||
policy
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
return JSON.parse(await callGlobalTool('deploy_workspace_item', { type: 'app', path }))
|
||||
}
|
||||
|
||||
const open = await deployApp('f/apps/open', {
|
||||
execution_mode: 'anonymous',
|
||||
on_behalf_of: 'u/alice'
|
||||
})
|
||||
expect(open.success).toBe(true)
|
||||
expect(open.message).toContain('anyone with the URL, without logging in')
|
||||
// The server decides what a deployed app runs as — it overwrites on_behalf_of for
|
||||
// anyone outside the deployers group — so the note must not name an identity.
|
||||
expect(open.message).not.toContain('u/alice')
|
||||
|
||||
// Guest is a real widening only where the deployment, the instance and the
|
||||
// workspace all admit guests; stored below that, it is inert.
|
||||
vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({
|
||||
available: true,
|
||||
instance_enabled: true
|
||||
} as any)
|
||||
vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({
|
||||
guest_access_enabled: true
|
||||
} as any)
|
||||
const guestOn = await deployApp('f/apps/guest', { execution_mode: 'guest' })
|
||||
expect(guestOn.success).toBe(true)
|
||||
expect(guestOn.message).toContain('identity provider authenticates')
|
||||
|
||||
// Each switch crossed in turn, because either one alone admits nobody and the note
|
||||
// would then announce an exposure that does not exist. Both off needs no case of its
|
||||
// own: whichever half of the check were dropped, one of these two still catches it.
|
||||
vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({
|
||||
guest_access_enabled: true
|
||||
} as any)
|
||||
const instanceOff = await deployApp('f/apps/guest_inst_off', { execution_mode: 'guest' })
|
||||
expect(instanceOff.success).toBe(true)
|
||||
expect(instanceOff.message).not.toContain('identity provider')
|
||||
|
||||
vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({
|
||||
available: true,
|
||||
instance_enabled: true
|
||||
} as any)
|
||||
const workspaceOff = await deployApp('f/apps/guest_ws_off', { execution_mode: 'guest' })
|
||||
expect(workspaceOff.success).toBe(true)
|
||||
expect(workspaceOff.message).not.toContain('identity provider')
|
||||
})
|
||||
|
||||
it('forwards preserve_on_behalf_of when the deployed policy carries an on_behalf_of', async () => {
|
||||
// Without the flag the backend resets the policy's on_behalf_of to the
|
||||
// deploying user; this chat path has no on-behalf-of selector, so it must
|
||||
@@ -6955,8 +7128,7 @@ describe('session-only preview tools gating', () => {
|
||||
expect(names).not.toContain('list_app_runs')
|
||||
expect(names).not.toContain('search_dom')
|
||||
expect(names).not.toContain('read_dom')
|
||||
// Not withheld: without it the side panel's only route to a deployed run is the raw
|
||||
// endpoint, which confirms an opaque request body instead of the arguments.
|
||||
// Not withheld: without it the side panel has no route to a deployed run at all.
|
||||
expect(names).toContain('run_script')
|
||||
// other tools are still present
|
||||
expect(names).toContain('write_script')
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
SqsTriggerService,
|
||||
VariableService,
|
||||
WebsocketTriggerService,
|
||||
WorkerService
|
||||
WorkerService,
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import { createTwoFilesPatch } from 'diff'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
@@ -253,7 +254,6 @@ const VARIABLE_MASKED_NOTE =
|
||||
'Note: variable values are never shown in chat — the diff marks whether the value changed without revealing it.\n\n'
|
||||
const SECRET_UNCOMPARABLE_NOTE =
|
||||
'Note: this is a SECRET variable — its value is never shown and cannot be compared, so it may ALSO have changed beyond what this diff shows.\n\n'
|
||||
import { apiCatalogTools } from './apiCatalogTools'
|
||||
|
||||
const ITEM_TYPES = [
|
||||
'script',
|
||||
@@ -1385,7 +1385,6 @@ ${pipelineBullet}
|
||||
? ' By default it preselects the items this chat modified; pass items ("<kind>:<path>" entries) to control the selection'
|
||||
: ' Pass items ("<kind>:<path>" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace'
|
||||
}, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed.
|
||||
- For a Windmill operation no other tool covers (queue state, a run's args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead.
|
||||
- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's.
|
||||
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive).
|
||||
- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.
|
||||
@@ -1421,8 +1420,9 @@ Flows:
|
||||
- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.
|
||||
|
||||
Raw apps:
|
||||
- The app tools below only work on raw (code) apps. \`rawApp\` says which: false is a drag-and-drop app, which you can list and read but not edit or deploy. Check it before offering to change an app.
|
||||
- The app tools below only work on raw (code) apps. \`rawApp\` says which: false is a drag-and-drop app: you can list it and read its metadata, but not read its contents, edit it or deploy it. Check it before offering to change an app.
|
||||
- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.
|
||||
- A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings.
|
||||
- Use write_app_file, patch_app_file, and delete_app_file for frontend files.
|
||||
- Use write_app_runnable and delete_app_runnable for backend runnables.
|
||||
- Use init_app only after confirming framework, path, and summary with the user.
|
||||
@@ -1533,6 +1533,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown {
|
||||
summary: item.summary,
|
||||
value: summarizeAppValue(item.value as AppDraftValue),
|
||||
rawApp: item.rawApp,
|
||||
executionMode: item.executionMode,
|
||||
isDraft: item.isDraft
|
||||
}
|
||||
}
|
||||
@@ -1829,13 +1830,28 @@ function getInlineRunnableContent(
|
||||
return { content: runnable.inlineScript?.content ?? '', runnable }
|
||||
}
|
||||
|
||||
// appSourceToDraftValue drops a low-code app's `grid`, so converting one here would stage a
|
||||
// code-app draft at its path: a false picture of the app that every later read answers from,
|
||||
// and a deploy the server then refuses.
|
||||
async function getRawAppByPath(workspace: string, path: string): Promise<AppWithLastVersion> {
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
// Only an explicit false: the draft-only branch of get_app carries no version, and so
|
||||
// no `raw_app`, and must not read as low-code.
|
||||
if (app.raw_app === false) {
|
||||
throw new Error(
|
||||
`"${path}" is a low-code app. This chat only edits code-based apps — open it in the app editor instead.`
|
||||
)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
async function loadAppValueForRead(path: string, workspace: string): Promise<AppDraftValue> {
|
||||
const draft = await getGlobalDraft(workspace, 'app', path)
|
||||
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
|
||||
return draft.value as AppDraftValue
|
||||
}
|
||||
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
const app = await getRawAppByPath(workspace, path)
|
||||
return appSourceToDraftValue(app, app)
|
||||
}
|
||||
|
||||
@@ -1845,7 +1861,7 @@ async function loadAppDraftValue(path: string, workspace: string): Promise<Loade
|
||||
return { value: draft.value as AppDraftValue }
|
||||
}
|
||||
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
const app = await getRawAppByPath(workspace, path)
|
||||
return { value: appSourceToDraftValue(app, app) }
|
||||
}
|
||||
|
||||
@@ -1979,6 +1995,35 @@ const triggerServices: Record<TriggerKind, TriggerService> = {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the server would admit a guest here: the deployment has to support guests at
|
||||
* all, and the instance and workspace switches both have to be on. `guest` is stored on
|
||||
* an app even when none of that holds, so the mode alone never settles who can open it.
|
||||
* `undefined` when a switch read fails — neither proven live nor proven inert. */
|
||||
async function guestAccessIsLive(workspace: string): Promise<boolean | undefined> {
|
||||
const [usage, settings] = await Promise.all([
|
||||
WorkspaceService.getGuestUsage({ workspace }).catch(() => undefined),
|
||||
WorkspaceService.getPublicSettings({ workspace }).catch(() => undefined)
|
||||
])
|
||||
if (usage === undefined || settings === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return !!(usage.available && usage.instance_enabled && settings.guest_access_enabled)
|
||||
}
|
||||
|
||||
/** Who may open an app, from the mode stored on it. An app sits in `guest` mode whether
|
||||
* or not anyone is admitted by it, so reporting the mode bare would say strangers can
|
||||
* open an app that admits members only. Say it is inert rather than hide it, as the
|
||||
* app's deploy settings do. */
|
||||
async function describeAppExposure(
|
||||
workspace: string,
|
||||
mode: string | undefined
|
||||
): Promise<string | undefined> {
|
||||
if (mode !== 'guest' || (await guestAccessIsLive(workspace)) !== false) {
|
||||
return mode
|
||||
}
|
||||
return 'guest, but inert: the instance or workspace admits no guest, so this app admits members only'
|
||||
}
|
||||
|
||||
async function readWorkspaceItem(
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
@@ -2041,6 +2086,19 @@ async function readWorkspaceItem(
|
||||
case 'app': {
|
||||
// Returns lightweight metadata only — file/runnable contents come via read_app_file.
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
// A grid is not files and runnables: summarizing one reports an empty app. Name the
|
||||
// kind instead.
|
||||
const executionMode = await describeAppExposure(workspace, app.policy?.execution_mode)
|
||||
if (app.raw_app === false) {
|
||||
return {
|
||||
type: 'app',
|
||||
path: app.path,
|
||||
summary: app.summary,
|
||||
rawApp: false,
|
||||
executionMode,
|
||||
isDraft: false
|
||||
}
|
||||
}
|
||||
const value = appSourceToDraftValue(app)
|
||||
const metadata = summarizeAppValue(value)
|
||||
return {
|
||||
@@ -2049,6 +2107,7 @@ async function readWorkspaceItem(
|
||||
summary: value.summary,
|
||||
value: metadata as unknown as AppDraftValue,
|
||||
rawApp: app.raw_app,
|
||||
executionMode,
|
||||
isDraft: false
|
||||
}
|
||||
}
|
||||
@@ -3473,7 +3532,17 @@ export const globalTools: Tool<{}>[] = [
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Read draft ${parsed.type} "${parsed.path}"`
|
||||
})
|
||||
return JSON.stringify(serializeWorkspaceItemForRead(draft), null, 2)
|
||||
// The mode reported is the draft's, which is what deploying it will write. The
|
||||
// deployed app's own mode is a different question, asked with version:
|
||||
// "deployed", and must not be fetched here.
|
||||
const executionMode =
|
||||
parsed.type === 'app'
|
||||
? await describeAppExposure(
|
||||
workspace,
|
||||
(draft.value as AppDraftValue)?.policy?.execution_mode
|
||||
)
|
||||
: undefined
|
||||
return JSON.stringify(serializeWorkspaceItemForRead({ ...draft, executionMode }), null, 2)
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
@@ -4356,10 +4425,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
// Workspace DuckLake: pipeline storage prerequisite, and declared measures
|
||||
...getDucklakeTools(),
|
||||
// Read-only tools over files the user attached to the conversation
|
||||
...fileTools,
|
||||
// Search + call access to the backend API endpoint catalog, for operations
|
||||
// no dedicated tool covers
|
||||
...apiCatalogTools
|
||||
...fileTools
|
||||
]
|
||||
|
||||
// Tools that only make sense inside an AI session (they drive the session's
|
||||
@@ -7995,6 +8061,27 @@ async function deployDraft(
|
||||
`not working until they are.`
|
||||
}
|
||||
|
||||
// `policy` is the mode being written, so the exposure is stated from the value in
|
||||
// hand. What a runnable runs as is the server's to decide, so the note names who
|
||||
// can reach the app and never an identity.
|
||||
if (policy.execution_mode === 'anonymous') {
|
||||
deployNote =
|
||||
`${deployNote ? `${deployNote} ` : ''}This app is deployed as anonymous: its ` +
|
||||
`backend runnables are now reachable by anyone with the URL, without logging in. ` +
|
||||
`Tell the user plainly what is now reachable and by whom.`
|
||||
} else if (policy.execution_mode === 'guest') {
|
||||
// Only where the guest door actually opens: below that, silence — a false note
|
||||
// is worse than none. The standing cap is a live count no read settles, so the
|
||||
// note says the door is open, not that every newcomer gets in.
|
||||
if (await guestAccessIsLive(workspace)) {
|
||||
deployNote =
|
||||
`${deployNote ? `${deployNote} ` : ''}This app is deployed as guest: anyone ` +
|
||||
`the instance's identity provider authenticates can now open it and run its ` +
|
||||
`backend runnables, member of this workspace or not. Tell the user plainly ` +
|
||||
`what is now reachable and by whom.`
|
||||
}
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Bundling app "${path}"...`
|
||||
})
|
||||
|
||||
@@ -400,8 +400,7 @@ function shapeRunResult(job: Job): Record<string, unknown> {
|
||||
: { result: cap(stringify(job.result)) }
|
||||
}
|
||||
|
||||
/** Why a run ended badly. get_run is the only route to these — the catalog
|
||||
* refuses getJob. Kept out of summarizeRun, which list_runs pays per job. */
|
||||
/** Why a run ended badly. Kept out of summarizeRun, which list_runs pays per job. */
|
||||
function diagnoseRun(job: Job): Record<string, unknown> {
|
||||
return {
|
||||
...(job.canceled_by ? { canceled_by: job.canceled_by } : {}),
|
||||
|
||||
@@ -143,6 +143,14 @@ export type WorkspaceItem = {
|
||||
* editor. The two are edited by disjoint tool sets, so the distinction has to
|
||||
* reach the model before it picks one. */
|
||||
rawApp?: boolean
|
||||
/** Apps only. Who may open the app — read from a draft, who may open it once
|
||||
* that draft is deployed: `anonymous` is anyone with the URL and no login,
|
||||
* `guest` anyone the identity provider authenticates, the rest a workspace
|
||||
* member. A `guest` app whose instance or workspace admits
|
||||
* no guest says so here, since the mode is stored either way. Reported per
|
||||
* app, not in listings — a listing carrying it would make absence read as
|
||||
* proof of no exposure. */
|
||||
executionMode?: string
|
||||
isDraft: boolean
|
||||
isLiveDraft?: boolean
|
||||
}
|
||||
|
||||
@@ -22,9 +22,10 @@ import {
|
||||
} from '../shared'
|
||||
import { createWorkspaceMutationTools } from '../workspaceTools'
|
||||
import { setupTypeAcquisition, type DepsToGet } from '$lib/ata'
|
||||
import { getModelContextWindow } from '../../modelConfig'
|
||||
import { getEffectiveModelContextWindow } from '../../modelConfig'
|
||||
import type { ReviewChangesOpts } from '../monaco-adapter'
|
||||
import { getCurrentModel } from '$lib/aiStore'
|
||||
import { copilotInfo, getCurrentModel } from '$lib/aiStore'
|
||||
import { get } from 'svelte/store'
|
||||
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata'
|
||||
import { getScriptPrompt, getWorkflowAsCodePrompt } from '$system_prompts'
|
||||
|
||||
@@ -548,7 +549,11 @@ export async function searchExternalIntegrationResources(args: { query: string }
|
||||
)
|
||||
|
||||
const model = getCurrentModel()
|
||||
const modelContextWindow = getModelContextWindow(model.model)
|
||||
const modelContextWindow = getEffectiveModelContextWindow(
|
||||
model.provider,
|
||||
model.model,
|
||||
get(copilotInfo).contextWindowPerModel
|
||||
)
|
||||
const results: PackageSearchResult[] = await Promise.all(
|
||||
filtered.map(async (r: PackageSearchQuery) => {
|
||||
let documentation = ''
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { parseFimCompletionChoice } from './fim'
|
||||
import {
|
||||
getKnownModelContextWindow,
|
||||
getModelContextWindow,
|
||||
getModelContextWindowFromTable,
|
||||
modelSupportsVision,
|
||||
requiresMaxCompletionTokens
|
||||
} from './modelConfig'
|
||||
@@ -289,12 +289,12 @@ describe('model context windows', () => {
|
||||
// a version between "qwen3" and "-max" must not claim the 256K entry, and
|
||||
// there is deliberately no qwen family entry (variant windows range 8K–1M)
|
||||
expect(getKnownModelContextWindow('qwen3.8-max')).toBeUndefined()
|
||||
expect(getModelContextWindow('qwen3.8-max')).toBe(128000)
|
||||
expect(getModelContextWindowFromTable('qwen3.8-max')).toBe(128000)
|
||||
})
|
||||
|
||||
it('returns undefined for unrecognized models, 128K via the defaulting wrapper', () => {
|
||||
expect(getKnownModelContextWindow('some-custom-model')).toBeUndefined()
|
||||
expect(getModelContextWindow('some-custom-model')).toBe(128000)
|
||||
expect(getModelContextWindowFromTable('some-custom-model')).toBe(128000)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { usesAnthropicMessagesApi } from './modelConfig'
|
||||
import {
|
||||
getConfiguredModelContextWindow,
|
||||
getEffectiveModelContextWindow,
|
||||
usesAnthropicMessagesApi
|
||||
} from './modelConfig'
|
||||
|
||||
describe('workspace context window overrides', () => {
|
||||
const overrides = { 'customai:qwen-local': 32_768, 'anthropic:claude-sonnet-5': 200_000 }
|
||||
|
||||
it('wins over the built-in table and the assumed window for its exact provider:model', () => {
|
||||
expect(getEffectiveModelContextWindow('customai', 'qwen-local', overrides)).toBe(32_768)
|
||||
expect(getConfiguredModelContextWindow('customai', 'qwen-local', overrides)).toBe(32_768)
|
||||
expect(getEffectiveModelContextWindow('anthropic', 'claude-sonnet-5', overrides)).toBe(200_000)
|
||||
})
|
||||
|
||||
it('does not apply to the same model id served by another provider', () => {
|
||||
expect(getEffectiveModelContextWindow('openrouter', 'claude-sonnet-5', overrides)).toBe(
|
||||
1_000_000
|
||||
)
|
||||
expect(getConfiguredModelContextWindow('openai', 'qwen-local', overrides)).toBeUndefined()
|
||||
expect(getEffectiveModelContextWindow('openai', 'qwen-local', overrides)).toBe(128_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usesAnthropicMessagesApi', () => {
|
||||
it('routes the native Anthropic provider through the Messages API', () => {
|
||||
|
||||
@@ -66,9 +66,9 @@ export function requiresMaxCompletionTokens(model: string) {
|
||||
// ids (anthropic.claude-sonnet-4-6-...-v1:0, gpt-5.2-2026-01-01) still resolve.
|
||||
// Conservative family fallbacks sit below the explicit entries; models not
|
||||
// listed at all resolve to undefined. Consumers that need a number regardless
|
||||
// (trim/compaction, the usage indicator) go through getModelContextWindow,
|
||||
// whose conservative 128K fallback keeps a limit enforced and is surfaced to
|
||||
// the user as an assumed window.
|
||||
// (trim/compaction, the usage indicator) go through
|
||||
// getEffectiveModelContextWindow, whose conservative 128K fallback keeps a
|
||||
// limit enforced and is surfaced to the user as an assumed window.
|
||||
const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [
|
||||
// Anthropic — Sonnet/Opus 4.6+ ship a 1M window at standard pricing (GA);
|
||||
// Haiku, older Claude models (3.x, 4.0, 4.1, 4.5) and date-suffixed Claude 4
|
||||
@@ -162,9 +162,7 @@ export function buildModelMatchers<T>(
|
||||
// separator is normalized. Only a short segment: a date is digits as well
|
||||
// (`-20251101`) and stays a decoration.
|
||||
strictVariants ? '(?!-\\d{1,3}(?:$|-))' : '',
|
||||
strictVariants
|
||||
? `(?!-(?!(?:v\\d|${DECORATIVE_SUFFIXES.join('|')})$)[a-z])`
|
||||
: ''
|
||||
strictVariants ? `(?!-(?!(?:v\\d|${DECORATIVE_SUFFIXES.join('|')})$)[a-z])` : ''
|
||||
].join('')
|
||||
return [new RegExp(pattern + guards), value]
|
||||
})
|
||||
@@ -172,8 +170,9 @@ export function buildModelMatchers<T>(
|
||||
|
||||
/**
|
||||
* The `provider:model` key the workspace AI settings use for their per-model maps
|
||||
* (`max_tokens_per_model`, `model_pricing`). A bare model id is not enough: the
|
||||
* same id can be served by more than one provider at different rates.
|
||||
* (`max_tokens_per_model`, `model_pricing`, `context_window_per_model`). A bare
|
||||
* model id is not enough: the same id can be served by more than one provider at
|
||||
* different rates.
|
||||
*
|
||||
* Matched exactly, unlike the fuzzy tables above. Those tables generalize across
|
||||
* every route to one model on purpose; a per-model *setting* must not, or an
|
||||
@@ -196,9 +195,41 @@ export function getKnownModelContextWindow(model: string): number | undefined {
|
||||
return matchModel(MODEL_CONTEXT_WINDOW_MATCHERS, model)
|
||||
}
|
||||
|
||||
export function getModelContextWindow(model: string) {
|
||||
// Trim/compaction logic needs a number; assume a conservative window when unknown.
|
||||
return getKnownModelContextWindow(model) ?? 128000
|
||||
/** Trim/compaction logic needs a number; assume a conservative window when unknown. */
|
||||
export const ASSUMED_CONTEXT_WINDOW = 128000
|
||||
|
||||
/**
|
||||
* What the built-in table states for a model, assuming a conservative window for one
|
||||
* it does not list. Table only, so it ignores the workspace's `context_window_per_model`
|
||||
* — deciding how much context to send goes through `getEffectiveModelContextWindow`
|
||||
* instead. Named for the table because a plain `getModelContextWindow` is the obvious
|
||||
* thing to reach for, and reaching for it here would silently skip the override.
|
||||
*/
|
||||
export function getModelContextWindowFromTable(model: string) {
|
||||
return getKnownModelContextWindow(model) ?? ASSUMED_CONTEXT_WINDOW
|
||||
}
|
||||
|
||||
/**
|
||||
* The context window stated for a model: the workspace's override
|
||||
* (`context_window_per_model`, passed in so this module stays free of stores)
|
||||
* when one is set, otherwise the built-in table. Undefined only when neither
|
||||
* knows the model, which is when a consumer falls back to an assumed window.
|
||||
*/
|
||||
export function getConfiguredModelContextWindow(
|
||||
provider: AIProvider,
|
||||
model: string,
|
||||
overrides: Record<string, number> | undefined
|
||||
): number | undefined {
|
||||
return overrides?.[modelKey(provider, model)] ?? getKnownModelContextWindow(model)
|
||||
}
|
||||
|
||||
/** The window to budget against: the override, then the table, then the assumption. */
|
||||
export function getEffectiveModelContextWindow(
|
||||
provider: AIProvider,
|
||||
model: string,
|
||||
overrides: Record<string, number> | undefined
|
||||
): number {
|
||||
return getConfiguredModelContextWindow(provider, model, overrides) ?? ASSUMED_CONTEXT_WINDOW
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -114,7 +114,12 @@
|
||||
<span class="text-xs text-secondary">Loading…</span>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if !info.editable}
|
||||
{#if info.clone}
|
||||
<span class="text-xs text-secondary">
|
||||
Read only: this data table is a clone, and its owners and grants stay as they were copied
|
||||
from the data table it was cloned from.
|
||||
</span>
|
||||
{:else if !info.editable}
|
||||
<span class="text-xs text-secondary">
|
||||
Read only: access is changed by the admins of the workspace that governs this data table, on
|
||||
Windmill Enterprise Edition.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const request = vi.fn()
|
||||
vi.mock('$lib/gen/core/request', () => ({ request: (...args: unknown[]) => request(...args) }))
|
||||
vi.mock('$lib/gen', () => ({ OpenAPI: {} }))
|
||||
vi.mock('$lib/cloud', () => ({ isCloudHosted: () => false }))
|
||||
|
||||
import { listUsableDatatableRoles } from './datatableUsableRoles'
|
||||
|
||||
const refuse = (body: string) => () =>
|
||||
Promise.reject(Object.assign(new Error('Bad Request'), { body }))
|
||||
|
||||
describe('listUsableDatatableRoles', () => {
|
||||
// The refusal is recognised by its sentence, so rewording it on one side alone would turn every
|
||||
// data table on a build without the Enterprise Edition into a failed lookup rather than one
|
||||
// that is not under roles.
|
||||
it('reads the enterprise refusal as not under roles, and rethrows anything else', async () => {
|
||||
request.mockImplementation(refuse('Data table roles are a Windmill Enterprise Edition feature'))
|
||||
expect(await listUsableDatatableRoles('ws', 'main')).toEqual({
|
||||
permissioned: false,
|
||||
roles: [],
|
||||
default_role: 'admin'
|
||||
})
|
||||
|
||||
request.mockImplementation(refuse('Data table not found'))
|
||||
const rethrown = await listUsableDatatableRoles('ws', 'main').catch((e) => e)
|
||||
expect(rethrown).toBeInstanceOf(Error)
|
||||
})
|
||||
})
|
||||
@@ -47,9 +47,12 @@
|
||||
)
|
||||
|
||||
const datatableItems = $derived(toDatatableItems(datatables.current))
|
||||
// Until the answer is for this data table and role, the schemas in hand belong to another.
|
||||
// Until the answer is for this workspace, data table and role, the schemas in hand belong to
|
||||
// another.
|
||||
const schemaItems = $derived(
|
||||
access.current.datatable === datatable && access.current.role === role
|
||||
access.current.workspace === opWs &&
|
||||
access.current.datatable === datatable &&
|
||||
access.current.role === role
|
||||
? toSchemaItems(access.current.schemas)
|
||||
: []
|
||||
)
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
if (!isOpen || !workspace || !datatable) return undefined
|
||||
try {
|
||||
return {
|
||||
workspace,
|
||||
datatable,
|
||||
...(await listUsableDatatableRoles(workspace, datatable))
|
||||
}
|
||||
@@ -93,6 +94,7 @@
|
||||
// that is refused.
|
||||
console.error('Failed to load datatable roles:', e)
|
||||
return {
|
||||
workspace,
|
||||
datatable,
|
||||
permissioned: false,
|
||||
roles: [] as string[],
|
||||
@@ -102,9 +104,12 @@
|
||||
}
|
||||
)
|
||||
|
||||
// A resource keeps its previous value while it refetches, and roles are per data table.
|
||||
// A resource keeps its previous value while it refetches, and roles are per data table of one
|
||||
// workspace: a fork and its parent both have a `main`, with their own roles on it.
|
||||
const rolesOfCurrent = $derived(
|
||||
usableRoles.current?.datatable === selectedDatatable ? usableRoles.current : undefined
|
||||
usableRoles.current?.datatable === selectedDatatable && usableRoles.current?.workspace === opWs
|
||||
? usableRoles.current
|
||||
: undefined
|
||||
)
|
||||
|
||||
// Mounting the manager fires its first queries, so it waits for the role: a round sent
|
||||
@@ -210,11 +215,17 @@
|
||||
)
|
||||
}
|
||||
|
||||
function selectDatatable(datatable: string, role?: string) {
|
||||
// `undefined` where the workspace has no data table at all: the drawer opens on none and
|
||||
// shows its placeholder, rather than on a name that does not exist.
|
||||
function selectDatatable(datatable: string | undefined, role?: string) {
|
||||
// A row clicked under another data table has just set the selection it should open on.
|
||||
openSchemaKey = selectedSchemaKey
|
||||
openTableKey = selectedTableKey
|
||||
selectedDatatable = datatable
|
||||
if (datatable === undefined) {
|
||||
selectedRole = undefined
|
||||
return
|
||||
}
|
||||
// A data table opens as the role its picked tables were browsed as, else the one the app
|
||||
// already uses it through.
|
||||
connectAs(
|
||||
|
||||
@@ -747,6 +747,8 @@
|
||||
data = next
|
||||
aiChatManager.datatableCreationPolicy = {
|
||||
...aiChatManager.datatableCreationPolicy,
|
||||
// As on load: data that names no data table leaves nothing to create tables in.
|
||||
enabled: next.datatable !== undefined,
|
||||
datatable: next.datatable,
|
||||
schema: next.schema,
|
||||
roles: next.roles
|
||||
|
||||
@@ -86,10 +86,13 @@
|
||||
let selectedRole = $state<string | undefined>(undefined)
|
||||
|
||||
// Every reader waits for an answer stamped with the current selection: until then `current`
|
||||
// belongs to the previous data table or role.
|
||||
// belongs to the previous workspace, data table or role.
|
||||
// A data table whose name cannot carry a role in a reference is used as its default one.
|
||||
const rolesAnswered = $derived(
|
||||
roles.current.workspace === opWs && roles.current.datatable === selectedDatatable
|
||||
)
|
||||
const loadedRoles = $derived(
|
||||
roles.current.datatable === selectedDatatable &&
|
||||
rolesAnswered &&
|
||||
selectedDatatable !== undefined &&
|
||||
datatableNameTakesRole(selectedDatatable)
|
||||
? roles.current.roles
|
||||
@@ -114,8 +117,7 @@
|
||||
const hasNoDatatables = $derived(availableDatatables?.length === 0)
|
||||
|
||||
const rolesSettled = $derived(
|
||||
hasNoDatatables ||
|
||||
(selectedDatatable !== undefined && roles.current.datatable === selectedDatatable)
|
||||
hasNoDatatables || (selectedDatatable !== undefined && rolesAnswered)
|
||||
)
|
||||
|
||||
// A role is picked on one data table: two data tables can both define an `analyst` that
|
||||
@@ -123,7 +125,7 @@
|
||||
let rolesPickedOn = $state<string | undefined>(undefined)
|
||||
$effect(() => {
|
||||
const loaded = roles.current
|
||||
if (loaded.datatable !== selectedDatatable) return
|
||||
if (!rolesAnswered) return
|
||||
const switched = untrack(() => rolesPickedOn) !== selectedDatatable
|
||||
const current = untrack(() => selectedRole)
|
||||
if (switched || current === undefined || !loaded.roles.includes(current)) {
|
||||
@@ -173,6 +175,7 @@
|
||||
hasNoDatatables ||
|
||||
(rolesSettled &&
|
||||
selectedDatatable !== undefined &&
|
||||
access.current.workspace === opWs &&
|
||||
access.current.datatable === selectedDatatable &&
|
||||
access.current.role === effectiveRole)
|
||||
)
|
||||
|
||||
@@ -40,8 +40,9 @@ export function createDatatablesResource(getWorkspace: () => string | undefined)
|
||||
}
|
||||
|
||||
export type DatatableRoles = {
|
||||
/** The data table this answers for: while a switch is in flight, `current` still holds the
|
||||
* previous one's roles, which say nothing about the one now selected. */
|
||||
/** The workspace and data table this answers for: while a switch is in flight, `current` still
|
||||
* holds the previous ones' roles, and a fork and its parent both have a `main` of their own. */
|
||||
workspace: string | undefined
|
||||
datatable: string | undefined
|
||||
/** Whether the data table is under roles. Without roles `roles` is empty because there is
|
||||
* nothing to pick, which is not the same as a permissioned one this caller may use no role of. */
|
||||
@@ -61,6 +62,7 @@ export function createRolesResource(
|
||||
getWorkspace: () => string | undefined = () => get(workspaceStore)
|
||||
) {
|
||||
const initialValue: DatatableRoles = {
|
||||
workspace: undefined,
|
||||
datatable: undefined,
|
||||
permissioned: false,
|
||||
failed: false,
|
||||
@@ -71,7 +73,11 @@ export function createRolesResource(
|
||||
() => [getDatatable() ?? '', getWorkspace() ?? ''] as const,
|
||||
latestOnly(
|
||||
async ([datatableName, workspace]: readonly [string, string]): Promise<DatatableRoles> => {
|
||||
const empty = { ...initialValue, datatable: datatableName || undefined }
|
||||
const empty = {
|
||||
...initialValue,
|
||||
workspace: workspace || undefined,
|
||||
datatable: datatableName || undefined
|
||||
}
|
||||
if (!datatableName || !workspace) return empty
|
||||
try {
|
||||
const res = await listUsableDatatableRoles(workspace, datatableName)
|
||||
@@ -94,8 +100,9 @@ export function createRolesResource(
|
||||
}
|
||||
|
||||
export type DatatableAccess = {
|
||||
/** What this answers for. Until both match the selection, the schemas and the right to
|
||||
* create one belong to another data table or another role. */
|
||||
/** What this answers for. Until all three match the selection, the schemas and the right to
|
||||
* create one belong to another workspace, data table or role. */
|
||||
workspace: string | undefined
|
||||
datatable: string | undefined
|
||||
role: string | undefined
|
||||
/** The request failed, or the server kept the entry with an error (a role this caller may
|
||||
@@ -119,6 +126,7 @@ export function createDatatableAccessResource(
|
||||
getReady: () => boolean = () => true
|
||||
) {
|
||||
const initialValue: DatatableAccess = {
|
||||
workspace: undefined,
|
||||
datatable: undefined,
|
||||
role: undefined,
|
||||
failed: false,
|
||||
@@ -137,6 +145,7 @@ export function createDatatableAccessResource(
|
||||
]): Promise<DatatableAccess> => {
|
||||
const asked = {
|
||||
...initialValue,
|
||||
workspace: workspace || undefined,
|
||||
datatable: datatable || undefined,
|
||||
role: role || undefined
|
||||
}
|
||||
|
||||
@@ -11,7 +11,16 @@
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { AI_PROVIDERS, fetchAvailableModels, providerSupportsWebSearch } from '../copilot/lib'
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
fetchAvailableModels,
|
||||
getModelMaxTokens,
|
||||
providerSupportsWebSearch
|
||||
} from '../copilot/lib'
|
||||
import {
|
||||
getKnownModelContextWindow,
|
||||
getModelContextWindowFromTable
|
||||
} from '../copilot/modelConfig'
|
||||
import { supportsAutocomplete } from '../copilot/utils'
|
||||
import TestAiKey from '../copilot/TestAIKey.svelte'
|
||||
import Label from '../Label.svelte'
|
||||
@@ -77,6 +86,7 @@
|
||||
let metadataModel: string | undefined = $state(undefined)
|
||||
let customPrompts: Record<string, string> = $state({})
|
||||
let maxTokensPerModel: Record<string, number> = $state({})
|
||||
let contextWindowPerModel: Record<string, number> = $state({})
|
||||
let modelPricing: Record<string, ModelPriceOverride> = $state({})
|
||||
let usingOpenaiClientCredentialsOauth = $state(false)
|
||||
let workspaceOverrideEditorOpened = $state(false)
|
||||
@@ -91,6 +101,7 @@
|
||||
let initialMetadataModel: string | undefined = $state(undefined)
|
||||
let initialCustomPrompts: Record<string, string> = $state({})
|
||||
let initialMaxTokensPerModel: Record<string, number> = $state({})
|
||||
let initialContextWindowPerModel: Record<string, number> = $state({})
|
||||
let initialModelPricing: Record<string, ModelPriceOverride> = $state({})
|
||||
let initialPrompts: Record<string, string> = $state({})
|
||||
let initialCopilotDisabled = $state(false)
|
||||
@@ -122,6 +133,7 @@
|
||||
codeCompletionModel = config?.code_completion_model?.model
|
||||
customPrompts = clone(config?.custom_prompts ?? {})
|
||||
maxTokensPerModel = clone(config?.max_tokens_per_model ?? {})
|
||||
contextWindowPerModel = clone(config?.context_window_per_model ?? {})
|
||||
modelPricing = clone(config?.model_pricing ?? {})
|
||||
copilotDisabled = config?.copilot_disabled === true
|
||||
sessionsStorageDisabled = config?.sessions_storage_disabled === true
|
||||
@@ -140,6 +152,7 @@
|
||||
initialCodeCompletionModel = codeCompletionModel
|
||||
initialCustomPrompts = clone(customPrompts)
|
||||
initialMaxTokensPerModel = clone(maxTokensPerModel)
|
||||
initialContextWindowPerModel = clone(contextWindowPerModel)
|
||||
initialModelPricing = clone(modelPricing)
|
||||
initialPrompts = clone(customPrompts)
|
||||
initialCopilotDisabled = copilotDisabled
|
||||
@@ -159,6 +172,7 @@
|
||||
codeCompletionModel = initialCodeCompletionModel
|
||||
customPrompts = clone(initialCustomPrompts)
|
||||
maxTokensPerModel = clone(initialMaxTokensPerModel)
|
||||
contextWindowPerModel = clone(initialContextWindowPerModel)
|
||||
modelPricing = clone(initialModelPricing)
|
||||
copilotDisabled = initialCopilotDisabled
|
||||
sessionsStorageDisabled = initialSessionsStorageDisabled
|
||||
@@ -197,6 +211,7 @@
|
||||
codeCompletionModel !== initialCodeCompletionModel ||
|
||||
JSON.stringify(customPrompts) !== JSON.stringify(initialCustomPrompts) ||
|
||||
JSON.stringify(maxTokensPerModel) !== JSON.stringify(initialMaxTokensPerModel) ||
|
||||
JSON.stringify(contextWindowPerModel) !== JSON.stringify(initialContextWindowPerModel) ||
|
||||
JSON.stringify(modelPricing) !== JSON.stringify(initialModelPricing) ||
|
||||
copilotDisabled !== initialCopilotDisabled ||
|
||||
sessionsStorageDisabled !== initialSessionsStorageDisabled ||
|
||||
@@ -319,6 +334,8 @@
|
||||
custom_prompts: Object.keys(custom_prompts).length > 0 ? custom_prompts : undefined,
|
||||
max_tokens_per_model:
|
||||
Object.keys(maxTokensPerModel).length > 0 ? maxTokensPerModel : undefined,
|
||||
context_window_per_model:
|
||||
Object.keys(contextWindowPerModel).length > 0 ? contextWindowPerModel : undefined,
|
||||
model_pricing: Object.keys(modelPricing).length > 0 ? modelPricing : undefined,
|
||||
copilot_disabled,
|
||||
sessions_storage_disabled,
|
||||
@@ -625,7 +642,31 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ModelTokenLimits {aiProviders} bind:maxTokensPerModel />
|
||||
<ModelTokenLimits
|
||||
{aiProviders}
|
||||
bind:limits={maxTokensPerModel}
|
||||
label="Model output limits"
|
||||
description="Configure maximum token limits for each model. These limits apply to all AI chat interactions in the workspace."
|
||||
min={1}
|
||||
max={2_000_000}
|
||||
getDefault={(provider, model) => ({
|
||||
tokens: getModelMaxTokens(provider, model),
|
||||
assumed: false
|
||||
})}
|
||||
/>
|
||||
|
||||
<ModelTokenLimits
|
||||
{aiProviders}
|
||||
bind:limits={contextWindowPerModel}
|
||||
label="Model context windows"
|
||||
description="The context size AI chat plans for with each model: older messages are summarized as a conversation nears it. Set it for models the built-in list does not know, such as local models served through Custom AI."
|
||||
min={1024}
|
||||
max={10_000_000}
|
||||
getDefault={(_, model) => ({
|
||||
tokens: getModelContextWindowFromTable(model),
|
||||
assumed: getKnownModelContextWindow(model) === undefined
|
||||
})}
|
||||
/>
|
||||
|
||||
<SettingCard label="Custom system prompts" description={promptDescription}>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
|
||||
@@ -380,7 +380,7 @@
|
||||
failedSyncJobs = []
|
||||
forkCreationError = ''
|
||||
|
||||
// Clone datatables BEFORE creating the workspace fork
|
||||
// Each data table copy is confirmed first; the fork request makes the copies
|
||||
if (forkDatatableSection) {
|
||||
const queue = forkDatatableSection.buildCloneQueue(prefixed_id)
|
||||
if (queue.length > 0) {
|
||||
@@ -396,6 +396,15 @@
|
||||
}
|
||||
|
||||
async function completeFork(prefixed_id: string): Promise<void> {
|
||||
// The fork request makes these copies, and drops them if the fork is not created
|
||||
const forkedDatatables = forkDatatableSection
|
||||
? forkDatatableSection.getConfirmedCloneJobs().map((job) => ({
|
||||
name: job.name,
|
||||
new_dbname: job._newDbName,
|
||||
fork_behavior: job.behavior
|
||||
}))
|
||||
: []
|
||||
|
||||
let gitSyncJobIds: string[]
|
||||
try {
|
||||
gitSyncJobIds = await WorkspaceService.createWorkspaceForkGitBranch({
|
||||
@@ -404,6 +413,9 @@
|
||||
id: prefixed_id,
|
||||
name,
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
|
||||
// Sent in this first phase too, so a clone the fork would refuse is refused before
|
||||
// any branch is created.
|
||||
forked_datatables: forkedDatatables,
|
||||
is_dev_workspace: createAsDevWorkspace,
|
||||
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
|
||||
// Send the lock intent in this first phase too so the backend can reject a non-admin's
|
||||
@@ -457,14 +469,6 @@
|
||||
return
|
||||
}
|
||||
|
||||
// Build forked_datatables info from completed clone jobs
|
||||
const forkedDatatables = forkDatatableSection
|
||||
? forkDatatableSection.getCompletedCloneJobs().map((job) => ({
|
||||
name: job.name,
|
||||
new_dbname: job._newDbName
|
||||
}))
|
||||
: []
|
||||
|
||||
try {
|
||||
await WorkspaceService.createWorkspaceFork({
|
||||
workspace: baseWorkspaceId!,
|
||||
|
||||
@@ -258,6 +258,12 @@
|
||||
data table backed by that database can use one. This one is backed by a PostgreSQL
|
||||
resource — grant access on that server directly.
|
||||
</Alert>
|
||||
{:else if info?.clone_of}
|
||||
<Alert type="info" title="Governed by {info.clone_of.workspace_id}" size="xs">
|
||||
This data table is a clone of <span class="font-mono">{info.clone_of.datatable}</span>
|
||||
in workspace <span class="font-mono">{info.clone_of.workspace_id}</span>, so it takes
|
||||
its roles from there. You are evaluated as a member of that workspace.
|
||||
</Alert>
|
||||
{:else if governing}
|
||||
<Alert type="info" title="Governed by {governing}" size="xs">
|
||||
This data table points at the one in workspace <span class="font-mono">{governing}</span
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
<script module lang="ts">
|
||||
export type ForkStep = {
|
||||
label: string
|
||||
status: 'pending' | 'running' | 'done' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type DatatableCloneJob = {
|
||||
name: string
|
||||
resourceType: string
|
||||
behavior: 'schema_only' | 'schema_and_data'
|
||||
steps: ForkStep[]
|
||||
_newDbName: string
|
||||
_isInstance: boolean
|
||||
_sourceWorkspace: string
|
||||
_targetWorkspace: string
|
||||
_resourcePath: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { workspaceStore, userStore, enterpriseLicense } from '$lib/stores'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { resource } from 'runed'
|
||||
import Select from '../select/Select.svelte'
|
||||
import Label from '../Label.svelte'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { Check, X, Loader2 } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
// Workspace whose datatables are cloned into the fork (the fork's base). Falls back to the
|
||||
@@ -52,111 +40,46 @@
|
||||
let cloneModalOpen = $state(false)
|
||||
let currentCloneJob: DatatableCloneJob | undefined = $state(undefined)
|
||||
let cloneQueue: DatatableCloneJob[] = $state([])
|
||||
let cloneRunning = $state(false)
|
||||
|
||||
export function hasDatatables(): boolean {
|
||||
return (allDatatables.current?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
export function buildCloneQueue(targetWorkspaceId: string): DatatableCloneJob[] {
|
||||
// A fork attempt starts here: what an earlier attempt confirmed is not this one's to send.
|
||||
confirmedJobs = []
|
||||
return (allDatatables.current ?? [])
|
||||
.filter((dt) => {
|
||||
const behavior = datatableBehaviors[dt.name] ?? 'keep_original'
|
||||
return behavior !== 'keep_original'
|
||||
})
|
||||
.map((dt) => {
|
||||
const behavior = datatableBehaviors[dt.name] as 'schema_only' | 'schema_and_data'
|
||||
const isInstance = dt.resource_type === 'instance'
|
||||
const newDbName = `${targetWorkspaceId.replace(/-/g, '_')}__${dt.name}`
|
||||
|
||||
const steps: ForkStep[] = [
|
||||
{
|
||||
label: `CREATE DATABASE "${newDbName}"`,
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
label: `pg_dump → pg_import (${behavior === 'schema_only' ? 'schema only' : 'schema + data'})`,
|
||||
status: 'pending'
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
name: dt.name,
|
||||
resourceType: dt.resource_type,
|
||||
behavior,
|
||||
steps,
|
||||
_newDbName: newDbName,
|
||||
_isInstance: isInstance,
|
||||
_sourceWorkspace: effectiveSource!,
|
||||
_targetWorkspace: targetWorkspaceId,
|
||||
_resourcePath: dt.resource_path
|
||||
}
|
||||
})
|
||||
.map((dt) => ({
|
||||
name: dt.name,
|
||||
resourceType: dt.resource_type,
|
||||
behavior: datatableBehaviors[dt.name] as 'schema_only' | 'schema_and_data',
|
||||
// A dev workspace's id has no `wm-fork-` prefix; its copies are named like a fork's
|
||||
_newDbName: `wm_fork_${targetWorkspaceId.replace(/^wm-fork-/, '').replace(/-/g, '_')}__${dt.name}`
|
||||
}))
|
||||
}
|
||||
|
||||
let completedJobs: DatatableCloneJob[] = $state([])
|
||||
let confirmedJobs: DatatableCloneJob[] = $state([])
|
||||
|
||||
// Each clone is only confirmed here: the fork request makes the copies, and drops them if the
|
||||
// fork is not created.
|
||||
export function startCloning(queue: DatatableCloneJob[]) {
|
||||
completedJobs = []
|
||||
confirmedJobs = []
|
||||
cloneQueue = queue
|
||||
currentCloneJob = cloneQueue[0]
|
||||
cloneModalOpen = true
|
||||
}
|
||||
|
||||
export function getCompletedCloneJobs(): DatatableCloneJob[] {
|
||||
return completedJobs
|
||||
}
|
||||
|
||||
async function executeCloneJob(job: DatatableCloneJob) {
|
||||
cloneRunning = true
|
||||
let stepIdx = 0
|
||||
|
||||
// Step 1: Create the database
|
||||
job.steps[stepIdx].status = 'running'
|
||||
try {
|
||||
await WorkspaceService.createPgDatabase({
|
||||
workspace: job._sourceWorkspace,
|
||||
requestBody: {
|
||||
source: `datatable://${job.name}`,
|
||||
target_dbname: job._newDbName
|
||||
}
|
||||
})
|
||||
job.steps[stepIdx].status = 'done'
|
||||
} catch (e: any) {
|
||||
job.steps[stepIdx].status = 'error'
|
||||
job.steps[stepIdx].error = e?.body ?? e?.message ?? String(e)
|
||||
cloneRunning = false
|
||||
return
|
||||
}
|
||||
stepIdx++
|
||||
|
||||
// Step 2: Import data
|
||||
job.steps[stepIdx].status = 'running'
|
||||
try {
|
||||
await WorkspaceService.importPgDatabase({
|
||||
workspace: job._sourceWorkspace,
|
||||
requestBody: {
|
||||
source: `datatable://${job.name}`,
|
||||
target: `datatable://${job.name}`,
|
||||
target_dbname_override: job._newDbName,
|
||||
fork_behavior: job.behavior
|
||||
}
|
||||
})
|
||||
job.steps[stepIdx].status = 'done'
|
||||
} catch (e: any) {
|
||||
job.steps[stepIdx].status = 'error'
|
||||
job.steps[stepIdx].error = e?.body ?? e?.message ?? String(e)
|
||||
cloneRunning = false
|
||||
return
|
||||
}
|
||||
stepIdx++
|
||||
|
||||
cloneRunning = false
|
||||
export function getConfirmedCloneJobs(): DatatableCloneJob[] {
|
||||
return confirmedJobs
|
||||
}
|
||||
|
||||
function advanceCloneQueue() {
|
||||
if (currentCloneJob) {
|
||||
completedJobs.push(currentCloneJob)
|
||||
confirmedJobs.push(currentCloneJob)
|
||||
}
|
||||
const idx = cloneQueue.indexOf(currentCloneJob!)
|
||||
if (idx < cloneQueue.length - 1) {
|
||||
@@ -193,8 +116,9 @@
|
||||
value: 'keep_original',
|
||||
label: dt.permissioned ? 'Keep original (under roles)' : 'Keep original'
|
||||
},
|
||||
// A copy of a data table under roles is refused by the server, so it is not offered.
|
||||
...(dt.permissioned
|
||||
// A copy of a data table under roles carries the source's owners and grants,
|
||||
// which only the Enterprise Edition replays: elsewhere the server refuses it.
|
||||
...(dt.permissioned && !$enterpriseLicense
|
||||
? []
|
||||
: [
|
||||
{ value: 'schema_only', label: 'Clone schema only' },
|
||||
@@ -213,13 +137,9 @@
|
||||
{#if cloneModalOpen && currentCloneJob}
|
||||
<ConfirmationModal
|
||||
title="Clone datatable: {currentCloneJob.name}"
|
||||
confirmationText={cloneRunning ? 'Running...' : 'Start'}
|
||||
confirmationText="Confirm"
|
||||
open={cloneModalOpen}
|
||||
loading={cloneRunning}
|
||||
onConfirmed={async () => {
|
||||
await executeCloneJob(currentCloneJob!)
|
||||
advanceCloneQueue()
|
||||
}}
|
||||
onConfirmed={advanceCloneQueue}
|
||||
onCanceled={() => {
|
||||
cloneModalOpen = false
|
||||
currentCloneJob = undefined
|
||||
@@ -241,39 +161,16 @@
|
||||
|
||||
{#if currentCloneJob.resourceType === 'instance'}
|
||||
<p class="text-xs text-secondary mt-2">
|
||||
This will run <code
|
||||
>CREATE DATABASE {currentCloneJob.steps[0]?.label.match(/"([^"]+)"/)?.[1] ?? ''}</code
|
||||
> on the Windmill PostgreSQL instance.
|
||||
Creating the fork will run <code>CREATE DATABASE {currentCloneJob._newDbName}</code> on the Windmill
|
||||
PostgreSQL instance. A data table under roles keeps its owners and grants in the copy, and its
|
||||
roles stay decided where they are decided today.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-secondary mt-2">
|
||||
This will run <code>CREATE DATABASE</code> on the resource's PostgreSQL server.
|
||||
Creating the fork will run <code>CREATE DATABASE {currentCloneJob._newDbName}</code> on the resource's
|
||||
PostgreSQL server.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
{#each currentCloneJob.steps as step}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
{#if step.status === 'done'}
|
||||
<Check class="w-4 h-4 shrink-0 text-green-500" />
|
||||
{:else if step.status === 'running'}
|
||||
<Loader2 class="w-4 h-4 shrink-0 animate-spin text-blue-500" />
|
||||
{:else if step.status === 'error'}
|
||||
<X class="w-4 h-4 shrink-0 text-red-500" />
|
||||
{:else}
|
||||
<div class="w-4 h-4 shrink-0 rounded-full border border-gray-300"></div>
|
||||
{/if}
|
||||
<span
|
||||
class:text-tertiary={step.status === 'pending'}
|
||||
class:font-medium={step.status === 'running'}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{#if step.error}
|
||||
<p class="text-2xs text-red-500 ml-6">{step.error}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-secondary mt-2"> If the fork cannot be created, the copy is dropped. </p>
|
||||
</ConfirmationModal>
|
||||
{/if}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import { getKnownModelPrice, inheritedCacheRates } from '../copilot/modelPricing'
|
||||
import { modelKey } from '../copilot/modelConfig'
|
||||
import { stripLegacyThinkingSuffix } from '../copilot/reasoningRegistry'
|
||||
import { chatModelsByProvider } from './perModelSettings'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import SettingCard from '../instanceSettings/SettingCard.svelte'
|
||||
@@ -25,28 +25,7 @@
|
||||
let errors = $state<Record<string, string>>({})
|
||||
let collapsedProviders = $state<Record<string, boolean>>({})
|
||||
|
||||
// Rates are keyed by the id the chat reports usage under, and `setCopilotInfo`
|
||||
// strips the deprecated `/thinking` suffix before the chat ever sees a model. A
|
||||
// row built from the raw config would save an override under a key nothing
|
||||
// reports, so it would sit in settings looking applied and never price anything.
|
||||
// Stripping can collapse two configured slots onto one model, hence the dedupe.
|
||||
const modelsByProvider = $derived(
|
||||
Object.entries(aiProviders).reduce(
|
||||
(acc, [provider, config]) => {
|
||||
const seen = new Set<string>()
|
||||
acc[provider] = config.models.flatMap((configured) => {
|
||||
const model = stripLegacyThinkingSuffix(configured)
|
||||
if (seen.has(model)) {
|
||||
return []
|
||||
}
|
||||
seen.add(model)
|
||||
return [{ provider: provider as AIProvider, model }]
|
||||
})
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Array<{ provider: AIProvider; model: string }>>
|
||||
)
|
||||
)
|
||||
const modelsByProvider = $derived(chatModelsByProvider(aiProviders))
|
||||
|
||||
type Field = 'input' | 'output' | 'cache_read' | 'cache_write'
|
||||
type CacheField = Extract<Field, 'cache_read' | 'cache_write'>
|
||||
@@ -228,13 +207,13 @@
|
||||
{#if overridden}
|
||||
<div class="text-xs text-tertiary flex flex-row items-center gap-2">
|
||||
<span>Overriding the built-in price</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-blue-500 hover:underline"
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="2xs"
|
||||
onclick={() => resetModel(provider as AIProvider, model)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if errors[key]}
|
||||
|
||||
@@ -1,176 +1,158 @@
|
||||
<script lang="ts">
|
||||
import type { AIConfig, AIProvider } from '$lib/gen'
|
||||
import { Badge, Button } from '../common'
|
||||
import { getModelMaxTokens } from '../copilot/lib'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import { modelKey } from '../copilot/modelConfig'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import SettingCard from '../instanceSettings/SettingCard.svelte'
|
||||
|
||||
const MAX_TOKENS_LIMIT = 2000000
|
||||
import { chatModelsByProvider } from './perModelSettings'
|
||||
|
||||
let {
|
||||
aiProviders,
|
||||
maxTokensPerModel = $bindable()
|
||||
limits = $bindable(),
|
||||
label,
|
||||
description,
|
||||
min,
|
||||
max,
|
||||
getDefault
|
||||
}: {
|
||||
aiProviders: Exclude<AIConfig['providers'], undefined>
|
||||
maxTokensPerModel: Record<string, number>
|
||||
/** Overrides keyed `provider:model`; a model on its default has no entry. */
|
||||
limits: Record<string, number>
|
||||
label: string
|
||||
description: string
|
||||
min: number
|
||||
max: number
|
||||
/**
|
||||
* What a model gets without an override, shown as the empty field's placeholder.
|
||||
* `assumed` marks a guess for a model the built-in table does not know.
|
||||
*/
|
||||
getDefault: (provider: AIProvider, model: string) => { tokens: number; assumed: boolean }
|
||||
} = $props()
|
||||
|
||||
let errors = $state<Record<string, string>>({})
|
||||
let collapsedProviders = $state<Record<string, boolean>>({})
|
||||
|
||||
// Group available models by provider
|
||||
const modelsByProvider = $derived(
|
||||
Object.entries(aiProviders).reduce(
|
||||
(acc, [provider, config]) => {
|
||||
acc[provider] = config.models.map((model) => ({
|
||||
provider: provider as AIProvider,
|
||||
model
|
||||
}))
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Array<{ provider: AIProvider; model: string }>>
|
||||
)
|
||||
let collapsedProviders = $derived<Record<string, boolean>>(
|
||||
Object.fromEntries(Object.keys(aiProviders).map((provider) => [provider, true]))
|
||||
)
|
||||
|
||||
function getModelKey(provider: AIProvider, model: string): string {
|
||||
return `${provider}:${model}`
|
||||
const modelsByProvider = $derived(chatModelsByProvider(aiProviders))
|
||||
|
||||
function isOverridden(provider: AIProvider, model: string): boolean {
|
||||
return limits[modelKey(provider, model)] !== undefined
|
||||
}
|
||||
|
||||
function getDefaultTokensForModel(provider: AIProvider, model: string): number {
|
||||
return getModelMaxTokens(provider, model)
|
||||
}
|
||||
|
||||
function getCurrentTokensForModel(provider: AIProvider, model: string): number {
|
||||
const modelKey = getModelKey(provider, model)
|
||||
return maxTokensPerModel[modelKey] ?? getDefaultTokensForModel(provider, model)
|
||||
}
|
||||
|
||||
function updateTokensForModel(provider: AIProvider, model: string, tokens: number) {
|
||||
const modelKey = getModelKey(provider, model)
|
||||
if (tokens < 1 || tokens > MAX_TOKENS_LIMIT) {
|
||||
errors[modelKey] = 'Token limit must be between 1 and ' + MAX_TOKENS_LIMIT
|
||||
function updateTokens(provider: AIProvider, model: string, tokens: number) {
|
||||
const key = modelKey(provider, model)
|
||||
if (!Number.isInteger(tokens) || tokens < min || tokens > max) {
|
||||
errors[key] = `Must be a whole number between ${min} and ${max}`
|
||||
return
|
||||
}
|
||||
|
||||
const defaultTokens = getDefaultTokensForModel(provider, model)
|
||||
|
||||
if (tokens === defaultTokens) {
|
||||
// Remove from object if it's the default value
|
||||
const newSettings = { ...maxTokensPerModel }
|
||||
delete newSettings[modelKey]
|
||||
maxTokensPerModel = newSettings
|
||||
} else {
|
||||
maxTokensPerModel = {
|
||||
...maxTokensPerModel,
|
||||
[modelKey]: tokens
|
||||
}
|
||||
}
|
||||
errors[modelKey] = ''
|
||||
limits = { ...limits, [key]: tokens }
|
||||
errors[key] = ''
|
||||
}
|
||||
|
||||
function resetModelToDefault(provider: AIProvider, model: string) {
|
||||
const modelKey = getModelKey(provider, model)
|
||||
const newSettings = { ...maxTokensPerModel }
|
||||
delete newSettings[modelKey]
|
||||
maxTokensPerModel = newSettings
|
||||
}
|
||||
|
||||
function isModelAtDefault(provider: AIProvider, model: string): boolean {
|
||||
const currentTokens = getCurrentTokensForModel(provider, model)
|
||||
const defaultTokens = getDefaultTokensForModel(provider, model)
|
||||
return currentTokens === defaultTokens
|
||||
function resetModel(provider: AIProvider, model: string) {
|
||||
const key = modelKey(provider, model)
|
||||
const next = { ...limits }
|
||||
delete next[key]
|
||||
limits = next
|
||||
errors[key] = ''
|
||||
}
|
||||
|
||||
function toggleProvider(provider: string) {
|
||||
collapsedProviders[provider] = !collapsedProviders[provider]
|
||||
collapsedProviders = { ...collapsedProviders, [provider]: !collapsedProviders[provider] }
|
||||
}
|
||||
|
||||
function hasCustomSettings(provider: string, models: Array<{ model: string }>): boolean {
|
||||
return models.some((m) => !isModelAtDefault(provider as AIProvider, m.model))
|
||||
function hasOverrides(provider: string, models: Array<{ model: string }>): boolean {
|
||||
return models.some((m) => isOverridden(provider as AIProvider, m.model))
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Initialize collapsedProviders to true for all providers
|
||||
collapsedProviders = {
|
||||
...Object.fromEntries(Object.keys(aiProviders).map((provider) => [provider, true]))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if Object.keys(aiProviders).length > 0}
|
||||
<SettingCard
|
||||
label="Model output limits"
|
||||
description="Configure maximum token limits for each model. These limits apply to all AI chat interactions in the workspace."
|
||||
>
|
||||
<SettingCard {label} {description}>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each Object.entries(modelsByProvider).filter(([provider, models]) => models.length > 0) as [provider, models]}
|
||||
{#each Object.entries(modelsByProvider).filter(([_, models]) => models.length > 0) as [provider, models] (provider)}
|
||||
{@const isExpanded = !collapsedProviders[provider]}
|
||||
{@const hasCustom = hasCustomSettings(provider, models)}
|
||||
<div class="border rounded-md bg-surface-tertiary">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
onclick={() => toggleProvider(provider)}
|
||||
class="w-full px-4 py-1 min-h-8 flex items-center justify-between hover:bg-surface-hover transition-colors rounded-t-md"
|
||||
wrapperClasses="w-full"
|
||||
btnClasses="w-full px-4 min-h-8 justify-between rounded-t-md rounded-b-none"
|
||||
endIcon={{ icon: isExpanded ? ChevronUp : ChevronDown }}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-medium text-xs capitalize">{provider}</h4>
|
||||
{#if hasCustom}
|
||||
{#if hasOverrides(provider, models)}
|
||||
<Badge color="blue">Modified</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isExpanded}
|
||||
<ChevronUp size={16} class="text-gray-500" />
|
||||
{:else}
|
||||
<ChevronDown size={16} class="text-gray-500" />
|
||||
{/if}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{#if isExpanded}
|
||||
<div transition:slide|local={{ duration: 200 }} class="p-4 border-t">
|
||||
<div class="space-y-3">
|
||||
{#each models as { model }}
|
||||
{@const currentTokens = getCurrentTokensForModel(provider as AIProvider, model)}
|
||||
{@const defaultTokens = getDefaultTokensForModel(provider as AIProvider, model)}
|
||||
{@const isAtDefault = isModelAtDefault(provider as AIProvider, model)}
|
||||
{#each models as { model } (model)}
|
||||
{@const key = modelKey(provider as AIProvider, model)}
|
||||
{@const fallback = getDefault(provider as AIProvider, model)}
|
||||
{@const overridden = isOverridden(provider as AIProvider, model)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-xs text-primary truncate block">{model}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={MAX_TOKENS_LIMIT}
|
||||
value={currentTokens}
|
||||
oninput={(e) => {
|
||||
const value = parseInt(e.currentTarget.value)
|
||||
if (!isNaN(value)) {
|
||||
updateTokensForModel(provider as AIProvider, model, value)
|
||||
}
|
||||
}}
|
||||
class="w-20 px-2 py-1 text-xs text-center border border-gray-200 dark:border-gray-700 rounded bg-surface focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<div class="w-28">
|
||||
<TextInput
|
||||
value={limits[key] ?? ''}
|
||||
size="sm"
|
||||
error={!!errors[key]}
|
||||
inputProps={{
|
||||
type: 'number',
|
||||
min,
|
||||
max,
|
||||
step: 1,
|
||||
placeholder: String(fallback.tokens),
|
||||
oninput: (e: Event & { currentTarget: HTMLInputElement }) => {
|
||||
if (e.currentTarget.value === '') {
|
||||
resetModel(provider as AIProvider, model)
|
||||
return
|
||||
}
|
||||
const value = Number(e.currentTarget.value)
|
||||
if (!isNaN(value)) {
|
||||
updateTokens(provider as AIProvider, model, value)
|
||||
}
|
||||
},
|
||||
onblur: (e: Event & { currentTarget: HTMLInputElement }) => {
|
||||
// Resync a value the state refused, so what is shown is what is stored.
|
||||
e.currentTarget.value = String(limits[key] ?? '')
|
||||
errors[key] = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs text-secondary whitespace-nowrap">tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if !isAtDefault}
|
||||
<div class="text-xs text-primary flex flex-row items-center gap-1">
|
||||
<span>Default: {defaultTokens} tokens</span>
|
||||
{#if overridden}
|
||||
<div class="text-xs text-tertiary flex flex-row items-center gap-2">
|
||||
<span>
|
||||
{fallback.assumed ? 'Assumed' : 'Default'}: {fallback.tokens.toLocaleString()}
|
||||
tokens
|
||||
</span>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="xs"
|
||||
onclick={() => resetModelToDefault(provider as AIProvider, model)}
|
||||
variant="subtle"
|
||||
unifiedSize="2xs"
|
||||
onclick={() => resetModel(provider as AIProvider, model)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
{#if errors[getModelKey(provider as AIProvider, model)]}
|
||||
<div class="text-xs text-red-500"
|
||||
>{errors[getModelKey(provider as AIProvider, model)]}</div
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if errors[key]}
|
||||
<div class="text-xs text-red-500">{errors[key]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -84,6 +84,11 @@ export function instanceDbSteps(dbname: string, status: CustomInstanceDb | undef
|
||||
(status?.logs.replication_user_error
|
||||
? `\n\nError: ${status.logs.replication_user_error}`
|
||||
: '')
|
||||
},
|
||||
{
|
||||
title: 'Connect as custom_instance_user',
|
||||
status: status?.logs.user_connect,
|
||||
description: `The steps above run as the user in DATABASE_URL, but data tables and DuckLake catalogs on ${dbname} log in as custom_instance_user. This checks that login reaches the database too. A connection pooler that routes on the login name, such as Supabase's shared pooler, refuses it: use your own Postgres database instead, or point DATABASE_URL at the Postgres server directly.`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AIConfig, AIProvider } from '$lib/gen'
|
||||
import { stripLegacyThinkingSuffix } from '../copilot/reasoningRegistry'
|
||||
|
||||
/**
|
||||
* The rows of a per-model setting, grouped by provider. Settings are keyed by the id
|
||||
* the chat reads them under, and `setCopilotInfo` strips the deprecated `/thinking`
|
||||
* suffix before the chat ever sees a model. A row built from the raw config would
|
||||
* save a value under a key nothing reads, so it would sit in settings looking applied
|
||||
* and never take effect. Stripping can collapse two configured slots onto one model,
|
||||
* hence the dedupe.
|
||||
*/
|
||||
export function chatModelsByProvider(
|
||||
aiProviders: Exclude<AIConfig['providers'], undefined>
|
||||
): Record<string, Array<{ provider: AIProvider; model: string }>> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(aiProviders).map(([provider, config]) => [
|
||||
provider,
|
||||
[...new Set(config.models.map(stripLegacyThinkingSuffix))].map((model) => ({
|
||||
provider: provider as AIProvider,
|
||||
model
|
||||
}))
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
/**
|
||||
* Identity for one editing session's conflict resolution.
|
||||
*
|
||||
* An editor outlives what it shows, so an awaited step cannot tell from the workspace and path
|
||||
* alone whether it still speaks for what is on screen: both come back to the values it started
|
||||
* with when the user returns to where they were. Every resolution takes a token and checks it
|
||||
* before touching shared editor state.
|
||||
*/
|
||||
export function createDraftConflictSession() {
|
||||
let generation = $state(0)
|
||||
/** The token of the resolution in flight, or 0. A token rather than a flag: one left over from
|
||||
* a closed session must not clear the busy state of the session that replaced it. */
|
||||
let inFlight = $state(0)
|
||||
|
||||
return {
|
||||
/** A resolution started in the current session is in flight. Ending a session clears it, so
|
||||
* a reopened editor never shows buttons that only a stale request settling can re-enable. */
|
||||
get busy(): boolean {
|
||||
return inFlight !== 0
|
||||
},
|
||||
/** Claim the session for a resolution about to start. */
|
||||
start(): number {
|
||||
inFlight = ++generation
|
||||
return inFlight
|
||||
},
|
||||
/** Whether `token` still speaks for the editor. */
|
||||
holds(token: number): boolean {
|
||||
return token === generation
|
||||
},
|
||||
/** Release `token`'s claim. A stale token is ignored. */
|
||||
finish(token: number): void {
|
||||
if (inFlight === token) inFlight = 0
|
||||
},
|
||||
/** Nothing outstanding speaks for this editor any more: a different item, a different
|
||||
* session on the same one, or the component going away. */
|
||||
end(): void {
|
||||
generation++
|
||||
inFlight = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A session for an editor that swaps what it shows in place, `scopeOf` naming what that currently
|
||||
* is. Leaving a scope and coming back is a new session, so the change itself ends the old one:
|
||||
* afterwards every value a resolution captured before the switch reads as current again, and no
|
||||
* later comparison could tell it apart from one that never left.
|
||||
*/
|
||||
export function useDraftConflictSession(scopeOf: () => unknown) {
|
||||
const session = createDraftConflictSession()
|
||||
|
||||
let lastScope = untrack(scopeOf)
|
||||
$effect(() => {
|
||||
const next = scopeOf()
|
||||
untrack(() => {
|
||||
if (next === lastScope) return
|
||||
lastScope = next
|
||||
session.end()
|
||||
})
|
||||
})
|
||||
|
||||
return session
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { createDraftConflictSession } from './draftConflictSession.svelte'
|
||||
|
||||
/**
|
||||
* A resolution the user has walked away from must neither speak for what replaced it nor hold its
|
||||
* buttons disabled until it settles.
|
||||
*/
|
||||
describe('createDraftConflictSession', () => {
|
||||
it('frees the next session from a resolution the last one left in flight', () => {
|
||||
const session = createDraftConflictSession()
|
||||
|
||||
const stale = session.start()
|
||||
expect(session.busy).toBe(true)
|
||||
|
||||
session.end()
|
||||
expect(session.busy).toBe(false)
|
||||
expect(session.holds(stale)).toBe(false)
|
||||
|
||||
const fresh = session.start()
|
||||
expect(session.busy).toBe(true)
|
||||
expect(session.holds(fresh)).toBe(true)
|
||||
|
||||
// The stale request settles last: it must not release the live session's claim.
|
||||
session.finish(stale)
|
||||
expect(session.busy).toBe(true)
|
||||
|
||||
session.finish(fresh)
|
||||
expect(session.busy).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -723,5 +723,35 @@ export const UserDraftDbSyncer = {
|
||||
const key = draftKey(query.workspace, query.itemKind, query.path)
|
||||
pendingSaveOpts.delete(key)
|
||||
debouncer.cancel(key)
|
||||
},
|
||||
|
||||
/**
|
||||
* The value parked for this key, wrapped so a parked delete (`null`) stays distinguishable
|
||||
* from nothing parked at all. While a conflict stands this is the version the server refused,
|
||||
* which is to say this tab's own: an editor opening on the key takes it over the server's
|
||||
* draft, which is the version that did the refusing.
|
||||
*/
|
||||
peekPending(query: UserDraftLastSyncQuery): { value: unknown } | undefined {
|
||||
const parked = pendingSaveOpts.get(draftKey(query.workspace, query.itemKind, query.path))
|
||||
return parked ? { value: parked.value } : undefined
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop scheduling saves for this key and wait until nothing for it is still in flight.
|
||||
* Cancelling alone cannot stop a POST the runner already started, and such a POST settles
|
||||
* *after* the caller has moved on — a rejected one re-raising the conflict it was told to
|
||||
* resolve. Await this before installing a baseline that would make a stale payload acceptable.
|
||||
*
|
||||
* What is parked is deliberately left alone: a refused save keeps its payload here, and while
|
||||
* the conflict stands that is the only copy of the edit outside the editor's own memory. A
|
||||
* caller that gives up half way must leave it recoverable, so dropping it is the committing
|
||||
* caller's job, via `dropPending`, once it has something to replace it with.
|
||||
*/
|
||||
async quiesce(query: UserDraftLastSyncQuery): Promise<void> {
|
||||
const key = draftKey(query.workspace, query.itemKind, query.path)
|
||||
debouncer.cancel(key)
|
||||
await runner.settled(key)
|
||||
// A save that landed while we waited can have scheduled the next one.
|
||||
debouncer.cancel(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
|
||||
// Mocked so a test can hold a POST in flight: that window is the whole point of `quiesce`,
|
||||
// which exists to wait one out rather than return while it is still going.
|
||||
const updateDraft = vi.fn(async (..._args: any[]) => ({
|
||||
status: 'saved' as const,
|
||||
current_timestamp: '2020-01-01T00:00:00Z'
|
||||
}))
|
||||
|
||||
vi.mock('./gen', () => ({
|
||||
DraftService: { updateDraft: (...a: unknown[]) => updateDraft(...(a as [])) }
|
||||
}))
|
||||
vi.mock('./gen/core/OpenAPI', () => ({ OpenAPI: { BASE: '' } }))
|
||||
vi.mock('./localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() }))
|
||||
|
||||
import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte'
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (v: T) => void
|
||||
const promise = new Promise<T>((res) => (resolve = res))
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
updateDraft.mockResolvedValue({ status: 'saved', current_timestamp: '2020-01-01T00:00:00Z' })
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolving a draft conflict installs a fresh baseline. Anything of the old version still in the
|
||||
* air when that lands would be made acceptable by it, and a rejected one re-raises the conflict
|
||||
* just resolved — so the resolution waits for the key to go quiet first. What it must NOT do is
|
||||
* discard the payload while waiting: a refused save keeps it here, and until the resolution
|
||||
* commits it is the only copy of the edit outside the editor's own memory.
|
||||
*/
|
||||
describe('UserDraftDbSyncer.quiesce', () => {
|
||||
it('waits for a save already in flight', async () => {
|
||||
const q = { workspace: 'w', itemKind: 'variable' as const, path: 'u/me/quiesce_a' }
|
||||
const inFlight = deferred()
|
||||
let settledBeforeQuiesceReturned = false
|
||||
updateDraft.mockImplementationOnce(async () => {
|
||||
await inFlight.promise
|
||||
settledBeforeQuiesceReturned = true
|
||||
return { status: 'saved', current_timestamp: '2020-01-01T00:00:00Z' }
|
||||
})
|
||||
|
||||
// In flight, not awaited: the POST is held open by the mock above.
|
||||
const saving = UserDraftDbSyncer.save({ ...q, value: { v: 'in flight' }, immediate: true })
|
||||
|
||||
let quiesced = false
|
||||
const quiescing = UserDraftDbSyncer.quiesce(q).then(() => (quiesced = true))
|
||||
// A full macrotask, not a microtask: anything that merely yields would have resolved by
|
||||
// now, so this is what tells "waited for the POST" apart from "waited for nothing".
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(quiesced).toBe(false)
|
||||
|
||||
inFlight.resolve()
|
||||
await quiescing
|
||||
await saving
|
||||
expect(settledBeforeQuiesceReturned).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a refused payload recoverable, until the resolution that replaces it says otherwise', async () => {
|
||||
const q = { workspace: 'w', itemKind: 'variable' as const, path: 'u/me/quiesce_b' }
|
||||
// Refused, so the payload stays parked — the state a conflict resolution starts from.
|
||||
updateDraft.mockResolvedValueOnce({
|
||||
status: 'conflict',
|
||||
current_timestamp: '2020-01-02T00:00:00Z'
|
||||
})
|
||||
await UserDraftDbSyncer.save({ ...q, value: { v: 'refused' }, immediate: true })
|
||||
expect(UserDraftDbSyncer.getConflict(q).conflict).toBeTruthy()
|
||||
|
||||
await UserDraftDbSyncer.quiesce(q)
|
||||
|
||||
// A resolution abandoned here (the editor closed while quiescing) must leave the edit
|
||||
// somewhere it can still be sent. Flushing is how that payload is observed, since the
|
||||
// parked opts themselves are private to the syncer.
|
||||
updateDraft.mockClear()
|
||||
await UserDraftDbSyncer.flush(q)
|
||||
expect(updateDraft).toHaveBeenCalledTimes(1)
|
||||
expect(updateDraft.mock.calls[0][0]).toMatchObject({ requestBody: { value: { v: 'refused' } } })
|
||||
|
||||
// Only the caller that has something to put in its place drops it.
|
||||
UserDraftDbSyncer.dropPending(q)
|
||||
updateDraft.mockClear()
|
||||
await UserDraftDbSyncer.flush(q)
|
||||
expect(updateDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a parked delete distinguishable from nothing parked', async () => {
|
||||
const q = { workspace: 'w', itemKind: 'variable' as const, path: 'u/me/quiesce_delete' }
|
||||
|
||||
expect(UserDraftDbSyncer.peekPending(q)).toBeUndefined()
|
||||
|
||||
// Reverting to the deployed value, or discarding, parks a delete. Refused, it stays
|
||||
// parked — and a resolution reading it has to keep meaning "remove the row", not fall
|
||||
// back to whatever the server holds.
|
||||
updateDraft.mockResolvedValueOnce({
|
||||
status: 'conflict',
|
||||
current_timestamp: '2020-01-02T00:00:00Z'
|
||||
})
|
||||
await UserDraftDbSyncer.save({ ...q, value: null, immediate: true })
|
||||
|
||||
expect(UserDraftDbSyncer.peekPending(q)).toEqual({ value: null })
|
||||
})
|
||||
|
||||
it('cancels a debounced autosave so it cannot displace the write that follows', async () => {
|
||||
const q = { workspace: 'w', itemKind: 'variable' as const, path: 'u/me/quiesce_c' }
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
// Debounced rather than immediate: this is the autosave a resolution has to call off, or
|
||||
// it fires mid-resolution and, being conditional, is refused.
|
||||
void UserDraftDbSyncer.save({ ...q, value: { v: 'queued' } })
|
||||
|
||||
await UserDraftDbSyncer.quiesce(q)
|
||||
updateDraft.mockClear()
|
||||
|
||||
// Past the debouncer's 10s ceiling, so a schedule that survived has certainly fired.
|
||||
// Real time would not reach it: the test would pass with the cancelling removed.
|
||||
await vi.advanceTimersByTimeAsync(11000)
|
||||
expect(updateDraft).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -143,10 +143,11 @@
|
||||
// same way the old `w-52`/`w-12` classes did — `:root` jumps to 18px on screens
|
||||
// ≥1760px (app.css), which grows the rem-based button content; a fixed-px rail would
|
||||
// not grow with it and the content would overflow. SIDEBAR_MIN_REM is the default
|
||||
// expanded width (the old w-52); the handle only resizes when expanded and only
|
||||
// widens from there — collapsing is the toggle button's job, not the drag's.
|
||||
// expanded width (the old w-52); the handle only widens from there. Dragging the
|
||||
// pointer into the snap zone at the screen's left edge collapses the rail.
|
||||
const SIDEBAR_MIN_REM = 13
|
||||
const SIDEBAR_COLLAPSED_REM = 3
|
||||
const SIDEBAR_SNAP_COLLAPSE_REM = 6
|
||||
// Root font-size in px, used to convert the pointer's clientX (px) into rem.
|
||||
function rootFontPx(): number {
|
||||
if (!BROWSER) return 16
|
||||
@@ -169,11 +170,14 @@
|
||||
// Width (in rem) the content offset must track: the icon strip when collapsed,
|
||||
// the user-chosen width otherwise.
|
||||
let railWidth = $derived(isCollapsed ? SIDEBAR_COLLAPSED_REM : sidebarWidth)
|
||||
// Width transition shared by the rail and the content offset: none for the whole
|
||||
// drag (the rail tracks the pointer 1:1 and hits the min as a hard wall, no
|
||||
// friction), a plain ease only for the collapse/expand toggle.
|
||||
// True for one transition's length after a drag crosses the snap zone's edge, so
|
||||
// the collapse/expand eases before the rail goes back to tracking the pointer.
|
||||
let sidebarSnapping = $state(false)
|
||||
let sidebarSnapTimer: ReturnType<typeof setTimeout> | undefined
|
||||
// Width transition shared by the rail and the content offset: none while a drag
|
||||
// tracks the pointer 1:1, a plain ease for the toggle and the snap.
|
||||
let sidebarTransitionClass = $derived(
|
||||
resizingSidebar ? '' : 'transition-all duration-200 ease-in-out'
|
||||
resizingSidebar && !sidebarSnapping ? '' : 'transition-all duration-200 ease-in-out'
|
||||
)
|
||||
// Set while a drag is live so it can be torn down if the layout unmounts
|
||||
// mid-drag (otherwise the window listeners would leak).
|
||||
@@ -192,12 +196,22 @@
|
||||
handle.setPointerCapture(e.pointerId)
|
||||
} catch {}
|
||||
resizingSidebar = true
|
||||
// Re-expanding a snap-collapsed rail restores the width it had before the drag.
|
||||
const widthAtStart = sidebarWidth
|
||||
const collapsedAtStart = isCollapsed
|
||||
// The rail is fixed at left:0, so the pointer's clientX is the width — in px.
|
||||
// Convert to rem (the unit the rail is sized in) via the root font-size. Pure
|
||||
// resize: clamp at the min so the rail stops there like a wall (dragging left
|
||||
// never collapses — that's the toggle button's job).
|
||||
// Convert to rem (the unit the rail is sized in) via the root font-size. The
|
||||
// rail clamps at the min like a wall, until the pointer reaches the snap zone.
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
sidebarWidth = Math.max(SIDEBAR_MIN_REM, ev.clientX / rootFontPx())
|
||||
const x = ev.clientX / rootFontPx()
|
||||
const collapse = x < SIDEBAR_SNAP_COLLAPSE_REM
|
||||
if (collapse !== isCollapsed) {
|
||||
isCollapsed = collapse
|
||||
sidebarSnapping = true
|
||||
clearTimeout(sidebarSnapTimer)
|
||||
sidebarSnapTimer = setTimeout(() => (sidebarSnapping = false), 200)
|
||||
}
|
||||
sidebarWidth = collapse ? widthAtStart : Math.max(SIDEBAR_MIN_REM, x)
|
||||
}
|
||||
// pointercancel (and unmount, via onDestroy) must clear the state too, or
|
||||
// `resizingSidebar` sticks true — the overlay and handle highlight stay up
|
||||
@@ -205,7 +219,10 @@
|
||||
const stop = () => {
|
||||
if (!resizingSidebar) return
|
||||
resizingSidebar = false
|
||||
clearTimeout(sidebarSnapTimer)
|
||||
sidebarSnapping = false
|
||||
widthPref.val = sidebarWidth
|
||||
if (isCollapsed !== collapsedAtStart) collapsePref.val = isCollapsed
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', stop)
|
||||
window.removeEventListener('pointercancel', stop)
|
||||
@@ -1158,22 +1175,19 @@
|
||||
class="flex-1 flex flex-col min-h-0 h-screen shadow-[inset_-1px_0_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_-1px_0_0_0_#374151] [html.github-dark_&]:shadow-[inset_-1px_0_0_0_rgb(var(--color-border-light))]"
|
||||
style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}
|
||||
>
|
||||
{#if !isCollapsed}
|
||||
<!-- Resize handle straddling the right edge, only while expanded:
|
||||
drag to widen (clamped at the min). Collapsing is the toggle
|
||||
button's job. -->
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize sidebar"
|
||||
title="Drag to resize"
|
||||
class={classNames(
|
||||
'absolute inset-y-0 -right-0.5 w-1.5 cursor-col-resize z-50 transition-colors',
|
||||
resizingSidebar ? '' : 'hover:bg-surface-hover'
|
||||
)}
|
||||
onpointerdown={startSidebarResize}
|
||||
></div>
|
||||
{/if}
|
||||
<!-- Resize handle straddling the right edge: drag to resize, into the
|
||||
left snap zone to collapse, or out of it to expand. -->
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize sidebar"
|
||||
title="Drag to resize"
|
||||
class={classNames(
|
||||
'absolute inset-y-0 -right-0.5 w-1.5 cursor-col-resize z-50 transition-colors',
|
||||
resizingSidebar ? '' : 'hover:bg-surface-hover'
|
||||
)}
|
||||
onpointerdown={startSidebarResize}
|
||||
></div>
|
||||
<!-- Workspace picker as the sidebar header (replaces the Windmill logo).
|
||||
Kept in both modes: it scopes which workspace family's sessions
|
||||
the sessions sidebar shows. -->
|
||||
|
||||
Reference in New Issue
Block a user