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:
Diego Imbert
2026-09-21 14:43:51 +02:00
9 changed files with 406 additions and 69 deletions
+1 -1
View File
@@ -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
+40 -17
View File
@@ -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
View File
@@ -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. -->