mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
Merge remote-tracking branch 'origin/main' into datatable-external-instance
# Conflicts: # backend/ee-repo-ref.txt # backend/windmill-api-workspaces/src/datatable_clone.rs # backend/windmill-api-workspaces/src/workspaces.rs # backend/windmill-common/src/lib.rs # backend/windmill-common/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
|
||||
|
||||
@@ -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
|
||||
|
||||
+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).
|
||||
|
||||
@@ -106,6 +106,13 @@ const CATALOG = [
|
||||
path: '/w/{workspace}/scripts/get/p/{path}',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
name: 'getAppByPath',
|
||||
description: 'Get app by path',
|
||||
instructions: 'Returns the app source',
|
||||
path: '/w/{workspace}/apps/get/p/{path}',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
name: 'deleteScriptByHash',
|
||||
description: 'Delete a script by hash',
|
||||
@@ -260,12 +267,12 @@ describe('call_api_get', () => {
|
||||
})
|
||||
|
||||
it('refuses draft-blind item reads and lists, pointing at the draft-aware tools', async () => {
|
||||
for (const name of ['getScriptByPath', 'getResource', 'getSchedule']) {
|
||||
for (const name of ['getScriptByPath', 'getResource', 'getSchedule', 'getAppByPath']) {
|
||||
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']) {
|
||||
for (const name of ['listScripts', 'listFlows', 'listResource', 'listSchedules', 'listApps']) {
|
||||
const result = await run('call_api_get', { name })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('list_workspace_items')
|
||||
@@ -273,6 +280,9 @@ describe('call_api_get', () => {
|
||||
|
||||
const search = await run('search_api_endpoints', { query: 'get script' })
|
||||
expect(search.matches.map((m: any) => m.name)).not.toContain('getScriptByPath')
|
||||
// getAppByPath would hand the model the whole app source, so it must not even surface.
|
||||
const appSearch = await run('search_api_endpoints', { query: 'get app' })
|
||||
expect(appSearch.matches.map((m: any) => m.name)).not.toContain('getAppByPath')
|
||||
})
|
||||
|
||||
it('refuses the worker and data-metric reads, pointing at their dedicated tools', async () => {
|
||||
|
||||
@@ -32,6 +32,11 @@ const COVERED_ENDPOINTS: Record<string, string> = {
|
||||
'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)',
|
||||
// getAppByPath returns the entire app source — every file, every runnable's
|
||||
// script, lock and schema — where read_workspace_item returns paths and sizes.
|
||||
getAppByPath:
|
||||
'read_workspace_item (app metadata only; read_app_file for contents. Reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
listApps: 'list_workspace_items (it includes your drafts)',
|
||||
listScripts: 'list_workspace_items (it includes your drafts)',
|
||||
listFlows: 'list_workspace_items (it includes your drafts)',
|
||||
listResource: 'list_workspace_items (it includes your drafts)',
|
||||
|
||||
@@ -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,91 @@ 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()
|
||||
})
|
||||
|
||||
// getAppByPath and listApps are refused by the catalog, so this read is the only way
|
||||
// left 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 +4472,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
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
SqsTriggerService,
|
||||
VariableService,
|
||||
WebsocketTriggerService,
|
||||
WorkerService
|
||||
WorkerService,
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import { createTwoFilesPatch } from 'diff'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
@@ -1421,8 +1422,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 +1535,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 +1832,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 +1863,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 +1997,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 +2088,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 +2109,7 @@ async function readWorkspaceItem(
|
||||
summary: value.summary,
|
||||
value: metadata as unknown as AppDraftValue,
|
||||
rawApp: app.raw_app,
|
||||
executionMode,
|
||||
isDraft: false
|
||||
}
|
||||
}
|
||||
@@ -3473,7 +3534,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, {
|
||||
@@ -7995,6 +8066,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}"...`
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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