Merge remote-tracking branch 'origin/main' into remove-workspace-drafts

This commit is contained in:
Diego Imbert
2026-06-01 18:22:44 +02:00
163 changed files with 10076 additions and 2607 deletions
+27
View File
@@ -1,5 +1,32 @@
# Changelog
## [1.713.1](https://github.com/windmill-labs/windmill/compare/v1.713.0...v1.713.1) (2026-06-01)
### Bug Fixes
* **api:** handle multi-version scripts when removing granular ACL ([#9388](https://github.com/windmill-labs/windmill/issues/9388)) ([9d9c503](https://github.com/windmill-labs/windmill/commit/9d9c5038ce8b0016320a670c434ef9063cb40441))
## [1.713.0](https://github.com/windmill-labs/windmill/compare/v1.712.0...v1.713.0) (2026-05-31)
### Features
* **flows:** preserve step/subflow worker tags under a custom-tagged flow ([#9375](https://github.com/windmill-labs/windmill/issues/9375)) ([f0301b1](https://github.com/windmill-labs/windmill/commit/f0301b1605cee5fba4024803555333e6fa5c40ee))
* **oauth:** support per-provider sandbox URLs ([#9358](https://github.com/windmill-labs/windmill/issues/9358)) ([2bf11dc](https://github.com/windmill-labs/windmill/commit/2bf11dcb15540c538ea2ac3cf70dcbe589060b4e))
### Bug Fixes
* **ai:** validate token_url for SSRF in OAuth credentials flow ([#9385](https://github.com/windmill-labs/windmill/issues/9385)) ([4b06881](https://github.com/windmill-labs/windmill/commit/4b06881918b76c5a411cc70b318e46efcc1393a7))
* **api:** authorize and harden log-file reading endpoints ([#9368](https://github.com/windmill-labs/windmill/issues/9368)) ([bb90f4c](https://github.com/windmill-labs/windmill/commit/bb90f4ce83a0e60af219b11c12ab4fe1d13f47a4))
* **apps:** make public apps opt into cross-origin isolation via wm_coep (GIT-884) ([#9374](https://github.com/windmill-labs/windmill/issues/9374)) ([2c0c2c4](https://github.com/windmill-labs/windmill/commit/2c0c2c467f163cd24c14c7be2db07af9cf2ce020))
* **auth:** enforce monotonic privilege on user token lifecycle endpoints ([#9371](https://github.com/windmill-labs/windmill/issues/9371)) ([2ddf93d](https://github.com/windmill-labs/windmill/commit/2ddf93de96622b2a1b2b6f59398a7a1f59360efd))
* batch encryption-key rotation into one git-sync job ([#9355](https://github.com/windmill-labs/windmill/issues/9355)) ([04a0897](https://github.com/windmill-labs/windmill/commit/04a08976aec4ba9b0516350316df303e9f96bfd3))
* **cli:** preserve user drafts on sync push and permissioned-as ([#9381](https://github.com/windmill-labs/windmill/issues/9381)) ([b0c3b01](https://github.com/windmill-labs/windmill/commit/b0c3b01d31b0ab3a6566e1f5fec60e3e230cfadb))
* **frontend:** sanitize user markdown to prevent stored XSS ([#9386](https://github.com/windmill-labs/windmill/issues/9386)) ([def01b8](https://github.com/windmill-labs/windmill/commit/def01b8ff6f331cc36ce02b947adc31c766042c4))
* **security:** re-pin cached hub scripts to CVE-patched versions (+ HUB_BASE_URL override for cache mode) ([#9387](https://github.com/windmill-labs/windmill/issues/9387)) ([edf340c](https://github.com/windmill-labs/windmill/commit/edf340c4d4f18b16b142cb7deb67afa586f10946))
## [1.712.0](https://github.com/windmill-labs/windmill/compare/v1.711.0...v1.712.0) (2026-05-28)
+1 -3
View File
@@ -56,7 +56,7 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus
bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o
bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose
bun run cli -- run flow --record
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-3-flash-preview
WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview
bun run cli -- run global global-test1-script-create
bun run cli -- run cli bun-hello-script
@@ -89,8 +89,6 @@ Today:
- `opus`
- `4o`
- `gpt-5.5`
- `gemini-flash`
- `gemini-pro`
- `gemini-3-flash-preview`
- `gemini-3.1-pro-preview`
- `deepseek-v4-flash`
@@ -21,9 +21,9 @@ describe("proxy helpers", () => {
describe("resolveEvalModelProvider", () => {
it("infers googleai from Gemini model ids", () => {
expect(resolveEvalModelProvider("gemini-2.5-flash")).toEqual({
expect(resolveEvalModelProvider("gemini-3-flash-preview")).toEqual({
provider: "googleai",
model: "gemini-2.5-flash",
model: "gemini-3-flash-preview",
});
});
@@ -35,9 +35,11 @@ describe("resolveEvalModelProvider", () => {
});
it("preserves an explicit provider", () => {
expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({
expect(
resolveEvalModelProvider("gemini-3.1-pro-preview", "googleai"),
).toEqual({
provider: "googleai",
model: "gemini-2.5-pro",
model: "gemini-3.1-pro-preview",
});
});
});
+11
View File
@@ -8,6 +8,9 @@
args:
a: 4
b: 5
toolExpect:
requiredToolsUsed:
- test_run_flow
judgeChecklist:
- "the flow takes `a` and `b` as inputs"
- "the main step is named `sum_numbers`"
@@ -25,6 +28,9 @@
args:
a: 2
b: 3
toolExpect:
requiredToolsUsed:
- test_run_flow
judgeChecklist:
- "the flow takes `a` and `b` as inputs"
- "the main step is named `sum_numbers`"
@@ -42,6 +48,9 @@
args:
a: 7
b: 8
toolExpect:
requiredToolsUsed:
- test_run_flow
judgeChecklist:
- "the parent flow takes `a` and `b` as inputs"
- "the main step is named `call_add_numbers`"
@@ -426,6 +435,7 @@
- return_schedule_status
toolExpect:
requiredToolsUsed:
- test_run_flow
- create_schedule
toolCallArgs:
- tool: create_schedule
@@ -453,6 +463,7 @@
- webhook_response
toolExpect:
requiredToolsUsed:
- test_run_flow
- create_trigger
toolCallArgs:
- tool: create_trigger
+188
View File
@@ -430,3 +430,191 @@
judgeChecklist:
- asks which script to update when the user refers to "this script" without selected or active editor context
- does not guess a path or create a new script draft
- id: global-test15-human-postgres-resource
prompt: |-
I'm wiring the eval reporting database into this workspace.
Can you stage a Postgres connection for it in the shared evals/global folder?
Use host `reports-db.internal`, port 5432, database `evals_reporting`, user `report_reader`, and password `pg-redacted-reporting-password`.
Keep the credentials safe.
This is just draft work for now.
runtime:
maxTurns: 10
validate:
draftCountExactly: 2
requiredDrafts:
- type: variable
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- report
- password
valueIncludes:
- "true"
- report
- type: resource
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- report
valueIncludes:
- postgres
- reports-db.internal
- "5432"
- evals_reporting
- report_reader
- "$var:"
valueExcludes:
- pg-redacted-reporting-password
toolExpect:
requiredToolsUsed:
- write_variable
- search_resource_types
- write_resource
forbiddenToolsUsed:
- write_schedule
- write_trigger
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: write_variable
field: value
stringStartsWithAnyOf:
- pg-redacted-reporting-password
skipJudge: true
judgeChecklist:
- creates a Postgres resource draft for the eval reporting database
- creates a secret variable draft for the database password
- puts the drafts in sensible eval/global reporting-related paths
- uses the requested host, port, database, and user
- references the secret variable from the resource instead of embedding the password
- leaves the work as a draft only
- id: global-test16-human-visible-variable
prompt: |-
We keep reusing a 30 day trial cutoff in eval notification jobs.
Can you stage that as a normal workspace variable in the shared evals/global folder, with a short description so people know what it controls?
It is not a secret.
runtime:
maxTurns: 6
validate:
draftCountExactly: 1
requiredDrafts:
- type: variable
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- trial
valueIncludes:
- "30"
- "false"
- trial
toolExpect:
requiredToolsUsed:
- write_variable
forbiddenToolsUsed:
- write_resource
- write_schedule
- write_trigger
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates exactly one non-secret variable draft for the trial cutoff
- stores the value 30
- chooses a sensible eval/global path related to trials or notifications
- includes a useful description of what the value controls
- does not create resources, schedules, triggers, or deployed workspace changes
- id: global-test17-human-schedule-existing-helper
prompt: |-
The workspace already has a report digest helper.
Can you stage a weekday 8:30 AM UTC run for it with `dry_run` turned on?
I only want the schedule draft for review.
initial: ai_evals/fixtures/frontend/global/initial/report_digest_script.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: schedule
pathIncludes:
- digest
valueIncludes:
- f/evals/global/send_report_digest
- UTC
- dry_run
- "true"
toolExpect:
requiredToolsUsed:
- list_workspace_items
- write_schedule
forbiddenToolsUsed:
- write_script
- write_flow
- write_resource
- write_variable
- write_trigger
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- finds the existing report digest helper rather than creating a new script or flow
- creates one schedule draft for that helper
- schedules it for weekdays around 08:30 UTC
- passes dry_run as true
- leaves only the schedule draft for review
- id: global-test18-human-slack-resource-with-secret
prompt: |-
I'm preparing Slack notifications for eval failures.
Can you stage a Slack connection in the shared evals/global folder?
The bot token is `xoxb-redacted-test-token`; keep it safe.
Don't deploy anything yet.
runtime:
maxTurns: 8
validate:
draftCountExactly: 2
requiredDrafts:
- type: variable
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- slack
- token
valueIncludes:
- "true"
- type: resource
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- slack
valueIncludes:
- slack
- "$var:"
valueExcludes:
- xoxb-redacted-test-token
toolExpect:
requiredToolsUsed:
- write_variable
- search_resource_types
- write_resource
forbiddenToolsUsed:
- write_schedule
- write_trigger
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: write_variable
field: value
stringStartsWithAnyOf:
- xoxb-redacted-test-token
skipJudge: true
judgeChecklist:
- creates a secret variable draft for the Slack bot token placeholder
- creates a Slack resource draft that references the secret variable instead of embedding the token
- keeps both drafts under a sensible eval/global Slack-related path
- does not create schedules, triggers, or deployed workspace changes
+5
View File
@@ -5,6 +5,9 @@
Keep it simple and do not add external dependencies.
initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
toolExpect:
requiredToolsUsed:
- test_run_script
judgeChecklist:
- uses the existing `name` input
- returns a plain greeting string
@@ -20,6 +23,7 @@
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
toolExpect:
requiredToolsUsed:
- test_run_script
- create_schedule
toolCallArgs:
- tool: create_schedule
@@ -44,6 +48,7 @@
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
toolExpect:
requiredToolsUsed:
- test_run_script
- create_trigger
toolCallArgs:
- tool: create_trigger
+16 -1
View File
@@ -14,6 +14,21 @@ describe("loadCases", () => {
},
},
});
expect(caseEntry?.toolExpect).toEqual({
requiredToolsUsed: ["test_run_flow"],
});
});
it("loads script and flow test tool expectations", async () => {
const scriptCases = await loadCases("script");
const flowCases = await loadCases("flow");
expect(scriptCases.find((entry) => entry.id === "script-test1-greet-user")?.toolExpect).toEqual({
requiredToolsUsed: ["test_run_script"],
});
expect(flowCases.find((entry) => entry.id === "flow-test0-sum-two-numbers")?.toolExpect).toEqual({
requiredToolsUsed: ["test_run_flow"],
});
});
it("loads the workspace-flow preference benchmark case", async () => {
@@ -238,7 +253,7 @@ describe("loadCases", () => {
);
expect(caseEntry?.toolExpect).toEqual({
requiredToolsUsed: ["create_schedule"],
requiredToolsUsed: ["test_run_script", "create_schedule"],
toolCallArgs: [
{
tool: "create_schedule",
+2 -10
View File
@@ -18,14 +18,6 @@ describe("resolveEvalModel", () => {
});
it("supports Gemini aliases for frontend evals", () => {
expect(resolveEvalModel("flow", "gemini").frontend).toEqual({
provider: "googleai",
model: "gemini-2.5-flash",
});
expect(resolveEvalModel("app", "gemini-pro").frontend).toEqual({
provider: "googleai",
model: "gemini-2.5-pro",
});
expect(
resolveEvalModel("script", "gemini-3-flash-preview").frontend,
).toEqual({
@@ -52,8 +44,8 @@ describe("resolveEvalModel", () => {
});
it("rejects Gemini aliases for cli evals", () => {
expect(() => resolveEvalModel("cli", "gemini")).toThrow(
"Model gemini-flash is not supported for cli mode",
expect(() => resolveEvalModel("cli", "gemini-3-flash-preview")).toThrow(
"Model gemini-3-flash-preview is not supported for cli mode",
);
});
});
-18
View File
@@ -96,24 +96,6 @@ export const EVAL_MODELS: EvalModelSpec[] = [
model: "gpt-5.5",
},
},
{
id: "gemini-flash",
label: "Gemini 2.5 Flash",
aliases: ["gemini", "gemini-flash", "gemini-2.5-flash"],
frontend: {
provider: "googleai",
model: "gemini-2.5-flash",
},
},
{
id: "gemini-pro",
label: "Gemini 2.5 Pro",
aliases: ["gemini-pro", "gemini-2.5-pro"],
frontend: {
provider: "googleai",
model: "gemini-2.5-pro",
},
},
{
id: "gemini-3-flash-preview",
label: "Gemini 3 Flash Preview",
@@ -0,0 +1,23 @@
{
"workspace": {
"scripts": [
{
"path": "f/evals/global/send_report_digest",
"summary": "Build and send the eval report digest",
"description": "Returns a dry-run summary for eval report digest notifications.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"dry_run": {
"type": "boolean"
}
},
"required": ["dry_run"]
},
"content": "export async function main(dry_run: boolean) {\n return { dry_run, sent: !dry_run, message: dry_run ? 'Preview digest' : 'Digest sent' }\n}\n"
}
]
}
}
+142 -152
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.712.0"
version = "1.713.1"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.712.0"
version = "1.713.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
08e3b9b818f9f1b439e9a6ba477fd5fdbcab944e
3742e0659c5e97aab03b9efeea14cd94a3ac658a
+24 -24
View File
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6263,7 +6263,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"proc-macro2",
"quote",
@@ -6275,7 +6275,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"convert_case",
"serde",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6296,7 +6296,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6308,7 +6308,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"gosyn",
@@ -6320,7 +6320,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6332,7 +6332,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6344,7 +6344,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6378,7 +6378,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -6411,7 +6411,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6423,7 +6423,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6437,7 +6437,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"convert_case",
@@ -6454,7 +6454,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6467,7 +6467,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"serde",
@@ -6479,7 +6479,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6513,7 +6513,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6529,7 +6529,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6561,7 +6561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"serde",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.712.0"
version = "1.713.1"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.712.0"
version = "1.713.1"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+10
View File
@@ -258,6 +258,15 @@ pub fn main() -> anyhow::Result<()> {
}
async fn cache_hub_scripts(file_path: Option<String>) -> anyhow::Result<()> {
// The `cache` CLI mode never connects to the DB, so HUB_BASE_URL keeps its
// compiled default. Allow overriding it via env so the prebuild cache step can
// be pointed at a private/staging hub (e.g. a local proxy for testing).
if let Ok(hub_base_url) = std::env::var("HUB_BASE_URL") {
if !hub_base_url.is_empty() {
tracing::info!("Overriding hub base url from env: {hub_base_url}");
windmill_common::HUB_BASE_URL.store(std::sync::Arc::new(hub_base_url));
}
}
let file_path = file_path.unwrap_or("./hubPaths.json".to_string());
let mut file = File::open(&file_path)
.await
@@ -567,6 +576,7 @@ fn print_help() {
println!(" RUN_UPDATE_CA_CERTIFICATE_AT_START = false Run system CA update at startup");
println!(" RUN_UPDATE_CA_CERTIFICATE_PATH = /usr/sbin/update-ca-certificates Path to CA update tool");
println!(" SYNC_CACHED_RT = false Sync cached resource types to admins workspace on server start");
println!(" HUB_BASE_URL = https://hub.windmill.dev Hub to fetch scripts from in `cache` mode (server/worker use the DB setting instead)");
println!();
println!("Notes:");
println!("- Advanced and less commonly used settings are managed via the database and are omitted here.");
+2
View File
@@ -451,6 +451,7 @@ def main():
preserve_on_behalf_of: None,
ws_error_handler_muted: None,
labels: None,
skip_draft_deletion: None,
})
.send()
.await
@@ -513,6 +514,7 @@ def main():
custom_path: None,
preserve_on_behalf_of: None,
labels: None,
skip_draft_deletion: None,
})
.send()
.await
+1 -1
View File
@@ -20,7 +20,7 @@ where
lazy_static::lazy_static! {
static ref OPENAI_AZURE_BASE_PATH: Option<String> = std::env::var("OPENAI_AZURE_BASE_PATH").ok();
static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS")
pub static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS")
.ok()
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
+477
View File
@@ -235,6 +235,202 @@ where
Ok(())
}
/// Returns the caller's "real" scope restrictions: every scope other than
/// `if_jobs:filter_tags:` tag filters. `None` means the token is unscoped and
/// has the full privileges of its user; `Some` means it is restricted to the
/// returned scopes. An empty or filter-tags-only scope list is treated as
/// unscoped, mirroring `check_scopes`/`check_route_access`.
fn scope_restrictions(scopes: Option<&[String]>) -> Option<Vec<&String>> {
let restrictions: Vec<&String> = scopes?
.iter()
.filter(|s| !s.starts_with("if_jobs:filter_tags:"))
.collect();
(!restrictions.is_empty()).then_some(restrictions)
}
/// Enforce monotonic privilege when a token lifecycle endpoint mints or rescopes
/// a credential on behalf of `authed`: the resulting credential must never be
/// more privileged than the caller's own token.
///
/// - An unscoped caller may grant any scopes (this is the existing UI/CLI flow).
/// - A scope-restricted caller may only grant scopes that are a subset of its
/// own, and may never produce an unscoped credential.
///
/// Without this, a `users:write` token could create or rescope a token to be
/// unscoped, and a `users:read` token could refresh into an unscoped session —
/// escaping its own restrictions.
pub fn ensure_scopes_within_caller(
authed: &ApiAuthed,
requested_scopes: Option<&[String]>,
) -> error::Result<()> {
if let Some(caller_restrictions) = scope_restrictions(authed.scopes.as_deref()) {
let Some(requested_restrictions) = scope_restrictions(requested_scopes) else {
return Err(Error::PermissionDenied(
"A scope-restricted token cannot create or update a token with broader (unscoped) \
privileges"
.to_string(),
));
};
// MCP scopes (`mcp:all`, `mcp:favorites`, `mcp:scripts:*`, etc.) use a
// custom format that ScopeDefinition::from_scope_string parses
// permissively but the MCP runtime interprets via its own parser
// (parse_mcp_scopes). The two views disagree — e.g. the generic parser
// accepts `mcp:scripts` as an unrestricted-resource scope, while the
// MCP runtime ignores it as unrecognized but interprets `mcp:scripts:*`
// as granting all scripts. So generic containment would silently allow
// `mcp:scripts` → `mcp:scripts:*` (a widening). Legitimate MCP token
// issuance goes through the OAuth gateway (mcp/oauth_server.rs), not
// these user-token endpoints, so require byte-identical match for MCP
// scopes here rather than trying to mirror MCP semantics in two places.
// Unparseable non-MCP caller scopes are intentionally dropped
// (fail-closed): a caller scope that fails to parse can only narrow
// the set of requested scopes that get covered, never widen it.
// Unparseable requested scopes surface as `BadRequest`, which is what
// we want — the client is sending garbage.
let parsed_caller: Vec<ScopeDefinition> = caller_restrictions
.iter()
.filter(|s| !s.starts_with("mcp:"))
.filter_map(|s| ScopeDefinition::from_scope_string(s).ok())
.collect();
let caller_mcp: std::collections::HashSet<&str> = caller_restrictions
.iter()
.filter(|s| s.starts_with("mcp:"))
.map(|s| s.as_str())
.collect();
for requested in requested_restrictions {
if requested.starts_with("mcp:") {
if !caller_mcp.contains(requested.as_str()) {
return Err(Error::PermissionDenied(format!(
"A scope-restricted token cannot grant MCP scope '{requested}' unless the \
caller holds the same scope verbatim"
)));
}
continue;
}
let requested_scope = ScopeDefinition::from_scope_string(requested)?;
let covered = parsed_caller
.iter()
.any(|caller_scope| scope_contains(caller_scope, &requested_scope));
if !covered {
return Err(Error::PermissionDenied(format!(
"A scope-restricted token cannot grant scope '{requested}' which exceeds its \
own scopes"
)));
}
}
}
// `if_jobs:filter_tags:` fences which job tags a token can run on (enforced
// at job operations as `v2_job.tag = ANY(...)`), and is checked independently
// of domain/action/resource subset. A caller restricted by filter_tags must
// not be able to mint or rescope a credential that drops or widens the fence
// — even if the caller has no other scope restrictions (filter_tags-only
// tokens otherwise look "unscoped" to `scope_restrictions`).
if let Some(caller_tags) = first_filter_tags(authed.scopes.as_deref()) {
let Some(requested_tags) = first_filter_tags(requested_scopes) else {
return Err(Error::PermissionDenied(
"A token restricted by if_jobs:filter_tags cannot mint or rescope a token that \
drops the tag restriction"
.to_string(),
));
};
let caller_set: std::collections::HashSet<&str> = caller_tags.iter().copied().collect();
for tag in &requested_tags {
if !caller_set.contains(tag) {
return Err(Error::PermissionDenied(format!(
"A token restricted by if_jobs:filter_tags cannot grant tag '{tag}' which is \
not within its own filter_tags"
)));
}
}
}
Ok(())
}
/// Tags from the first `if_jobs:filter_tags:<a,b,...>` scope, matching the
/// semantics of [`get_scope_tags`] (which is what the job runtime consults).
/// Returns `None` if no such scope is present.
fn first_filter_tags(scopes: Option<&[String]>) -> Option<Vec<&str>> {
scopes?.iter().find_map(|s| {
s.strip_prefix("if_jobs:filter_tags:")
.map(|tags| tags.split(',').collect())
})
}
/// Whether `caller` grants at least everything `requested` grants (directional
/// containment).
///
/// This is intentionally NOT `ScopeDefinition::includes`: that method answers
/// "does this scope grant access to a required action" using OR semantics over
/// resources (any overlap counts, and a `*` on either side matches), which is
/// correct for access checks but unsafe for subset checks — it would let a
/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or
/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that
/// EVERY requested resource is covered by SOME caller resource.
fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool {
if caller.domain != requested.domain {
return false;
}
// write subsumes read; otherwise the action must match exactly.
match (caller.action.as_str(), requested.action.as_str()) {
(c, r) if c == r || (c == "write" && r == "read") => {}
_ => return false,
}
if caller.domain == "jobs" && caller.action == "run" {
match (&caller.kind, &requested.kind) {
(Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => {
return false
}
// Caller pinned to a kind, but the request covers any kind.
(Some(_), None) => return false,
_ => {}
}
}
match (&caller.resource, &requested.resource) {
// Caller is unrestricted on resources: covers everything.
(None, _) => true,
// Caller is resource-restricted but the request is not: broader.
(Some(_), None) => false,
(Some(caller_resources), Some(requested_resources)) => {
resource_set_contains(caller_resources, requested_resources)
}
}
}
/// Every resource in `requested` must be covered by some resource in `caller`.
fn resource_set_contains(caller: &[String], requested: &[String]) -> bool {
if caller.iter().any(|r| r == "*") {
return true;
}
requested
.iter()
.all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req)))
}
/// Directional: does the single caller resource pattern cover `requested`?
/// `caller` may be an exact path or a `<prefix>/*` subtree wildcard; `requested`
/// may itself be a subtree wildcard, in which case the whole requested subtree
/// must fall within the caller's subtree.
fn resource_covers(caller: &str, requested: &str) -> bool {
if caller == requested {
return true;
}
let Some(prefix) = caller.strip_suffix("/*") else {
// An exact caller resource only covers itself (handled above).
return false;
};
let requested_base = requested.strip_suffix("/*").unwrap_or(requested);
requested_base == prefix
|| (requested_base.starts_with(prefix)
&& requested_base.as_bytes().get(prefix.len()) == Some(&b'/'))
}
/// Returns a predicate that checks whether `path` is within the token's
/// scope for `{domain}:{action}:{path}`. For tokens without scope
/// restrictions (no scopes at all, or only `if_jobs:filter_tags:*` scopes),
@@ -574,6 +770,13 @@ impl NewToken {
}
}
/// Low-level token mint shared by trusted callers (the user-facing
/// `tokens/create` handler and internal mints such as native-trigger webhook
/// tokens). It does NOT enforce that `token_config.scopes` is within the
/// caller's own scopes — callers exposed to untrusted input must call
/// [`ensure_scopes_within_caller`] first (internal narrowing mints intentionally
/// skip it, since their scopes derive from the action being authorized, not the
/// caller's token).
pub async fn create_token_internal(
tx: &mut sqlx::PgConnection,
db: &DB,
@@ -908,4 +1111,278 @@ mod tests {
assert!(allowed("u/alice/foo"));
assert!(!allowed("u/alice/bar"));
}
fn opt_scopes(scopes: Option<Vec<&str>>) -> Option<Vec<String>> {
scopes.map(|v| v.into_iter().map(String::from).collect())
}
// Regression tests for WIN-1999: scoped user tokens must not be able to
// mint or rescope credentials with broader privileges than themselves.
#[test]
fn unscoped_caller_can_grant_anything() {
let authed = authed_with_scopes(None);
assert!(ensure_scopes_within_caller(&authed, None).is_ok());
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
)
.is_ok());
}
#[test]
fn filter_tags_only_caller_is_unrestricted_on_domain_action_dimension() {
// The domain/action/resource subset check treats filter-tags-only as
// unrestricted, mirroring check_scopes/check_route_access. The tag
// dimension is checked separately (see filter_tags_dimension_is_monotonic).
let authed = authed_with_scopes(Some(vec!["if_jobs:filter_tags:default"]));
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["users:write", "if_jobs:filter_tags:default"])).as_deref()
)
.is_ok());
}
#[test]
fn filter_tags_dimension_is_monotonic() {
// Caller restricted to tag fence "a" cannot drop the fence …
let single = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a"]));
assert!(ensure_scopes_within_caller(&single, None).is_err());
assert!(
ensure_scopes_within_caller(&single, opt_scopes(Some(vec!["users:read"])).as_deref())
.is_err(),
"minting a token without filter_tags must be rejected"
);
// … cannot widen to a tag it lacks …
assert!(ensure_scopes_within_caller(
&single,
opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref()
)
.is_err());
// … and cannot mint a token fenced on a disjoint tag.
assert!(ensure_scopes_within_caller(
&single,
opt_scopes(Some(vec!["if_jobs:filter_tags:b"])).as_deref()
)
.is_err());
// Narrowing or matching the tag fence is allowed.
let multi = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a,b"]));
assert!(ensure_scopes_within_caller(
&multi,
opt_scopes(Some(vec!["if_jobs:filter_tags:a"])).as_deref()
)
.is_ok());
assert!(ensure_scopes_within_caller(
&multi,
opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref()
)
.is_ok());
// A caller with a real scope plus a tag fence cannot drop just the fence.
let mixed = authed_with_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"]));
assert!(ensure_scopes_within_caller(
&mixed,
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
)
.is_err());
assert!(ensure_scopes_within_caller(
&mixed,
opt_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"])).as_deref()
)
.is_ok());
// An unrestricted caller may grant filter_tags freely.
let unscoped = authed_with_scopes(None);
assert!(ensure_scopes_within_caller(
&unscoped,
opt_scopes(Some(vec!["if_jobs:filter_tags:x"])).as_deref()
)
.is_ok());
}
#[test]
fn scoped_caller_cannot_mint_unscoped_token() {
// Primitive 2 in the report: a users:write token minting an unscoped token.
let authed = authed_with_scopes(Some(vec!["users:write"]));
assert!(ensure_scopes_within_caller(&authed, None).is_err());
// Empty scope list is effectively unscoped and must also be rejected.
assert!(ensure_scopes_within_caller(&authed, Some(&[])).is_err());
// A scope list of only tag filters is effectively unscoped too.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["if_jobs:filter_tags:default"])).as_deref()
)
.is_err());
}
#[test]
fn scoped_caller_cannot_remove_its_own_scopes() {
// Primitive 3 in the report: a users:write token setting its scopes to null.
let authed = authed_with_scopes(Some(vec!["users:write"]));
assert!(ensure_scopes_within_caller(&authed, None).is_err());
}
#[test]
fn scoped_caller_cannot_grant_scope_it_lacks() {
let authed = authed_with_scopes(Some(vec!["users:write"]));
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
)
.is_err());
}
#[test]
fn scoped_caller_can_grant_subset_of_own_scopes() {
let authed = authed_with_scopes(Some(vec!["users:write", "jobs:run:scripts"]));
// Equal scope is allowed.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
)
.is_ok());
// write implies read, so a narrower read scope is allowed.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["users:read"])).as_deref()
)
.is_ok());
// Tag filters narrow further and are always permitted.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["users:read", "if_jobs:filter_tags:default"])).as_deref()
)
.is_ok());
}
#[test]
fn scoped_caller_cannot_broaden_resource_scope() {
let authed = authed_with_scopes(Some(vec!["scripts:read:f/team/*"]));
// Narrower resource within the subtree is allowed.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["scripts:read:f/team/sub"])).as_deref()
)
.is_ok());
// A nested subtree within the caller's subtree is allowed.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["scripts:read:f/team/sub/*"])).as_deref()
)
.is_ok());
// The subtree root itself is allowed.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["scripts:read:f/team"])).as_deref()
)
.is_ok());
// A path outside the subtree is rejected.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["scripts:read:f/other/x"])).as_deref()
)
.is_err());
// read caller cannot grant write.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["scripts:write:f/team/db"])).as_deref()
)
.is_err());
}
#[test]
fn mcp_scopes_require_byte_identical_match() {
// Regression for the access-grant-OR vs runtime-MCP-parser confusion:
// ScopeDefinition treats `mcp:scripts` as an unrestricted-resource scope
// and `mcp:scripts:*` as a strictly narrower one, so generic containment
// would silently allow widening. The MCP runtime however ignores
// `mcp:scripts` (unrecognized) while `mcp:scripts:*` grants all scripts.
// Legitimate MCP token issuance is the OAuth gateway, not these
// user-token endpoints, so MCP scopes must match the caller verbatim.
// The bypass the reviewer flagged: malformed `mcp:scripts` would widen
// into the real `mcp:scripts:*` under generic containment.
let bypass = authed_with_scopes(Some(vec!["users:write", "mcp:scripts"]));
assert!(ensure_scopes_within_caller(
&bypass,
opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref()
)
.is_err());
// A caller without any MCP scope cannot grant one (widening on the MCP
// dimension), even if the rest of the requested scopes are within reach.
let no_mcp = authed_with_scopes(Some(vec!["users:write"]));
assert!(ensure_scopes_within_caller(
&no_mcp,
opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref()
)
.is_err());
// Byte-identical MCP scope passes; an additional non-matching MCP scope
// alongside it does not.
let mcp_caller = authed_with_scopes(Some(vec!["mcp:scripts:*"]));
assert!(ensure_scopes_within_caller(
&mcp_caller,
opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref()
)
.is_ok());
assert!(ensure_scopes_within_caller(
&mcp_caller,
opt_scopes(Some(vec!["mcp:scripts:*", "mcp:flows:*"])).as_deref()
)
.is_err());
// Even a narrowing within MCP semantics (`mcp:all` → `mcp:scripts:*`)
// is rejected by the byte-identical rule. This is intentional — these
// endpoints are not the legitimate path for narrowing MCP tokens.
let mcp_all = authed_with_scopes(Some(vec!["mcp:all"]));
assert!(ensure_scopes_within_caller(
&mcp_all,
opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref()
)
.is_err());
}
#[test]
fn scoped_caller_cannot_escalate_to_wildcard_or_superset() {
// Regression for the access-grant-OR vs subset-containment confusion:
// ScopeDefinition::includes would (incorrectly) allow all of these.
let star = authed_with_scopes(Some(vec!["scripts:read:f/team/a"]));
// Minting `*` from a single-path scope must be rejected.
assert!(ensure_scopes_within_caller(
&star,
opt_scopes(Some(vec!["scripts:read:*"])).as_deref()
)
.is_err());
// Minting a broader subtree must be rejected.
assert!(ensure_scopes_within_caller(
&star,
opt_scopes(Some(vec!["scripts:read:f/team/*"])).as_deref()
)
.is_err());
// A comma-separated list that adds an uncovered resource must be rejected,
// even though one element overlaps the caller's scope.
let list = authed_with_scopes(Some(vec!["scripts:read:f/team/a"]));
assert!(ensure_scopes_within_caller(
&list,
opt_scopes(Some(vec!["scripts:read:f/team/a,f/other/b"])).as_deref()
)
.is_err());
// A subset of a multi-resource caller scope is allowed.
let multi = authed_with_scopes(Some(vec!["scripts:read:f/team/a,f/team/b"]));
assert!(ensure_scopes_within_caller(
&multi,
opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref()
)
.is_ok());
// A wildcard caller covers any subset, but not `*`-less escalation rules apply
// only when the caller itself lacks `*`.
let wildcard = authed_with_scopes(Some(vec!["scripts:read:*"]));
assert!(ensure_scopes_within_caller(
&wildcard,
opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref()
)
.is_ok());
}
}
+23 -14
View File
@@ -551,13 +551,17 @@ async fn create_flow(
w_id
).execute(&mut *tx).await?;
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
nf.path,
&w_id
)
.execute(&mut *tx)
.await?;
// CLI / git-sync deploys ask us to preserve any existing user draft at this
// path instead of wiping it as part of the deploy.
if !nf.skip_draft_deletion.unwrap_or(false) {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
nf.path,
&w_id
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
@@ -1138,13 +1142,17 @@ async fn update_flow(
})?;
}
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
flow_path,
&w_id
)
.execute(&mut *tx)
.await?;
// CLI / git-sync deploys ask us to preserve any existing user draft at this
// path instead of wiping it as part of the deploy.
if !nf.skip_draft_deletion.unwrap_or(false) {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
flow_path,
&w_id
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
@@ -1956,6 +1964,7 @@ mod tests {
})),
preprocessor_module: None,
same_worker: false,
preserve_step_tags: false,
skip_expr: None,
cache_ttl: None,
cache_ignore_s3_path: None,
@@ -383,6 +383,8 @@ async fn remove_granular_acl(
// workspace export.
let table = if kind == "raw_app" { "app" } else { kind };
// SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function.
// LIMIT 1: `script` shares (workspace_id, path) across versions, so `old` can
// return >1 row, which would break the scalar subquery in RETURNING.
let obj_o = sqlx::query_scalar::<_, bool>(&format!(
"WITH old AS (
SELECT extra_perms->$1 as old_write FROM {table}
@@ -390,7 +392,7 @@ async fn remove_granular_acl(
)
UPDATE {table} SET extra_perms = extra_perms - $1
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
RETURNING (SELECT old_write FROM old)::bool"
RETURNING (SELECT old_write FROM old LIMIT 1)::bool"
))
.bind(&owner)
.bind(path)
+16 -8
View File
@@ -614,6 +614,9 @@ async fn is_noop_deploy_against_parent(
// caller-intent flag (auto-resolve parent), not script state
auto_parent: _,
labels,
// caller-intent flag (preserve user drafts on CLI/git-sync deploys);
// transient, never persisted, does not change what the script *is*
skip_draft_deletion: _,
} = ns;
if path != &parent.path {
@@ -802,6 +805,9 @@ async fn create_script_internal<'c>(
}
}
let script_path = ns.path.clone();
// Caller-intent: CLI / git-sync deploys ask us to preserve any existing
// user draft at this path instead of wiping it as part of the deploy.
let skip_draft_deletion = ns.skip_draft_deletion.unwrap_or(false);
let hash = ScriptHash(hash_script(&ns));
let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await;
@@ -1234,13 +1240,15 @@ async fn create_script_internal<'c>(
let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone());
if let Some(ref p_path) = p_path_opt {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
p_path,
&w_id
)
.execute(&mut *tx)
.await?;
if !skip_draft_deletion {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
p_path,
&w_id
)
.execute(&mut *tx)
.await?;
}
sqlx::query!(
"UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
@@ -1319,7 +1327,7 @@ async fn create_script_internal<'c>(
tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
}
}
} else {
} else if !skip_draft_deletion {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
ns.path,
+22 -5
View File
@@ -1964,7 +1964,8 @@ async fn login(
windmill_common::login_rate_limit::record_login_failure(&email);
Err(Error::BadRequest("Invalid login".to_string()))
} else {
let token = create_session_token(&email, super_admin, &mut tx, cookies).await?;
let token =
create_session_token(&email, super_admin, None, false, &mut tx, cookies).await?;
let audit_author = AuditAuthor {
email: email.clone(),
@@ -2036,7 +2037,15 @@ async fn refresh_token(
.await?
.unwrap_or(false);
let new_token = create_session_token(&authed.email, super_admin, &mut tx, cookies).await?;
let new_token = create_session_token(
&authed.email,
super_admin,
authed.scopes.as_deref(),
authed.read_only,
&mut tx,
cookies,
)
.await?;
audit_log(
&mut *tx,
@@ -2066,6 +2075,8 @@ lazy_static::lazy_static! {
pub async fn create_session_token<'c>(
email: &str,
super_admin: bool,
scopes: Option<&[String]>,
read_only: bool,
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
cookies: Cookies,
) -> Result<String> {
@@ -2108,15 +2119,17 @@ pub async fn create_session_token<'c>(
sqlx::query!(
"INSERT INTO token
(token_hash, token_prefix, token, email, label, expiration, super_admin)
VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)",
(token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, read_only)
VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7, $8, $9)",
t_hash,
t_prefix,
plaintext as Option<&str>,
email,
"session",
&MAX_SESSION_VALIDITY_SECONDS.to_string(),
super_admin
super_admin,
scopes,
read_only,
)
.execute(&mut **tx)
.await?;
@@ -2146,6 +2159,8 @@ async fn create_token(
) -> Result<(StatusCode, String)> {
check_token_create_rate_limit(&authed.username)?;
windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?;
let mut tx = db.begin().await?;
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
@@ -2353,6 +2368,8 @@ async fn update_token_scopes(
Path(token_prefix): Path<String>,
Json(req): Json<UpdateTokenScopesRequest>,
) -> Result<String> {
windmill_api_auth::ensure_scopes_within_caller(&authed, req.scopes.as_deref())?;
let mut tx = db.begin().await?;
let updated: Option<String> = sqlx::query_scalar!(
+22 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.712.0
version: 1.713.1
title: Windmill API
contact:
@@ -9831,6 +9831,9 @@ paths:
type: boolean
deployment_message:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path."
responses:
"201":
description: flow created
@@ -9872,6 +9875,9 @@ paths:
properties:
deployment_message:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path."
responses:
"200":
@@ -10370,6 +10376,9 @@ paths:
type: array
items:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
required:
- path
- value
@@ -10422,6 +10431,9 @@ paths:
type: array
items:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
required:
- path
- value
@@ -10726,6 +10738,9 @@ paths:
type: array
items:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
responses:
"200":
description: app updated
@@ -10772,6 +10787,9 @@ paths:
type: array
items:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
js:
type: string
css:
@@ -22032,6 +22050,9 @@ components:
type: array
items:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path."
required:
- path
+16
View File
@@ -305,6 +305,22 @@ async fn get_token_using_oauth(
resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?;
resource.client_secret = resolve_var(resource.client_secret, db, w_id, user_db, authed).await?;
resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?;
// Validate the resolved token_url against SSRF rules before issuing the request,
// mirroring the protection applied to base_url in `get_base_url` (same
// ALLOW_PRIVATE_AI_BASE_URLS opt-in). Without this a workspace member could
// point token_url at an internal/metadata address.
if !*windmill_ai::ai_providers::ALLOW_PRIVATE_AI_BASE_URLS {
use windmill_common::ssrf::SsrfValidationError;
windmill_common::ssrf::validate_url_for_ssrf(&resource.token_url)
.await
.map_err(|e| match e {
e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!(
"{e}. If you need to use private/internal AI endpoints, \
set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable"
)),
e => Error::from(e),
})?;
}
let mut params = HashMap::new();
params.insert("grant_type", "client_credentials");
params.insert("scope", "https://cognitiveservices.azure.com/.default");
+32 -14
View File
@@ -291,6 +291,11 @@ pub struct CreateApp {
pub preserve_on_behalf_of: Option<bool>,
#[serde(default)]
pub labels: Option<Vec<String>>,
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
/// this app must NOT delete an existing user draft at the same path.
/// Transient — never persisted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_draft_deletion: Option<bool>,
}
#[derive(Serialize, Deserialize)]
@@ -304,6 +309,11 @@ pub struct EditApp {
pub preserve_on_behalf_of: Option<bool>,
#[serde(default)]
pub labels: Option<Vec<String>>,
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
/// this app must NOT delete an existing user draft at the same path.
/// Transient — never persisted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_draft_deletion: Option<bool>,
}
#[derive(Serialize, FromRow)]
@@ -1294,13 +1304,17 @@ async fn create_app_internal<'a>(
));
}
}
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
&app.path,
&w_id
)
.execute(&mut *tx)
.await?;
// CLI / git-sync deploys ask us to preserve any existing user draft at this
// path instead of wiping it as part of the deploy.
if !app.skip_draft_deletion.unwrap_or(false) {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
&app.path,
&w_id
)
.execute(&mut *tx)
.await?;
}
let id = sqlx::query_scalar!(
"INSERT INTO app
(workspace_id, path, summary, policy, versions, draft_only, custom_path, labels)
@@ -1899,13 +1913,17 @@ async fn update_app_internal<'a>(
)));
}
};
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
path,
&w_id
)
.execute(&mut *tx)
.await?;
// CLI / git-sync deploys ask us to preserve any existing user draft at this
// path instead of wiping it as part of the deploy.
if !ns.skip_draft_deletion.unwrap_or(false) {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
path,
&w_id
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&authed,
+85 -9
View File
@@ -39,23 +39,43 @@ pub struct StaticFile(Uri);
impl IntoResponse for StaticFile {
fn into_response(self) -> Response<Body> {
let original_path = self.0.path();
let query = self.0.query();
let path = original_path.trim_start_matches('/');
serve_path(path, original_path)
serve_path(path, original_path, query)
}
}
#[cfg(feature = "static_frontend")]
const TWO_HUNDRED: &str = "200.html";
/// Check if the original path requires cross-origin isolation headers
/// Check if the original path requires cross-origin isolation headers.
///
/// These headers are needed for SharedArrayBuffer and TypeScript workers
/// Only enabled for /apps_raw paths (raw app editor)
/// (raw app editor at `/apps_raw/`, in-browser bundler at `/ui_builder/`).
///
/// Public apps (`/public/` and custom paths `/a/`) opt in via the `wm_coep`
/// query param: a public (raw) app must set COEP to be embeddable as an iframe
/// inside a cross-origin-isolated page (which requires the embedded document to
/// also set COEP). It is opt-in rather than always-on because cross-origin
/// isolation also blocks subresources without CORP (e.g. external image URLs
/// or embeds used by classic apps), so we only enable it when the embedder
/// explicitly requests it.
#[cfg(feature = "static_frontend")]
fn needs_cross_origin_isolation(original_path: &str) -> bool {
original_path.starts_with("/apps_raw/") || original_path.starts_with("/ui_builder/")
fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool {
original_path.starts_with("/apps_raw/")
|| original_path.starts_with("/ui_builder/")
|| ((original_path.starts_with("/public/") || original_path.starts_with("/a/"))
&& query_has_flag(query, "wm_coep"))
}
fn serve_path(path: &str, original_path: &str) -> Response<Body> {
/// Returns true if `query` contains the given flag key (with or without a
/// value), e.g. `?wm_coep`, `?wm_coep=on`, `?foo=1&wm_coep=1`.
#[cfg(feature = "static_frontend")]
fn query_has_flag(query: Option<&str>, flag: &str) -> bool {
query.is_some_and(|q| q.split('&').any(|kv| kv.split('=').next() == Some(flag)))
}
fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response<Body> {
if path.starts_with("api/") {
return Response::builder().status(404).body(Body::empty()).unwrap();
}
@@ -71,7 +91,7 @@ fn serve_path(path: &str, original_path: &str) -> Response<Body> {
// Add cross-origin isolation headers only for paths that need them
// (apps_raw editor needs SharedArrayBuffer for TypeScript workers)
if needs_cross_origin_isolation(original_path) {
if needs_cross_origin_isolation(original_path, query) {
res = res
.header("Cross-Origin-Opener-Policy", "same-origin")
.header("Cross-Origin-Embedder-Policy", "require-corp")
@@ -102,12 +122,68 @@ fn serve_path(path: &str, original_path: &str) -> Response<Body> {
None if path.starts_with("_app/") => {
Response::builder().status(404).body(Body::empty()).unwrap()
}
None => serve_path(TWO_HUNDRED, original_path),
None => serve_path(TWO_HUNDRED, original_path, query),
}
#[cfg(not(feature = "static_frontend"))]
{
let _ = original_path; // suppress unused warning
let _ = (original_path, query); // suppress unused warning
Response::builder().status(404).body(Body::empty()).unwrap()
}
}
#[cfg(all(test, feature = "static_frontend"))]
mod tests {
use super::*;
#[test]
fn test_query_has_flag() {
assert!(query_has_flag(Some("wm_coep"), "wm_coep"));
assert!(query_has_flag(Some("wm_coep=on"), "wm_coep"));
assert!(query_has_flag(Some("foo=1&wm_coep=1"), "wm_coep"));
assert!(query_has_flag(Some("wm_coep&foo=1"), "wm_coep"));
assert!(!query_has_flag(Some("wm_coepx=1"), "wm_coep"));
assert!(!query_has_flag(Some("foo=wm_coep"), "wm_coep"));
assert!(!query_has_flag(Some(""), "wm_coep"));
assert!(!query_has_flag(None, "wm_coep"));
}
#[test]
fn test_needs_cross_origin_isolation() {
// editor + bundler are always isolated, regardless of query
assert!(needs_cross_origin_isolation("/apps_raw/edit/foo", None));
assert!(needs_cross_origin_isolation("/ui_builder/index.html", None));
// public apps (and custom paths) are isolated only when they opt in via wm_coep
assert!(needs_cross_origin_isolation(
"/public/ws/secret",
Some("wm_coep")
));
assert!(needs_cross_origin_isolation(
"/public/ws/secret",
Some("wm_coep=on")
));
assert!(needs_cross_origin_isolation(
"/a/ws/my/path",
Some("wm_coep=on")
));
assert!(!needs_cross_origin_isolation("/public/ws/secret", None));
assert!(!needs_cross_origin_isolation("/a/ws/my/path", None));
assert!(!needs_cross_origin_isolation(
"/public/ws/secret",
Some("foo=1")
));
// unrelated paths never get the headers
assert!(!needs_cross_origin_isolation(
"/apps/get/foo",
Some("wm_coep")
));
// `/api/` must not be caught by the `/a/` prefix
assert!(!needs_cross_origin_isolation(
"/api/version",
Some("wm_coep")
));
assert!(!needs_cross_origin_isolation("/", None));
}
}
+1
View File
@@ -393,6 +393,7 @@ pub async fn clone_script<'c>(
modules: s.modules,
auto_parent: None,
labels: s.labels,
skip_draft_deletion: None,
};
let new_hash = hash_script(&ns);
+1
View File
@@ -5387,6 +5387,7 @@ async fn push_inner<'c, 'd>(
cache_ttl: cache_ttl.map(|val| val as u32),
cache_ignore_s3_path: cache_ignore_s3_path,
same_worker: false,
preserve_step_tags: false,
early_return: None,
skip_expr: None,
preprocessor_module: None,
+12
View File
@@ -109,6 +109,11 @@ pub struct NewFlow {
pub ws_error_handler_muted: Option<bool>,
#[serde(default)]
pub labels: Option<Vec<String>>,
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
/// this flow must NOT delete an existing user draft at the same path.
/// Transient — never persisted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_draft_deletion: Option<bool>,
}
impl NewFlow {
@@ -168,6 +173,13 @@ pub struct FlowValue {
#[serde(default)]
#[serde(skip_serializing_if = "is_default")]
pub same_worker: bool,
// When the flow runs on a custom worker tag, by default that tag is propagated to
// (and overrides) every step, script and nested sub-flow. Set this to true to instead
// let steps that declare their own non-empty tag run on it; steps without their own tag
// still inherit the flow's tag. Defaults to false to preserve the historical behavior.
#[serde(default)]
#[serde(skip_serializing_if = "is_default")]
pub preserve_step_tags: bool,
#[serde(flatten)]
pub concurrency_settings: ConcurrencySettings,
#[serde(flatten)]
+10
View File
@@ -538,9 +538,19 @@ pub struct NewScript {
pub auto_parent: Option<bool>,
#[serde(default)]
pub labels: Option<Vec<String>>,
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
/// this script must NOT delete an existing user draft at the same path.
/// Transient — never persisted. Deliberately excluded from `impl Hash`
/// below (it must not affect the version hash) and from the no-op
/// comparison in the deploy handler (it isn't part of what the script
/// *is*). See `is_noop_deploy_against_parent`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_draft_deletion: Option<bool>,
}
// IMPORTANT: update this Hash impl when adding fields to NewScript
// (exception: caller-intent flags like `skip_draft_deletion` are intentionally
// omitted — they must not influence the computed version hash)
impl Hash for NewScript {
fn hash<H: Hasher>(&self, state: &mut H) {
self.path.hash(state);
@@ -31,6 +31,7 @@ $PY_PATH
$INDEX_URL_ARG $EXTRA_INDEX_URL_ARG $TRUSTED_HOST_ARG
--system
--reinstall
--compile-bytecode
"
echo $CMD
@@ -2095,6 +2095,9 @@ async fn spawn_uv_install(
"--no-cache",
// If we invoke uv pip install, then we want to overwrite existing data
"--reinstall",
// Compile .py to .pyc at install time so imports are fast even
// through read-only nsjail mounts (no in-memory compilation per job).
"--compile-bytecode",
];
if let Some(py_path) = py_path.as_ref() {
+101 -8
View File
@@ -2985,6 +2985,87 @@ struct PushNextFlowJobRec {
// #[async_recursion]
// #[instrument(level = "trace", skip_all)]
/// Resolve the worker tag for a flow's child job (step, nested sub-flow, preprocessor).
///
/// A child normally inherits the parent flow job's tag so the whole flow runs on one worker
/// group. The exceptions, in order:
/// - the preprocessor step, or a flow running on the generic `flow` / `flow-{workspace}` tag,
/// always uses the child's own tag (`step_tag`);
/// - when the flow opts into `preserve_step_tags` and the child declares its own non-empty tag,
/// that tag is honored instead of being overridden by the flow tag;
/// - otherwise the child inherits the parent flow job's tag.
fn resolve_flow_step_tag(
is_preprocessor_step: bool,
flow_tag: &str,
workspace_id: &str,
preserve_step_tags: bool,
step_tag: Option<&str>,
) -> Option<String> {
if is_preprocessor_step || flow_tag == "flow" || flow_tag == format!("flow-{}", workspace_id) {
step_tag.map(str::to_string)
} else if preserve_step_tags && step_tag.is_some_and(|t| !t.is_empty()) {
step_tag.map(str::to_string)
} else {
Some(flow_tag.to_string())
}
}
#[cfg(test)]
mod tag_resolution_tests {
use super::resolve_flow_step_tag;
#[test]
fn step_inherits_custom_flow_tag_by_default() {
// Parent flow on a custom tag, step declares its own tag, preserve disabled:
// the step inherits the flow tag (historical behavior).
assert_eq!(
resolve_flow_step_tag(false, "worker-group-A", "w1", false, Some("worker-group-B")),
Some("worker-group-A".to_string())
);
}
#[test]
fn step_keeps_own_tag_when_preserve_enabled() {
// The exact customer scenario: a sub-flow tagged worker-group-B run as a step of a
// flow tagged worker-group-A now runs on worker-group-B when preserve_step_tags is on.
assert_eq!(
resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("worker-group-B")),
Some("worker-group-B".to_string())
);
}
#[test]
fn untagged_step_inherits_flow_tag_even_when_preserve_enabled() {
assert_eq!(
resolve_flow_step_tag(false, "worker-group-A", "w1", true, None),
Some("worker-group-A".to_string())
);
// An empty tag counts as "no tag" and still inherits.
assert_eq!(
resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("")),
Some("worker-group-A".to_string())
);
}
#[test]
fn generic_flow_tag_always_uses_step_tag() {
for flow_tag in ["flow", "flow-w1"] {
assert_eq!(
resolve_flow_step_tag(false, flow_tag, "w1", false, Some("worker-group-B")),
Some("worker-group-B".to_string())
);
}
}
#[test]
fn preprocessor_step_uses_step_tag() {
assert_eq!(
resolve_flow_step_tag(true, "worker-group-A", "w1", false, Some("worker-group-B")),
Some("worker-group-B".to_string())
);
}
}
async fn push_next_flow_job(
flow_job: Arc<MiniPulledJob>,
mut status: FlowStatus,
@@ -4118,13 +4199,13 @@ async fn push_next_flow_job(
.map(|x| x.into());
tracing::debug!(id = %flow_job.id, root_id = %job_root, "computed perms for job {i} of {len}");
let tag = if step.is_preprocessor_step()
|| (flow_job.tag == "flow" || flow_job.tag == format!("flow-{}", flow_job.workspace_id))
{
payload_tag.tag.clone()
} else {
Some(flow_job.tag.clone())
};
let tag = resolve_flow_step_tag(
step.is_preprocessor_step(),
&flow_job.tag,
&flow_job.workspace_id,
flow.preserve_step_tags,
payload_tag.tag.as_deref(),
);
let (email, permissioned_as) = if let Some(on_behalf_of) = payload_tag.on_behalf_of.as_ref()
{
@@ -4822,6 +4903,7 @@ fn payload_from_modules<'a>(
modules_node: Option<FlowNodeId>,
failure_module: Option<&Box<FlowModule>>,
same_worker: bool,
preserve_step_tags: bool,
id: impl FnOnce() -> String,
path: impl FnOnce() -> String,
opt_empty_inner_flows: bool,
@@ -4842,7 +4924,13 @@ fn payload_from_modules<'a>(
}
Some(JobPayload::RawFlow {
value: FlowValue { modules, failure_module, same_worker, ..Default::default() },
value: FlowValue {
modules,
failure_module,
same_worker,
preserve_step_tags,
..Default::default()
},
path: Some(path()),
restarted_from: None,
})
@@ -5144,6 +5232,7 @@ async fn compute_next_flow_transform(
modules_node,
flow.failure_module.as_ref(),
flow.same_worker,
flow.preserve_step_tags,
|| format!("{}-{i}", status.step),
|| format!("{}/forloop-{i}", flow_job.runnable_path()),
true,
@@ -5280,6 +5369,7 @@ async fn compute_next_flow_transform(
modules_node,
flow.failure_module.as_ref(),
flow.same_worker,
flow.preserve_step_tags,
|| status.step.to_string(),
|| format!("{}/branchone-{}", flow_job.runnable_path(), branch_idx),
true,
@@ -5321,6 +5411,7 @@ async fn compute_next_flow_transform(
modules_node,
flow.failure_module.as_ref(),
flow.same_worker,
flow.preserve_step_tags,
|| format!("{}-{i}", status.step),
|| format!("{}/branchall-{}", flow_job.runnable_path(), i),
false,
@@ -5391,6 +5482,7 @@ async fn compute_next_flow_transform(
modules_node,
flow.failure_module.as_ref(),
flow.same_worker,
flow.preserve_step_tags,
|| format!("{}-{}", status.step, branch_status.branch),
|| {
format!(
@@ -5473,6 +5565,7 @@ async fn next_loop_iteration(
modules_node,
flow.failure_module.as_ref(),
flow.same_worker,
flow.preserve_step_tags,
|| format!("{}-{}", status.step, ns.index),
inner_path,
true,
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.712.0";
export const VERSION = "v1.713.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+6
View File
@@ -192,6 +192,8 @@ export async function pushApp(
deployment_message: message,
...localAppBody,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
}
@@ -205,6 +207,8 @@ export async function pushApp(
deployment_message: message,
...localAppBody,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
}
@@ -480,6 +484,8 @@ const command = new Command()
on_behalf_of_email: email,
} as any,
preserve_on_behalf_of: true,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
log.info(colors.green(`Updated permissioned_as for app ${appPath} to ${email}`));
+4
View File
@@ -466,6 +466,8 @@ export async function pushRawApp(
summary: localApp.summary,
policy: appForPolicy.policy,
deployment_message: message,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
...(localApp.custom_path
? { custom_path: localApp.custom_path }
: {}),
@@ -486,6 +488,8 @@ export async function pushRawApp(
summary: localApp.summary,
policy: appForPolicy.policy,
deployment_message: message,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
...(localApp.custom_path
? { custom_path: localApp.custom_path }
: {}),
+6
View File
@@ -224,6 +224,8 @@ export async function pushFlow(
deployment_message: message,
...localFlowBody,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
}
@@ -237,6 +239,8 @@ export async function pushFlow(
deployment_message: message,
...localFlowBody,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
} catch (e) {
@@ -1159,6 +1163,8 @@ const command = new Command()
path: flowPath,
on_behalf_of_email: email,
preserve_on_behalf_of: true,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
} as any,
});
log.info(colors.green(`Updated permissioned_as for flow ${flowPath} to ${email}`));
+5
View File
@@ -758,6 +758,9 @@ async function createScript(
workspace: Workspace
): Promise<number> {
const start = performance.now();
// Preserve any user draft at this path: a CLI / git-sync deploy must not wipe
// an in-progress draft the way a UI "deploy from draft" intentionally does.
body = { ...body, skip_draft_deletion: true };
// skip_if_noop asks the backend to treat deploys identical to the parent
// (same content, lockfile, and metadata) as a no-op, so the CLI does not
// produce phantom git-sync / promotion commits on re-pushes.
@@ -1796,6 +1799,8 @@ async function setPermissionedAs(
parent_hash: remote.hash,
on_behalf_of_email: email,
preserve_on_behalf_of: true,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
log.info(colors.green(`Updated permissioned_as for script ${scriptPath} to ${email}`));
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -89,7 +89,7 @@ export {
token,
};
export const VERSION = "1.712.0";
export const VERSION = "1.713.1";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
+1 -1
View File
@@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
+1 -1
View File
@@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
+32 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.712.0",
"version": "1.713.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.712.0",
"version": "1.713.1",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -66,6 +66,7 @@
"quill": "^1.3.7",
"rehype-github-alerts": "^3.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rfc4648": "^1.5.3",
"runed": "^0.36.0",
"svelte-carousel": "^1.0.25",
@@ -6352,6 +6353,21 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-sanitize": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
"integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"unist-util-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz",
@@ -10888,6 +10904,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-sanitize": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
"integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-sanitize": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.712.0",
"version": "1.713.1",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
@@ -126,7 +126,6 @@
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
"mdast-util-find-and-replace": "^3.0.2",
"unist-util-visit": "^5.0.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
"monaco-languageclient": "10.6.0",
@@ -141,12 +140,14 @@
"quill": "^1.3.7",
"rehype-github-alerts": "^3.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rfc4648": "^1.5.3",
"runed": "^0.36.0",
"svelte-carousel": "^1.0.25",
"svelte-exmarkdown": "^5.0.0",
"svelte-infinite-loading": "^1.4.0",
"tailwind-merge": "^1.13.2",
"unist-util-visit": "^5.0.0",
"vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0",
"vscode-languageclient": "~9.0.1",
"vscode-uri": "~3.1.0",
@@ -13,6 +13,7 @@
import DropdownV2Inner from './DropdownV2Inner.svelte'
import { pointerDownOutside } from '$lib/utils'
import { createDropdownMenu, melt, createSync } from '@melt-ui/svelte'
import type { MenubarMenuElements } from '@melt-ui/svelte'
import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { twMerge } from 'tailwind-merge'
@@ -40,7 +41,10 @@
size?: ButtonType.UnifiedSize
btnText?: string
buttonReplacement?: import('svelte').Snippet
menu?: import('svelte').Snippet
// In customMenu mode the snippet receives the melt-ui `item` action
// store so consumers can wrap their own rows in <MenuItem> (or
// `use:melt={$item}`) and get arrow-key navigation + aria wiring.
menu?: import('svelte').Snippet<[{ item: MenubarMenuElements['item']; close: () => void }]>
maxHeight?: string | undefined
}
@@ -172,7 +176,7 @@
transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }}
>
{#if customMenu}
{@render menu?.()}
{@render menu?.({ item, close })}
{:else}
<div
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
+31 -1
View File
@@ -147,6 +147,11 @@
preparedAssetsSqlQueries?: InferAssetsSqlQueryDetails[] | undefined
// To execute preview scripts with the right worker group
customTag?: string
// Opt-in: reflect external `code` prop mutations back into Monaco (see
// the effect below). One-way `code={...}` callers that need live
// external updates — e.g. the inline flow rawscript — set this. Off by
// default so every other caller's behavior is unchanged.
syncExternalCode?: boolean
}
let {
@@ -178,7 +183,8 @@
enablePreprocessorSnippet = false,
rawAppRunnableKey = undefined,
preparedAssetsSqlQueries,
customTag
customTag,
syncExternalCode = false
}: Props = $props()
$effect.pre(() => {
@@ -1829,6 +1835,30 @@
$effect(() => {
lang = scriptLangToEditorLang(scriptLang)
})
// Opt-in (syncExternalCode): reflect external `code` prop mutations into
// Monaco's model. Parents that pass `code={...}` one-way (no bind) — e.g.
// the inline rawscript in the flow editor — otherwise mutate the prop
// without Monaco ever showing the change (the AI chat editing a flow
// module's content in a session is the motivating case). Gated off by
// default: Editor is sensitive and most callers either bind:code (and
// carry their own external-sync) or treat code as init-only, so a blanket
// setValue would risk clobbering them. The `getValue() !== code` guard
// keeps the caret intact when the change originated from typing inside
// Monaco (which round-trips code back via `$bindable`, re-firing this
// effect with `code === getValue()`).
let lastExternalCodeSync = code
$effect(() => {
if (!syncExternalCode) return
if (code === lastExternalCodeSync) return
lastExternalCodeSync = code
if (!editor) return
untrack(() => {
if (editor!.getValue() !== code) {
editor!.setValue(code ?? '')
}
})
})
$effect(() => {
filePath = computePath(path)
})
@@ -6,13 +6,28 @@
interface Props {
beforeYaml: string
afterYaml: string
/** Side-by-side vs unified. Leave undefined to let
* FlowGraphDiffViewer show its own user-facing toggle (matches the
* pre-fork-diff-drawer behavior). */
inlineDiff?: boolean
/** Forwarded to FlowGraphDiffViewer — render an empty surface
* placeholder for the "before" / "after" pane when the item is
* added / removed. */
beforeMissing?: boolean
afterMissing?: boolean
}
let { beforeYaml, afterYaml }: Props = $props()
let {
beforeYaml,
afterYaml,
inlineDiff = undefined,
beforeMissing = false,
afterMissing = false
}: Props = $props()
let diffMode: 'yaml' | 'graph' = $state('graph')
</script>
<div class="flex flex-col h-full min-h-[500px] gap-2">
<div class="flex flex-col h-full min-h-[500px]">
<Tabs bind:selected={diffMode}>
<Tab value="graph" label="Graph" />
<Tab value="yaml" label="YAML" />
@@ -30,6 +45,7 @@
defaultLang="yaml"
defaultOriginal={beforeYaml}
defaultModified={afterYaml}
{inlineDiff}
readOnly
/>
{/await}
@@ -37,7 +53,7 @@
{#await import('$lib/components/FlowGraphDiffViewer.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default {beforeYaml} {afterYaml} />
<Module.default {beforeYaml} {afterYaml} {beforeMissing} {afterMissing} {inlineDiff} />
{/await}
{/if}
</div>
@@ -2,12 +2,12 @@
import type { OpenFlow } from '$lib/gen'
import YAML from 'yaml'
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
import { Alert, Button } from './common'
import { Alert } from './common'
import { computeFlowModuleDiff } from './flows/flowDiff'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte'
import type { Viewport } from '@xyflow/svelte'
const SIDE_BY_SIDE_MIN_WIDTH = 700
@@ -15,13 +15,54 @@
interface Props {
beforeYaml: string
afterYaml: string
/** When true, render an empty surface placeholder for the "before"
* pane in side-by-side mode (use for added items where there's no
* prior flow to show). */
beforeMissing?: boolean
/** Same as `beforeMissing` but for the "after" pane (use for removed
* items). */
afterMissing?: boolean
/** Render the unified single-pane diff when true, side-by-side
* otherwise. When undefined, the component renders its own
* Unified / Side-by-side toggle in the corner (legacy behavior for
* the standalone comparison page). A narrow viewer still falls back
* to unified automatically. */
inlineDiff?: boolean | undefined
}
let { beforeYaml, afterYaml }: Props = $props()
let {
beforeYaml,
afterYaml,
beforeMissing = false,
afterMissing = false,
inlineDiff = undefined
}: Props = $props()
// Local toggle state, used only when no inlineDiff prop is supplied.
let localViewMode = $state<'sidebyside' | 'unified'>('sidebyside')
const showLocalToggle = $derived(inlineDiff === undefined)
const effectiveInlineDiff = $derived(
inlineDiff !== undefined ? inlineDiff : localViewMode === 'unified'
)
let viewerWidth = $state(SIDE_BY_SIDE_MIN_WIDTH)
let beforePaneSize = $state(50)
let viewMode = $state<'sidebyside' | 'unified'>('sidebyside')
// Track the content area's rendered height so unified-mode graphs can
// grow to fill the diff box (otherwise FlowGraphV2 sits at its
// content-fit height + small floor, leaving empty space below).
let contentAreaHeight = $state(0)
// Each FlowGraphV2 sizes itself to its own content (clamped to minHeight).
// In side-by-side mode we want both graphs to share the same height, so
// we track each side's reported height and feed back the max as minHeight
// to both. The width-graph then stays at its computed size; the shorter
// graph grows to match.
let beforeContentHeight = $state(0)
let afterContentHeight = $state(0)
const SHARED_MIN_HEIGHT = 400
const sharedMinHeight = $derived(
Math.max(SHARED_MIN_HEIGHT, beforeContentHeight, afterContentHeight)
)
// Shared viewport for synchronizing both graphs in side-by-side mode
let sharedViewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 })
@@ -29,7 +70,10 @@
let beforeGraph: FlowGraphV2 | undefined = $state(undefined)
let afterGraph: FlowGraphV2 | undefined = $state(undefined)
function parseFlow(yaml: string, label: 'before' | 'after'): {
function parseFlow(
yaml: string,
label: 'before' | 'after'
): {
flow: OpenFlow | undefined
error: string | undefined
} {
@@ -49,14 +93,27 @@
}
}
let beforeParsed = $derived.by(() => parseFlow(beforeYaml, 'before'))
let afterParsed = $derived.by(() => parseFlow(afterYaml, 'after'))
// For added/removed items, the caller passes empty YAML and sets the
// corresponding *Missing flag. We swap in an empty OpenFlow stub on
// that side so the unified diff path still has something to compare
// against (every module on the present side becomes added / removed).
// The side-by-side rendering uses the flag directly to draw a
// placeholder pane instead.
const EMPTY_FLOW: OpenFlow = { summary: '', value: { modules: [] } }
let beforeParsed = $derived.by(() =>
beforeMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(beforeYaml, 'before')
)
let afterParsed = $derived.by(() =>
afterMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(afterYaml, 'after')
)
let parseError = $derived(beforeParsed.error ?? afterParsed.error)
let beforeFlow: OpenFlow | undefined = $derived(beforeParsed.flow)
let afterFlow: OpenFlow | undefined = $derived(afterParsed.flow)
// Determine if we should render side-by-side or unified (user controlled via toggle)
let isSideBySide = $derived(viewMode === 'sidebyside')
// Side-by-side unless the caller asked for unified, OR the viewer pane
// is too narrow to comfortably split (fallback to unified for legibility).
const isSideBySide = $derived(!effectiveInlineDiff && viewerWidth >= SIDE_BY_SIDE_MIN_WIDTH)
// Build timeline using history-based approach
// In side-by-side view, mark removed modules as 'shadowed' in the After graph
@@ -72,14 +129,6 @@
sharedViewport = viewport
}
}
$effect(() => {
if (viewerWidth < SIDE_BY_SIDE_MIN_WIDTH) {
viewMode = 'unified'
} else {
viewMode = 'sidebyside'
}
})
</script>
{#if parseError}
@@ -88,10 +137,12 @@
</Alert>
{:else if beforeFlow && afterFlow}
<div class="h-full flex flex-col" bind:clientWidth={viewerWidth}>
<!-- Header with view toggle -->
<div class="flex flex-row items-center justify-end m-2 gap-4">
<div>
<ToggleButtonGroup bind:selected={viewMode}>
{#if showLocalToggle}
<!-- Legacy top banner — only holds the local Unified / Side-by-side
toggle when the parent doesn't pre-set inlineDiff. Zoom
controls live as an overlay below (same as the controlled path). -->
<div class="flex flex-row items-center justify-end m-2">
<ToggleButtonGroup bind:selected={localViewMode} noWFull>
{#snippet children({ item })}
<ToggleButton {item} value="unified" label="Unified" icon={DiffIcon} />
<ToggleButton
@@ -103,102 +154,131 @@
{/snippet}
</ToggleButtonGroup>
</div>
<!-- Header with controls and view toggle -->
{/if}
<!-- Main content area -->
<div class="flex-1 overflow-hidden relative" bind:clientHeight={contentAreaHeight}>
{#if isSideBySide}
<!-- Shared controls for both graphs in side-by-side mode -->
<div class="flex">
<Button
size="xs"
color="light"
variant="border"
onClick={() => {
<!-- Shared zoom controls overlay the graph viewport. Uses xy-flow's
own `.svelte-flow__controls` / `.svelte-flow__controls-button`
classes so it inherits the same look as FlowGraphV2's built-in
controls (FlowGraphV2's global override gives the buttons
bg-surface + hover bg-surface-hover with border:0). Local
overrides bump the icon size from 12px to 16px and drop the
xy-flow default shadow. No fit-view button — recenter can't
sync across two graphs. -->
<div
class="svelte-flow__controls horizontal absolute top-[15px] right-[15px] z-10 rounded bg-surface border border-gray-200 dark:border-gray-700 overflow-hidden diff-zoom-controls"
>
<button
type="button"
aria-label="Zoom in"
class="svelte-flow__controls-button"
onclick={() => {
beforeGraph?.zoomIn()
afterGraph?.zoomIn()
}}
iconOnly
startIcon={{ icon: Plus }}
/>
<Button
size="xs"
color="light"
variant="border"
onClick={() => {
>
<Plus />
</button>
<button
type="button"
aria-label="Zoom out"
class="svelte-flow__controls-button"
onclick={() => {
beforeGraph?.zoomOut()
afterGraph?.zoomOut()
}}
iconOnly
startIcon={{ icon: Minus }}
/>
>
<Minus />
</button>
</div>
{/if}
</div>
<!-- Main content area -->
<div class="flex-1 overflow-hidden">
{#if isSideBySide}
<!-- Side-by-side view for wide screens -->
<Splitpanes class="!overflow-visible h-full">
<!-- Before (Left) -->
<Pane bind:size={beforePaneSize} minSize={30}>
<div class="flex flex-col h-full border-r border-gray-200 dark:border-gray-700">
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={beforeGraph}
modules={beforeFlow.value.modules}
groups={beforeFlow.value.groups}
failureModule={beforeFlow.value.failure_module}
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
cache={beforeFlow.value.cache_ttl !== undefined}
moduleActions={beforeActions}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">Before</span>
{/snippet}
</FlowGraphV2>
</div>
<div
class="flex flex-col h-full border-r border-gray-200 dark:border-gray-700 relative bg-surface-secondary {beforeMissing
? 'hatched-thin'
: ''}"
>
{#if beforeMissing}
<span class="absolute top-2 left-2 z-10 text-2xs text-tertiary">
Before <span class="italic">(no prior version)</span>
</span>
{:else}
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={beforeGraph}
modules={beforeFlow.value.modules}
groups={beforeFlow.value.groups}
failureModule={beforeFlow.value.failure_module}
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
cache={beforeFlow.value.cache_ttl !== undefined}
moduleActions={beforeActions}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={sharedMinHeight}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
onHeight={(h) => (beforeContentHeight = h)}
>
{#snippet leftHeader()}
<span class="text-2xs text-tertiary">Before</span>
{/snippet}
</FlowGraphV2>
</div>
{/if}
</div>
</Pane>
<!-- After (Right) - Show merged flow with shadowed removed modules -->
<Pane minSize={30} class="flex flex-col h-full">
<div class="flex flex-col h-full">
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={afterGraph}
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
groups={afterFlow.value.groups}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
cache={afterFlow.value.cache_ttl !== undefined}
currentInputSchema={afterFlow.schema}
markRemovedAsShadowed={true}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">After</span>
{/snippet}
</FlowGraphV2>
</div>
<div
class="flex flex-col h-full relative bg-surface-secondary {afterMissing
? 'hatched-thin'
: ''}"
>
{#if afterMissing}
<span class="absolute top-2 left-2 z-10 text-2xs text-tertiary">
After <span class="italic">(flow deleted)</span>
</span>
{:else}
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={afterGraph}
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
groups={afterFlow.value.groups}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
cache={afterFlow.value.cache_ttl !== undefined}
currentInputSchema={afterFlow.schema}
markRemovedAsShadowed={true}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={sharedMinHeight}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
onHeight={(h) => (afterContentHeight = h)}
>
{#snippet leftHeader()}
<span class="text-2xs text-tertiary">After</span>
{/snippet}
</FlowGraphV2>
</div>
{/if}
</div>
</Pane>
</Splitpanes>
@@ -219,7 +299,7 @@
editMode={false}
download={false}
scroll={false}
minHeight={400}
minHeight={Math.max(contentAreaHeight, SHARED_MIN_HEIGHT)}
triggerNode={false}
/>
</div>
@@ -231,3 +311,31 @@
<p class="text-gray-500">Loading graphs...</p>
</div>
{/if}
<style>
/* Thin diagonal hatch used as the empty-pane fill. */
.hatched-thin {
background-image: repeating-linear-gradient(
-45deg,
transparent 0,
transparent 6px,
rgba(128, 128, 128, 0.16) 6px,
rgba(128, 128, 128, 0.16) 7.5px
);
}
/* Shared zoom controls overlay: same xy-flow layout as the in-graph
controls (`.svelte-flow__controls.horizontal`) but with bigger Plus/Minus
glyphs (xy-flow caps svg at 12px by default) and no panel shadow. */
.diff-zoom-controls {
box-shadow: none !important;
}
.diff-zoom-controls :global(.svelte-flow__controls-button) {
width: 28px;
height: 28px;
}
.diff-zoom-controls :global(.svelte-flow__controls-button svg) {
max-width: 16px;
max-height: 16px;
}
</style>
@@ -1,19 +1,12 @@
<script lang="ts">
import { Markdown, type Plugin } from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import rehypeRaw from 'rehype-raw'
import { rehypeGithubAlerts } from 'rehype-github-alerts'
import { Markdown } from 'svelte-exmarkdown'
import { markdownPlugins as plugins } from './markdownPlugins'
interface Props {
md: string
noPadding?: boolean
}
let { md, noPadding }: Props = $props()
const plugins: Plugin[] = [
gfmPlugin(),
{ rehypePlugin: [rehypeRaw] },
{ rehypePlugin: [rehypeGithubAlerts] }
]
</script>
<div class="!prose-xs {noPadding ? '' : 'pgap'}">
@@ -0,0 +1,101 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import { classes } from '$lib/components/common/alert/model'
import { type Value } from '$lib/utils'
import { AlertCircle } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { slide } from 'svelte/transition'
interface Props {
/** Whether there are unsaved local changes relative to the deployed baseline. */
show: boolean
/** Deployed/backend baseline config (the "original" side of the diff). */
getDeployed: () => unknown
/** Current form config — the local autosave (the "current" side of the diff). */
getCurrent: () => unknown
/** Drop the local changes and reset the form to the deployed baseline. */
onDiscard: () => void | Promise<void>
/** When true (e.g. no write access), hide the Discard action. */
disabled?: boolean
/** Diff drawer title. */
title?: string
}
let {
show,
getDeployed,
getCurrent,
onDiscard,
disabled = false,
title = 'Deployed <> Local changes'
}: Props = $props()
let diffDrawer: DiffDrawer | undefined = $state()
function showDiff() {
const deployed = getDeployed()
if (deployed == null) return
// Snapshot both sides at click time. They are typically Svelte `$state`
// proxies (resource/variable's `initialStates[ws]` and the draft handle's
// cell); without snapshot the diff drawer would re-read them reactively
// and update as the user keeps typing behind it.
const original = $state.snapshot(deployed) as Value
const current = $state.snapshot(getCurrent()) as Value
diffDrawer?.openDrawer()
// Mirror the inline Discard's `disabled` gate inside the diff drawer —
// otherwise a read-only user could still trigger onDiscard via the
// drawer button even though we hid the banner's inline action.
diffDrawer?.setDiff({
mode: 'simple',
original,
current,
title,
button: disabled
? undefined
: {
text: 'Discard changes',
onClick: async () => {
await onDiscard()
diffDrawer?.closeDrawer()
}
}
})
}
</script>
<DiffDrawer bind:this={diffDrawer} />
{#if show}
<div
transition:slide|local={{ duration: 120 }}
class={twMerge(
'flex flex-row items-center justify-between gap-2 px-4 py-1',
classes.warning.bgClass,
'!border-0 !rounded-none'
)}
>
<div class="flex flex-row items-center gap-2 min-w-0">
<AlertCircle class={classes.warning.iconClass} size={16} />
<span class={twMerge('text-xs font-semibold truncate', classes.warning.titleClass)}>
You have unsaved changes
</span>
</div>
<div class="flex flex-row items-center gap-2 shrink-0">
<Button
unifiedSize="sm"
variant="subtle"
btnClasses={classes.warning.titleClass}
on:click={showDiff}>Show diff</Button
>
{#if !disabled}
<Button
unifiedSize="sm"
variant="subtle"
btnClasses={classes.warning.titleClass}
on:click={onDiscard}>Discard</Button
>
{/if}
</div>
</div>
{/if}
@@ -14,7 +14,6 @@
import { getUserExt } from '$lib/user'
import type { UserExt } from '$lib/stores'
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
interface Props {
@@ -26,6 +25,14 @@
defaultValues?: Record<string, any> | undefined
workspace?: string | undefined
selected?: string | undefined
/** Notifies the parent drawer whether a local draft for the selected
* workspace diverges from the deployed baseline, so it can show the
* "unsaved changes" banner below its header. */
onDraftStateChange?: (hasDraft: boolean) => void
/** Notifies the parent drawer of write-access for the selected workspace,
* 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
}
let {
@@ -36,7 +43,9 @@
onChange,
defaultValues = undefined,
workspace = undefined,
selected: selectedProp = $bindable()
selected: selectedProp = $bindable(),
onDraftStateChange,
onCanWriteChange
}: Props = $props()
type ResourceState = {
@@ -189,6 +198,11 @@
Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws]))
)
const anyDirty = $derived(dirtyWorkspaces.length > 0)
// Banner is scoped to the selected workspace — the diff/discard only
// operate on it, so showing it for an unrelated dirty workspace would be
// misleading. The cross-workspace `otherDirty` alert below still covers
// that case.
const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected))
const otherDirty = $derived(
dirtyWorkspaces.length == 1
? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore)
@@ -277,11 +291,6 @@
{ workspace: ws }
)
}
notifyRestoredFromLocal(false, true, {
onResetToDeployed: () => {
UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws })
}
})
}
}
ensureHandle(ws, s)
@@ -330,6 +339,29 @@
canSave = anyDirty && dirtyValid && dirtyCanWrite
})
// Drive the parent drawer's "unsaved changes" banner. The drawer chrome
// (header + banner slot) lives in ResourceEditorDrawer, above this
// lazily-imported content, so the state is lifted up via these accessors.
$effect(() => {
onDraftStateChange?.(!!initialPath && selectedDirty)
})
$effect(() => {
onCanWriteChange?.(can_write)
})
export function localDraftDeployed(): ResourceState | undefined {
return selected ? initialStates[selected] : undefined
}
export function localDraftCurrent(): ResourceState | undefined {
return current
}
export function discardLocalDraft(): void {
if (!selected) return
UserDraft.discard('resource', initialPath ?? '', initialStates[selected], {
workspace: selected
})
}
$effect(() => {
if (current)
// $state.snapshot deep-reads (so the effect re-runs on nested
@@ -6,6 +6,7 @@
import { Loader2, Save } from 'lucide-svelte'
import WsSpecificVersions from './WsSpecificVersions.svelte'
import { workspaceStore } from '$lib/stores'
import LocalDraftBanner from './LocalDraftBanner.svelte'
let {
workspace = undefined,
@@ -17,7 +18,16 @@
let resource_type: string | undefined = $state(undefined)
let defaultValues: Record<string, any> | undefined = $state(undefined)
let resourceEditor: { save: () => void } | undefined = $state(undefined)
let resourceEditor:
| {
save: () => void
localDraftDeployed: () => unknown
localDraftCurrent: () => unknown
discardLocalDraft: () => void
}
| undefined = $state(undefined)
let hasLocalDraft = $state(false)
let canWriteSelected = $state(true)
let path: string | undefined = $state(undefined)
let selected: string | undefined = $state(undefined)
@@ -62,8 +72,19 @@
bind:this={resourceEditor}
bind:canSave
bind:selected
onDraftStateChange={(v) => (hasLocalDraft = v)}
onCanWriteChange={(v) => (canWriteSelected = v)}
/>
{/await}
{#snippet banner()}
<LocalDraftBanner
show={hasLocalDraft}
getDeployed={() => resourceEditor?.localDraftDeployed()}
getCurrent={() => resourceEditor?.localDraftCurrent()}
onDiscard={() => resourceEditor?.discardLocalDraft()}
disabled={!canWriteSelected}
/>
{/snippet}
{#snippet actions()}
{#if mode == 'edit' && path && effectiveWorkspace}
<WsSpecificVersions
@@ -78,7 +78,7 @@
import { writable } from 'svelte/store'
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
import DefaultScripts from './DefaultScripts.svelte'
import { onMount, setContext, untrack } from 'svelte'
import { getContext, onMount, setContext, untrack } from 'svelte'
import EditorHeader from './EditorHeader.svelte'
import LabelsInput from './LabelsInput.svelte'
@@ -122,7 +122,9 @@
onDeployError,
onSeeDetails,
onNavigate,
disableAi
disableAi,
initialTestPanelCollapsed = false,
initialPathChosen = false
}: ScriptBuilderProps = $props()
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
@@ -598,17 +600,23 @@
if (!disableHistoryChange) {
history.replaceState(history.state, '', `/scripts/edit/${script.path}`)
}
if (
// "Stay" deploys (explicit "Deploy & Stay here" or lib scripts) keep the
// editor in place rather than navigating to the deployed item.
const stayHere =
stay ||
(script.auto_kind === 'lib' &&
script.kind !== 'preprocessor' &&
!isWorkflowAsCode(script.content, script.language))
) {
if (stayHere) {
// Re-pin parent_hash so the next deploy's conflict check is against
// the version we just wrote.
script.parent_hash = newHash
sendUserToast('Deployed')
} else {
onDeploy?.({ path: script.path, hash: newHash })
}
// Always notify on a successful deploy; the consumer decides whether to
// navigate (route) or stay + sync the preview (session). Previously the
// stay/lib branch skipped onDeploy, so session previews didn't sync after
// a "Deploy & Stay here" or lib-script deploy.
onDeploy?.({ path: script.path, hash: newHash, stay: stayHere })
} catch (error) {
onDeployError?.({ path: script.path, error })
sendUserToast(`Error while saving the script: ${error.body || error.message}`, true)
@@ -619,6 +627,12 @@
// No-op: persistence happens via the page-level UserDraft autosave.
function saveDraft(): void {}
// Inside an AI session pane (which injects an aiChatManager via context) the
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
// fork, Exit & See details, Export — don't make sense: the session always
// stays put and is already scoped to a fork. Only "Show diff" is kept.
const inSessionPane = !!getContext('aiChatManager')
function computeDropdownItems(
initialPath: string,
savedScript: Script | NewScript | undefined,
@@ -627,26 +641,30 @@
let dropdownItems: { label: string; onClick: () => void }[] =
initialPath != '' && customUi?.topBar?.extraDeployOptions != false
? [
{
label: 'Deploy & Stay here',
onClick: () => {
handleEditScript(true)
}
},
{
label: 'Fork',
onClick: () => {
window.open(`/scripts/add?template=${initialPath}`)
}
},
...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking')
...(!inSessionPane
? [
{
label: 'Edit in workspace fork',
label: 'Deploy & Stay here',
onClick: () => {
window.open(buildForkEditUrl('script', initialPath))
handleEditScript(true)
}
}
},
{
label: 'Fork',
onClick: () => {
window.open(`/scripts/add?template=${initialPath}`)
}
},
...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking')
? [
{
label: 'Edit in workspace fork',
onClick: () => {
window.open(buildForkEditUrl('script', initialPath))
}
}
]
: [])
]
: []),
...(customUi?.topBar?.diff !== false && savedScript && diffDrawer
@@ -678,7 +696,10 @@
}
]
: []),
...(!script.draft_only && script.kind === 'script' && !script.auto_kind
...(!inSessionPane &&
!script.draft_only &&
script.kind === 'script' &&
!script.auto_kind
? [
{
label: 'Exit & See details',
@@ -688,7 +709,7 @@
}
]
: []),
...(isWorkflowAsCode(script.content, script.language)
...(!inSessionPane && isWorkflowAsCode(script.content, script.language)
? [
{
label: 'Export as YAML/JSON',
@@ -701,7 +722,11 @@
]
: []
if (dropdownItems.length === 0 && isWorkflowAsCode(script.content, script.language)) {
if (
!inSessionPane &&
dropdownItems.length === 0 &&
isWorkflowAsCode(script.content, script.language)
) {
dropdownItems = [
{
label: 'Export as YAML/JSON',
@@ -727,7 +752,11 @@
}
let path: Path | undefined = $state(undefined)
let dirtyPath = $state(false)
// Seed "path is already chosen" so the summary→path auto-slug (which only
// runs for new scripts with initialPath == '') doesn't clobber a path the
// caller pre-assigned. The session preview opens AI-created scripts as new
// (empty initialPath) but with a path the AI already picked.
let dirtyPath = $state(initialPathChosen)
let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = $state(
(() => {
@@ -1896,6 +1925,7 @@
bind:assets={script.assets}
bind:modules={script.modules}
enablePreprocessorSnippet
{initialTestPanelCollapsed}
/>
</div>
{:else}
@@ -160,6 +160,11 @@
modules?: { [key: string]: ScriptModule } | null
editorBarRight?: import('svelte').Snippet
enablePreprocessorSnippet?: boolean
// When true the right-hand test/run pane mounts collapsed. The user
// can still expand it via `toggleTestPanel`. Defaults to false so the
// regular /scripts/edit route keeps its current open-by-default UX;
// the session preview opts in to save vertical real estate.
initialTestPanelCollapsed?: boolean
}
let {
@@ -193,7 +198,8 @@
assets = $bindable(),
modules = $bindable(undefined),
editorBarRight,
enablePreprocessorSnippet = false
enablePreprocessorSnippet = false,
initialTestPanelCollapsed = false
}: Props = $props()
let initialArgs = structuredClone($state.snapshot(args))
@@ -1360,8 +1366,11 @@
// dynamic minimum below — so when the editor shrinks, the displayed test
// pane grows to honor the new minimum without needing an effect. The code
// pane's size is purely derived from it (100 - test).
let rawTestPanelSize = $state(30)
let storedTestPanelSize = untrack(() => rawTestPanelSize)
// `initialTestPanelCollapsed` seeds the raw value at 0 (collapsed) while
// keeping the "remembered" size at 30, so the user's first toggle expands
// the pane to a sensible width rather than 0.
let rawTestPanelSize = $state(untrack(() => (initialTestPanelCollapsed ? 0 : 30)))
let storedTestPanelSize = 30
const testPanelSize = $derived(
rawTestPanelSize === 0 ? 0 : Math.max(rawTestPanelSize, testPaneMinPercent)
)
@@ -17,8 +17,8 @@
import { getUserExt } from '$lib/user'
import type { UserExt } from '$lib/stores'
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
import LocalDraftBanner from './LocalDraftBanner.svelte'
const dispatch = createEventDispatcher()
@@ -128,6 +128,11 @@
Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws]))
)
const anyDirty = $derived(dirtyWorkspaces.length > 0)
// Banner is scoped to the selected workspace — the diff/discard only
// operate on it, so showing it for an unrelated dirty workspace would be
// misleading. The cross-workspace `otherDirty` alert below still covers
// that case.
const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected))
const otherDirty = $derived(
dirtyWorkspaces.length == 1
? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore)
@@ -191,11 +196,6 @@
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws })
}
notifyRestoredFromLocal(false, true, {
onResetToDeployed: () => {
UserDraft.discard('variable', p, s, { workspace: ws })
}
})
}
}
ensureHandle(ws, s)
@@ -335,6 +335,20 @@
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
on:close={drawer?.closeDrawer}
>
{#snippet banner()}
<LocalDraftBanner
show={edit && selectedDirty}
getDeployed={() => (selected ? initialStates[selected] : undefined)}
getCurrent={() => current}
onDiscard={() => {
if (!selected) return
UserDraft.discard('variable', editPath ?? '', initialStates[selected], {
workspace: selected
})
}}
disabled={!can_write}
/>
{/snippet}
<div class="flex flex-col gap-8">
{#if !can_write}
<Alert type="warning" title="Only read access">
@@ -75,7 +75,7 @@
disabled={edit && $userStore?.operator}
/>
{#if variable.is_secret}
<Alert type="warning" title="Audit log for each access">
<Alert type="info" title="Audit log for each access">
Every secret is encrypted at rest and in transit with a key specific to this workspace. In
addition, any read of a secret variable generates an audit log whose operation name is:
variables.decrypt_secret
@@ -0,0 +1,162 @@
<!--
@component
Inline diff renderer for a single workspace item. Mirrors the per-kind
rendering that DiffDrawer does in its body (`DiffDrawer.svelte:181-271`):
- `flow` → `<FlowDiffViewer>` (its own Graph / YAML toggle inside)
- has `content` (scripts) → Tabs(Content | Metadata) with two Monaco diffs
- everything else (apps, resources, variables, schedules, triggers…) →
a single Monaco YAML diff over the metadata
`inlineDiff` flips Monaco's `renderSideBySide` to false (unified view).
The component is content-sized — each Monaco block is sized to fit its
diff text (no internal scroll) using `lines * 19 + 24`; for the
Content+Metadata case we use the max of the two so switching tabs
doesn't reflow the parent.
-->
<script lang="ts">
import Tabs from './common/tabs/Tabs.svelte'
import Tab from './common/tabs/Tab.svelte'
import FlowDiffViewer from './FlowDiffViewer.svelte'
import { Loader2 } from 'lucide-svelte'
import { cleanValueProperties, orderedYamlStringify, replaceFalseWithUndefined } from '$lib/utils'
import { scriptLangToEditorLang } from '$lib/scripts'
interface Props {
/** Any WorkspaceItemDiff['kind'] — used only to special-case `flow`. */
kind: string
/** Raw value from `getItemValue(kind, path, parentWorkspace)`. Undefined
* for "added" items (don't exist in the parent). */
originalRaw?: unknown
/** Raw value from `getItemValue(kind, path, forkWorkspace)`. Undefined
* for "removed" items (don't exist in the fork). */
currentRaw?: unknown
/** Force unified diff (Monaco renderSideBySide=false). Default false. */
inlineDiff?: boolean
}
let { kind, originalRaw, currentRaw, inlineDiff = false }: Props = $props()
type Prepared = { lang?: string; content?: string; metadata: string }
function prepareValue(raw: unknown): Prepared {
if (!raw || typeof raw !== 'object') {
return { metadata: raw == null ? '' : String(raw) }
}
const cleaned = structuredClone(
cleanValueProperties(replaceFalseWithUndefined(raw as Record<string, unknown>))
)
const content = (cleaned as Record<string, unknown>)['content']
if (content !== undefined) {
delete (cleaned as Record<string, unknown>)['content']
}
const language = (raw as Record<string, unknown>).language
return {
lang:
typeof language === 'string'
? scriptLangToEditorLang(language as Parameters<typeof scriptLangToEditorLang>[0])
: undefined,
content: typeof content === 'string' ? content : undefined,
metadata: orderedYamlStringify(cleaned)
}
}
const original = $derived(prepareValue(originalRaw))
const current = $derived(prepareValue(currentRaw))
const hasContent = $derived(original.content !== undefined || current.content !== undefined)
// For added / removed flows, the missing side feeds an empty YAML so
// the YAML-mode editor shows the whole new (or removed) flow as a
// single-sided diff. FlowGraphDiffViewer uses the *Missing flag to
// swap in its own OpenFlow stub for parsing and to draw a placeholder
// pane in side-by-side mode.
const beforeFlowYaml = $derived(originalRaw == null ? '' : original.metadata)
const afterFlowYaml = $derived(currentRaw == null ? '' : current.metadata)
let contentTab: 'content' | 'metadata' = $state('content')
// Per-tab height: each Monaco block sizes to its own content. Switching
// tabs reflows the row, which is the expected tab behavior; we don't
// over-allocate to the larger tab the way the previous max() did.
const LINE_HEIGHT = 19
const EDITOR_CHROME = 24
function linesIn(s?: string): number {
return Math.max((s ?? '').split('\n').length, 1)
}
const contentHeight = $derived(
`${Math.max(linesIn(original.content), linesIn(current.content)) * LINE_HEIGHT + EDITOR_CHROME}px`
)
const metadataHeight = $derived(
`${Math.max(linesIn(original.metadata), linesIn(current.metadata)) * LINE_HEIGHT + EDITOR_CHROME}px`
)
const activeTabHeight = $derived(contentTab === 'content' ? contentHeight : metadataHeight)
</script>
{#if kind === 'flow'}
<div class="h-[600px]">
<FlowDiffViewer
beforeYaml={beforeFlowYaml}
afterYaml={afterFlowYaml}
beforeMissing={originalRaw == null}
afterMissing={currentRaw == null}
{inlineDiff}
/>
</div>
{:else if hasContent}
<div class="flex flex-col">
<Tabs bind:selected={contentTab}>
<Tab value="content" label="Content" />
<Tab value="metadata" label="Metadata" />
</Tabs>
<div style="height: {activeTabHeight}">
{#if contentTab === 'content'}
{#await import('$lib/components/DiffEditor.svelte')}
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang={original.lang ?? current.lang}
defaultOriginal={original.content ?? ''}
defaultModified={current.content ?? ''}
{inlineDiff}
readOnly
/>
{/await}
{:else}
{#await import('$lib/components/DiffEditor.svelte')}
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang="yaml"
defaultOriginal={original.metadata}
defaultModified={current.metadata}
{inlineDiff}
readOnly
/>
{/await}
{/if}
</div>
</div>
{:else}
{#await import('$lib/components/DiffEditor.svelte')}
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
{:then Module}
<div style="height: {metadataHeight}">
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang="yaml"
defaultOriginal={original.metadata}
defaultModified={current.metadata}
{inlineDiff}
readOnly
/>
</div>
{/await}
{/if}
@@ -17,6 +17,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
import { ChevronLeft, ChevronRight, Folder, Layers, Loader2, User } from 'lucide-svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import { onMount, untrack } from 'svelte'
import {
@@ -30,6 +31,8 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
type WorkspaceItem,
type WorkspaceItemKind
} from './workspacePicker'
import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
type Kind = WorkspaceItemKind
type Item = WorkspaceItem
@@ -72,8 +75,16 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
// Sibling-popover open: melt-ui's `openFocus` runs once during the close→open
// transition; the picker may not be mounted yet. Retry after settle.
// Also kicks off the initial scope's fetch — drill/goUp do the same from
// their respective branches, so `ensureLoaded` is always a callback
// reaction to user navigation, never a reactive consequence.
onMount(() => {
const t = setTimeout(focus, 50)
const initial = untrack(() => scope)
if (initial) {
if (initial.kind === 'all') for (const k of kinds) ensureLoaded(k)
else ensureLoaded(initial.kind)
}
return () => clearTimeout(t)
})
@@ -82,6 +93,22 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
let scope = $state<Scope>(untrack(() => initialScope))
let filter = $state('')
/**
* Canonical entry point for changing the picker's scope. Triggers the
* fetch for the kind(s) the new scope needs at the same point in time.
* Replaces the older "react to `scope` change via `$effect`" wiring,
* which had a subtle bug: `ensureLoaded` reads `loaded[kind]`, so the
* effect ended up subscribed to the signal it fills — every fetch
* result re-fired it. With explicit callbacks the fetch is tied to
* the user's action, never to a reactive consequence of that action.
*/
function setScope(next: Scope) {
scope = next
if (!next) return
if (next.kind === 'all') for (const k of kinds) ensureLoaded(k)
else ensureLoaded(next.kind)
}
/** Tracks whether the last user action was mouse movement (true) or
* keyboard nav (false). When false, row `mouseenter` events are ignored
* — prevents the cursor from stealing the keyboard-driven highlight as
@@ -90,10 +117,11 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* mounts under a stationary cursor doesn't clobber `initialHighlight`. */
let mouseActive = $state(false)
// Seed from cache so kinds already fetched in this session render on the
// first frame. Read once at mount: melt-ui mounts a fresh picker per
// popover open, so workspace changes are picked up at the next open
// without needing this seed to be reactive.
// Seed from the last fetched snapshot so kinds already fetched in this
// session render on the first frame. Each entry is replaced once
// `loadKind` returns fresh data — stale-while-revalidate, so deploys and
// AI-created drafts surface on the next open without explicit cache
// busting.
let loaded = $state<Partial<Record<Kind, Item[]>>>(
(() => {
if (!$workspaceStore) return {}
@@ -109,8 +137,15 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
async function ensureLoaded(kind: Kind) {
if (!$workspaceStore) return
if (loaded[kind]) return
loadingKind[kind] = true
// Always re-fetch. If we have nothing cached, show a spinner; if we do,
// keep displaying it and quietly swap to fresh data when it lands.
// `loaded[kind]` is read inside `untrack(...)` because this function is
// reachable from the search `$effect` below — without the untrack,
// that effect would subscribe to the signal `ensureLoaded` fills, and
// each `loaded[kind] = items` (proxy `set` notifies even when the ref
// is unchanged from cache) would refire it → runaway loop. Drill
// navigation goes through `setScope` directly so it isn't affected.
if (!untrack(() => loaded[kind])) loadingKind[kind] = true
try {
const items = await loadKind($workspaceStore, kind)
loaded[kind] = items
@@ -119,13 +154,31 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
}
}
// Fetch the scope's kind on entry to a non-root level. The `'all'` scope
// needs every kind loaded since it merges items across them.
$effect(() => {
if (!scope) return
if (scope.kind === 'all') for (const k of kinds) ensureLoaded(k)
else ensureLoaded(scope.kind)
})
// Chat tools and session editor previews write drafts through
// `UserDraft` (workspace-scoped, localStorage-backed). Merge those into
// the picker so users can navigate to in-flight items that haven't been
// deployed yet. Filter to kinds the picker actually displays.
//
// Gated on the same dev flag as the rest of the sessions feature: without
// it there are no sessions, so the only UserDrafts present are the
// standalone editors' autosaves — surfacing those in the breadcrumb picker
// would be surprising (they'd appear as navigable items that 404 on the
// backend draft fetch). When the flag is off this is a no-op.
const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const
function aiDraftsForKind(k: Kind): Item[] {
if (!isGlobalAiEnabled()) return []
if (!$workspaceStore) return []
const targetType = KIND_TO_DRAFT_TYPE[k]
return listGlobalDrafts($workspaceStore)
.filter((d) => d.type === targetType)
.map((d) => ({
path: d.path,
summary: d.summary ?? '',
kind: k,
// `raw_app` lives on the draft envelope for legacy/raw-app distinction.
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined
}))
}
// Searching is global → load every kind.
$effect(() => {
@@ -140,6 +193,17 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
leaves: Item[]
}
/** Merge AI-created in-memory drafts into a kind's list. The AI may have
* scaffolded a script/flow/app via chat tools without the user saving
* yet — those drafts should be navigable from the picker. Existing items
* (same path) win to keep the backend's metadata (summary etc.). */
function withAiDrafts(items: Item[], k: Kind): Item[] {
const ai = aiDraftsForKind(k)
if (ai.length === 0) return items
const known = new Set(items.map((it) => it.path))
return items.concat(ai.filter((d) => !known.has(d.path)))
}
/** Inject the currently-edited item into a kind's list at its live path,
* dropping the saved entry when a draft rename is in progress. Other kinds
* pass through untouched. */
@@ -207,7 +271,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* cached. */
function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] {
if (!kinds.includes(k)) return []
const items = withCurrent(list ?? [], k)
const items = withAiDrafts(withCurrent(list ?? [], k), k)
if (items.length === 0) return []
return buildTreeFromItems(items)
}
@@ -219,7 +283,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* one folder hierarchy. Each leaf still carries its real kind, so the row
* icon and `editPathFor` routing still work; folders contain a mix. */
const allTree = $derived.by(() => {
const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k))
const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k))
return merged.length === 0 ? [] : buildTreeFromItems(merged)
})
@@ -255,7 +319,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
let allItems = $derived<SearchInput[]>(
kinds.flatMap((k) =>
withCurrent(loaded[k] ?? [], k).map((it) => ({ ...it, _key: `${k}:${it.path}` }))
withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({
...it,
_key: `${k}:${it.path}`
}))
)
)
@@ -383,9 +450,9 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
function drill(entry: Entry) {
if (entry.type === 'kind') {
scope = { kind: entry.kind }
setScope({ kind: entry.kind })
} else if (entry.type === 'dir') {
scope = { kind: entry.kind, dir: entry.node.fullPath }
setScope({ kind: entry.kind, dir: entry.node.fullPath })
} else {
pick(entry.item)
}
@@ -397,13 +464,13 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
// just left, so the user sees where they came from.
if (!scope.dir) {
const leaving = kindKey(scope.kind)
scope = undefined
setScope(undefined)
highlightedKey = leaving
return
}
const leaving = dirKey(scope.kind, scope.dir)
const parent = parentDirPath(scope.dir)
scope = parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind }
setScope(parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind })
highlightedKey = leaving
}
@@ -528,32 +595,18 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
{#snippet leafRow(it: Item, secondary: string, baseClass: string)}
{@const key = leafKey(it)}
{@const isHl = key === highlightedKey}
{@const isCur = isCurrent(it)}
<button
type="button"
<WorkspaceItemRow
kind={it.kind}
summary={it.summary}
{secondary}
highlighted={key === highlightedKey}
current={isCurrent(it)}
id={idFor(key)}
role="option"
aria-selected={isHl}
data-nav-key={key}
aria-current={isCur ? 'true' : undefined}
class="w-full text-left flex items-center gap-2 px-3 transition-colors {baseClass} {isHl
? 'bg-surface-hover'
: ''} {isCur ? 'cursor-default text-emphasis font-medium' : ''}"
onmousedown={(e) => e.preventDefault()}
navKey={key}
{baseClass}
onclick={() => pick(it)}
onmouseenter={() => setHoverHighlight(key)}
>
<RowIcon kind={it.kind} size={12} />
<div class="min-w-0 flex-1">
{#if it.summary}
<div class="text-xs text-primary truncate">{it.summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
{/if}
</div>
</button>
/>
{/snippet}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -0,0 +1,148 @@
<!--
@component
Visual row for a workspace item (script / flow / app / resource /
schedule / trigger / …). Matches the leaf-row layout used by
WorkspaceItemDrillPicker: RowIcon + summary line on top with mono path
beneath, or just the mono path when there's no summary.
Pure presentation — the caller controls highlighting / current state via
props, supplies the onclick/onmouseenter handlers, and can pass an
`extras` snippet for right-side adornments (status dots, badges, …).
The button uses `onmousedown={(e) => e.preventDefault()}` so the click
doesn't steal focus from a sibling search input (matches the picker).
-->
<script module lang="ts">
import type { ComponentProps } from 'svelte'
import RowIconType from '$lib/components/common/table/RowIcon.svelte'
export type WorkspaceItemRowKind = ComponentProps<typeof RowIconType>['kind']
</script>
<script lang="ts">
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import type { Snippet } from 'svelte'
interface Props {
kind: WorkspaceItemRowKind
/** For `kind: 'trigger'`, specifies the concrete trigger subtype.
* Forwarded to RowIcon. */
triggerKind?: string
/** Optional summary text shown above the path. */
summary?: string
/** Mono path (or any secondary identifier). When summary is empty
* this is the only visible text. */
secondary: string
/** Highlighted via keyboard nav. Used for `aria-selected` +
* surface-hover background. */
highlighted?: boolean
/** "Currently editing this" — the picker uses this to grey out the
* active row and disable its click. */
current?: boolean
/** DOM id, used for `aria-activedescendant`. */
id?: string
/** Stamped on the element as `data-nav-key` so the parent can
* `pickerRoot.querySelector(...)` to scroll into view. */
navKey?: string
/** Per-row vertical padding class (e.g. `py-1` / `py-1.5`). */
baseClass?: string
/** Extra left padding (px) for tree-view indentation. Adds to the
* default `px-3` horizontal padding. */
indent?: number
/** Title tooltip shown on hover; defaults to the secondary text. */
title?: string
/** When set, the row renders as an `<a href target="_blank">` link
* instead of a `<button>`. Used by callers that want native
* new-tab / cmd-click behaviour. `onclick` still forwards. */
href?: string
onclick?: () => void
onmouseenter?: () => void
/** Right-side adornments (status dot, badges, …). The `group` class
* is always applied to the root so the snippet can use
* `group-hover:*` utilities to reveal hover-only affordances. */
extras?: Snippet
}
let {
kind,
triggerKind,
summary,
secondary,
highlighted = false,
current = false,
id,
navKey,
baseClass = 'py-1.5',
indent = 0,
title,
href,
onclick,
onmouseenter,
extras
}: Props = $props()
const rootClass = $derived(
`group w-full text-left flex items-center gap-2 px-3 transition-colors ${baseClass} ${highlighted ? 'bg-surface-hover' : ''} ${current ? 'cursor-default text-emphasis font-medium' : ''}`
)
</script>
{#if href}
<a
{href}
target="_blank"
rel="noopener noreferrer"
{id}
role="option"
aria-selected={highlighted}
aria-current={current ? 'true' : undefined}
data-nav-key={navKey}
title={title ?? secondary}
style={indent ? `padding-left: calc(0.75rem + ${indent}px)` : undefined}
class={rootClass}
{onclick}
{onmouseenter}
>
<RowIcon {kind} {triggerKind} size={12} />
<div class="min-w-0 flex-1">
{#if summary}
<div class="text-xs text-primary truncate">{summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
{/if}
</div>
{#if extras}
<div class="shrink-0 flex items-center gap-2">
{@render extras()}
</div>
{/if}
</a>
{:else}
<button
type="button"
{id}
role="option"
aria-selected={highlighted}
aria-current={current ? 'true' : undefined}
data-nav-key={navKey}
title={title ?? secondary}
style={indent ? `padding-left: calc(0.75rem + ${indent}px)` : undefined}
class={rootClass}
onmousedown={(e) => e.preventDefault()}
{onclick}
{onmouseenter}
>
<RowIcon {kind} {triggerKind} size={12} />
<div class="min-w-0 flex-1">
{#if summary}
<div class="text-xs text-primary truncate">{summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
{/if}
</div>
{#if extras}
<div class="shrink-0 flex items-center gap-2">
{@render extras()}
</div>
{/if}
</button>
{/if}
@@ -5,10 +5,8 @@
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
import { initCss } from '../../utils'
import RunnableWrapper from '../helpers/RunnableWrapper.svelte'
import { Markdown, type Plugin } from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import rehypeRaw from 'rehype-raw'
import { rehypeGithubAlerts } from 'rehype-github-alerts'
import { Markdown } from 'svelte-exmarkdown'
import { markdownPlugins as plugins } from '$lib/components/markdownPlugins'
import { classNames } from '$lib/utils'
import { components } from '../../editor/component'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
@@ -31,11 +29,6 @@
configuration
}: Props = $props()
const plugins: Plugin[] = [
gfmPlugin(),
{ rehypePlugin: [rehypeRaw] },
{ rehypePlugin: [rehypeGithubAlerts] }
]
const { app, worldStore, mode } = getContext<AppViewerContext>('AppViewerContext')
const resolvedConfig = $state(
@@ -3,7 +3,7 @@
const bubble = createBubbler()
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import { onMount, setContext, untrack } from 'svelte'
import { getContext, onMount, setContext, untrack } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { Pane, Splitpanes } from 'svelte-splitpanes'
@@ -78,20 +78,29 @@
replaceStateFn = (path: string) => window.history.replaceState(null, '', path),
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
onSavedNewAppPath,
onNavigate,
initialRevs
}: AppEditorProps = $props()
migrateApp(untrack(() => app))
// Inside a session pane the AIChatManager is injected via context. Sessions
// have their own state machinery (sessionRuntime + per-fork backend), and
// the user-facing $workspaceStore stays on the main workspace even when
// the session is editing in a fork — so a UserDraft handle here would
// share its LS key with the regular /apps/edit route and clobber both
// sides' autosaves. Skip UserDraft entirely in that case.
const inSessionPane = !!getContext('aiChatManager')
const appDraftPath = newApp ? '' : (path ?? '')
const appDraftHandle = UserDraft.use<App>('app', appDraftPath)
const appDraftHandle = inSessionPane ? undefined : UserDraft.use<App>('app', appDraftPath)
// Prefer the persisted autosave over the prop when both exist (e.g.
// /apps/add reload: the route always initializes `app` to an empty
// template, but the user's last session is sitting in LS under the
// empty-path entry). The route is responsible for wiping the entry
// (`UserDraft.remove`) when it wants to force a fresh start —
// `?nodraft=true`, template/hub loads, etc.
const stateApp = $state(untrack(() => appDraftHandle.draft ?? app))
const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app))
const appStore = writable<App>(stateApp)
// Captured once on mount: the load-time revs are only used as the
// seed meta on the very first persist of this entry. After that the
@@ -111,6 +120,7 @@
let firstMirror = true
$effect(() => {
readFieldsRecursively(stateApp)
if (!appDraftHandle) return
untrack(() => {
// Resolve the meta to attach BEFORE the wipe — the wipe clears
// in-memory meta and would otherwise force-seed `initialRevs`
@@ -164,7 +174,7 @@
groups: $userStore?.groups,
username: $userStore?.username,
name: $userStore?.name,
query: urlParamsToObject(new URL(window.location.href).searchParams),
query: urlParamsToObject(new URL(window.location.href).searchParams, { stripReserved: true }),
hash: window.location.hash.substring(1),
workspace: $workspaceStore,
mode: 'editor',
@@ -881,6 +891,7 @@
rightPanelHidden={rightPanelSize === 0}
bottomPanelHidden={runnablePanelSize === 0}
{onSavedNewAppPath}
{onNavigate}
onShowLeftPanel={() => showLeftPanel()}
onShowRightPanel={() => showRightPanel()}
onShowBottomPanel={() => showBottomPanel()}
@@ -59,7 +59,7 @@
import DebugPanel from './contextPanel/DebugPanel.svelte'
import EditorHeader from '$lib/components/EditorHeader.svelte'
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
import { editPathFor } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
import { beforeNavigate, goto } from '$app/navigation'
import HideButton from './settingsPanel/HideButton.svelte'
@@ -101,6 +101,7 @@
onHideRightPanel?: () => void
onHideLeftPanel?: () => void
onHideBottomPanel?: () => void
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
}
let {
@@ -120,7 +121,8 @@
onShowBottomPanel,
onHideLeftPanel,
onHideRightPanel,
onHideBottomPanel
onHideBottomPanel,
onNavigate = undefined
}: Props = $props()
/** Mirror of the path the user is editing in the pen popover. Initialized
@@ -160,6 +162,14 @@
const { history, jobsDrawerOpen, refreshComponents } =
getContext<AppEditorContext>('AppEditorContext')
// Sessions inject an AIChatManager via context; AppEditor skips its
// UserDraft handle in that case, so the cleanup calls here must skip too
// (otherwise we'd wipe a non-session tab's autosave at the same path). The
// session-side equivalent is the View's `onDeploy` →
// `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads
// the preview to the deployed version.
const inSessionPane = !!getContext('aiChatManager')
const loading = $state({
publish: false,
save: false
@@ -213,7 +223,7 @@
}
closeSaveDrawer()
sendUserToast('App deployed successfully')
UserDraft.remove('app', path)
if (!inSessionPane) UserDraft.remove('app', path)
onSavedNewAppPath?.(path)
} catch (e) {
sendUserToast('Error creating app', e)
@@ -297,7 +307,6 @@
preserve_on_behalf_of: preserveOnBehalfOf || undefined
}
})
invalidatePicker($workspaceStore!, 'app')
invalidateWorkspacePaths($workspaceStore!)
savedApp = {
summary: $summary,
@@ -314,7 +323,7 @@
closeSaveDrawer()
sendUserToast('App deployed successfully')
UserDraft.remove('app', $appPath)
if (!inSessionPane) UserDraft.remove('app', $appPath)
if ($appPath !== npath) {
onSavedNewAppPath?.(npath)
}
@@ -791,7 +800,7 @@
bind:path={newEditedPath}
savedPath={$appPath || newPath || undefined}
kind="app"
onNavigate={(item) => goto(editPathFor(item))}
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
/>
<div class="flex gap-2 {compactTopbar ? 'hidden' : ''}">
{#if $app}
@@ -35,7 +35,8 @@
newEditedPath = $bindable(),
newPath,
hideSecretUrl = false,
preserveOnBehalfOf = $bindable(false)
preserveOnBehalfOf = $bindable(false),
rawApp = false
}: {
policy: any
setPublishState: () => void
@@ -51,6 +52,11 @@
newPath: string
hideSecretUrl?: boolean
preserveOnBehalfOf?: boolean
// Raw apps need cross-origin isolation (wm_coep) to be embeddable. Classic
// (low-code) apps must NOT get the flag — it would force COEP on the
// document and break no-CORP cross-origin subresources (external images,
// {@html} embeds, CDN imports).
rawApp?: boolean
} = $props()
let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
@@ -91,6 +97,17 @@
isCloudHosted() || globalWorkspacedRoute ? $workspaceStore + '/' : ''
}${customPath}`
)
// When embedding a raw app in an iframe inside another Windmill app (or any
// cross-origin-isolated page), the embedded document must set COEP. The
// `wm_coep` flag opts the public app into the cross-origin isolation headers.
// Only raw apps get it — for classic (low-code) apps COEP would break
// no-CORP cross-origin subresources, so their snippet stays a plain iframe.
let embedMode = $state(false)
function toEmbedSnippet(url: string): string {
const finalUrl = rawApp ? `${url}${url.includes('?') ? '&' : '?'}wm_coep=on` : url
return `<iframe src="${finalUrl}" title="Windmill app" width="100%" height="600"></iframe>`
}
async function getSecretUrl() {
secretUrl = await AppService.getPublicSecretOfApp({
workspace: $workspaceStore!,
@@ -122,7 +139,7 @@
})
$effect(() => {
appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl())
appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl())
})
</script>
@@ -247,18 +264,40 @@
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
setPublishState()
}}
disabled={appPath == ''}
disabled={!savedApp}
/>
</div>
{#if appPath == ''}
{#if !savedApp}
<ClipboardPanel content={`Save this app once to get the public secret URL`} size="md" />
{:else if secretUrlHref}
<ClipboardPanel content={secretUrlHref} size="md" />
<div class="flex justify-end mb-1">
<Toggle
size="xs"
checked={embedMode}
on:change={(e) => (embedMode = e.detail)}
options={{ left: 'URL', right: 'Embed' }}
/>
</div>
<ClipboardPanel
content={embedMode ? toEmbedSnippet(secretUrlHref) : secretUrlHref}
size="md"
/>
{:else}<Loader2 class="animate-spin" />
{/if}
<div class="text-xs text-secondary mt-1">
Share this url directly or embed it using an iframe (if requiring login, top-level domain of
embedding app must be the same as the one of Windmill)
{#if embedMode}
Paste this iframe snippet into another app.
{#if rawApp}
The <code>wm_coep</code> flag <Tooltip
>Sets the cross-origin isolation headers (COEP) so the app can be embedded inside
another Windmill app or any cross-origin-isolated page. Without it the browser blocks
the iframe.</Tooltip
> lets it load inside a cross-origin-isolated page.
{/if}
(if requiring login, top-level domain of embedding app must be the same as the one of Windmill)
{:else}
Share this url directly, or switch to <b>Embed</b> to get an iframe snippet.
{/if}
</div>
<div class="mt-4">
@@ -305,7 +344,10 @@
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
<div>Custom public URL</div>
</div>
<ClipboardPanel content={fullCustomUrl} size="md" />
<ClipboardPanel
content={embedMode ? toEmbedSnippet(fullCustomUrl) : fullCustomUrl}
size="md"
/>
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyCustomPath ? customPathError : ''}
@@ -145,7 +145,7 @@
name: $userStore?.name,
groups: $userStore?.groups,
username: $userStore?.username,
query: urlParamsToObject(page.url.searchParams),
query: urlParamsToObject(page.url.searchParams, { stripReserved: true }),
hash: page.url.hash.substring(1)
}}
workspace={effectiveWorkspace}
@@ -161,6 +161,8 @@ export interface AppEditorProps {
replaceStateFn?: (path: string) => void
gotoFn?: (path: string, opt?: Record<string, any> | undefined) => void
onSavedNewAppPath?: (path: string) => void
/** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
/**
* Backend revs at the load that produced `app`. Used as the seed
* `UserDraft` meta on the first local autosave: until the handle has
@@ -78,6 +78,15 @@ this component just proposes new values.
})
}
// External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap
// stays active for a brief window after the menu closes — focusing our
// input during that window causes checkFocusIn to slam focus back out, which
// fires onblur=save and instantly closes the edit. A 50ms defer is enough
// for Melt's trap to release.
export function edit() {
setTimeout(startEditing, 50)
}
function save() {
// Re-entry guard: Enter calls `save()` and sets `editing = false`,
// which unmounts the `<input>` and synchronously fires its `blur`
@@ -22,6 +22,8 @@
id?: string | undefined
actions?: import('svelte').Snippet
titleExtra?: import('svelte').Snippet
/** Rendered fixed below the header, above the scrollable content. */
banner?: import('svelte').Snippet
children?: import('svelte').Snippet
}
@@ -40,6 +42,7 @@
id,
actions,
titleExtra,
banner,
children
}: Props = $props()
@@ -83,6 +86,10 @@
{/if}
</div>
{#if banner}
{@render banner()}
{/if}
<div
class={classNames(
noPadding ? '' : 'p-4',
@@ -3,30 +3,61 @@
import { untrack } from 'svelte'
import { type ScriptLang } from '$lib/gen'
import { dbSchemas, userStore, workspaceStore } from '$lib/stores'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import { AIMode } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
const aiChatManager = getAiChatManager()
import { base } from '$lib/base'
import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte'
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core'
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
let {
hideHeader = false,
hideModeSelector = false,
forceDisabled = false,
forceDisabledMessage = '',
wideLayout = false,
emptyHint,
inputPreface
}: {
hideHeader?: boolean
hideModeSelector?: boolean
// External "you can't type here" override. Used by sessions when
// the session's committed workspace was deleted/archived so the
// chat is effectively read-only until the user moves or discards
// the session. Wins over the internal disabled derivation.
forceDisabled?: boolean
forceDisabledMessage?: string
// Forwarded to AIChatDisplay. When true, the messages / input
// columns are centered in a max-w-3xl px-8 box. Sessions opt
// in; the narrow global-chat panel leaves it off.
wideLayout?: boolean
emptyHint?: import('svelte').Snippet
inputPreface?: import('svelte').Snippet
} = $props()
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
const hasCopilot = $derived($copilotInfo.enabled)
const disabled = $derived(
!hasCopilot ||
forceDisabled ||
!hasCopilot ||
(aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang))
)
const disabledMessage = $derived(
!hasCopilot
? isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
forceDisabled
? forceDisabledMessage
: !hasCopilot
? isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
)
const suggestions = [
@@ -53,6 +84,10 @@
aiChatManager.sendRequest(options)
}
export function focusInput() {
aiChatDisplay?.focusInput()
}
const historyManager = aiChatManager.historyManager
let aiChatDisplay: AIChatDisplay | undefined = $state(undefined)
@@ -129,4 +164,9 @@
{disabled}
{disabledMessage}
{suggestions}
{hideHeader}
{hideModeSelector}
{wideLayout}
{emptyHint}
{inputPreface}
></AIChatDisplay>
@@ -61,7 +61,12 @@ import { runChatLoop } from './chatLoop'
import type { ReviewChangesOpts } from './monaco-adapter'
import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore'
import type { WorkspaceMutationTarget } from './workspaceTools'
import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core'
import {
globalToolsFor,
prepareGlobalSystemMessage,
prepareGlobalUserMessage,
type GlobalToolHelpers
} from './global/core'
import { isGlobalAiEnabled } from './global/gate'
// If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message
@@ -208,13 +213,26 @@ export class AIChatManager {
private userQuestionCallbacks = new Map<string, (choice: string | undefined) => void>()
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
disabledModes: Partial<Record<AIMode, boolean>> = $state({})
// Set by AI sessions. Enables the session-only preview tools (open_preview /
// get_preview_status) and their system-prompt guidance in GLOBAL mode; the
// global side-panel chat leaves it false so those tools aren't offered.
isSessionChat = false
// The session this manager belongs to (session chats only). Carried into the
// tool `helpers` in GLOBAL mode so the preview/deploy tools dispatch to THIS
// session rather than the UI-active one — keeps backgrounded sessions isolated.
sessionId: string | undefined = undefined
allowedModes: Record<AIMode, boolean> = $derived({
script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined,
flow: this.flowAiChatHelpers !== undefined,
app: this.appAiChatHelpers !== undefined,
navigator: true,
ask: true,
API: true,
script:
this.flowAiChatHelpers === undefined &&
this.scriptEditorOptions !== undefined &&
!this.disabledModes.script,
flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow,
app: this.appAiChatHelpers !== undefined && !this.disabledModes.app,
navigator: !this.disabledModes.navigator,
ask: !this.disabledModes.ask,
API: !this.disabledModes.API,
// Dev-only gate. See `./global/gate.ts` for how to enable.
global: isAIModeVisible(AIMode.GLOBAL)
})
@@ -495,9 +513,15 @@ export class AIChatManager {
this.helpers = {}
} else if (mode === AIMode.GLOBAL) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareGlobalSystemMessage(customPrompt)
this.tools = [...globalTools]
this.helpers = {}
this.systemMessage = prepareGlobalSystemMessage(customPrompt, {
previewTools: this.isSessionChat
})
this.tools = globalToolsFor({ sessionPreview: this.isSessionChat })
this.helpers = {
...(this.isSessionChat ? { sessionId: this.sessionId } : {}),
testActiveFlow: async (args?: Record<string, any>) =>
this.flowAiChatHelpers?.testFlow(args)
} satisfies GlobalToolHelpers
} else if (mode === AIMode.APP) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareAppSystemMessage(customPrompt)
@@ -795,6 +819,12 @@ export class AIChatManager {
}
}
// Optional pre-flight hook called once per send, after validation but
// before any UI state mutates or backend calls go out. Sessions use
// this to commit/materialise the workspace (creating a staged fork via
// the API) so the first message targets the correct workspace.
beforeSend?: () => Promise<void> | void
sendRequest = async (
options: {
removeDiff?: boolean
@@ -819,6 +849,24 @@ export class AIChatManager {
if (!this.instructions.trim()) {
return
}
if (this.beforeSend) {
try {
await this.beforeSend()
} catch (e) {
// beforeSend commits the session's workspace before the first
// message hits the backend. If it throws, sending anyway would
// silently target the wrong workspace (typically the parent), so
// abort and tell the user — their message text stays in the input.
console.error('AIChatManager beforeSend hook failed', e)
sendUserToast(
`Could not prepare the session before sending: ${
e instanceof Error ? e.message : String(e)
}. Your message was not sent — please try again.`,
true
)
return
}
}
try {
const oldSelectedContext = this.contextManager?.getSelectedContext() ?? []
if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) {
@@ -16,7 +16,13 @@ vi.mock('$lib/gen', () => ({
}))
vi.mock('$lib/stores', () => ({
workspaceStore: { subscribe: () => () => undefined }
workspaceStore: { subscribe: () => () => undefined },
userStore: {
subscribe: (run: (value: { username: string }) => void) => {
run({ username: 'admin' })
return () => undefined
}
}
}))
vi.mock('$lib/toast', () => ({
@@ -55,10 +61,12 @@ vi.mock('esm-env', async (importOriginal) => ({
function createFlowHelpers({
hasPendingChanges,
acceptAllModuleActions
acceptAllModuleActions,
testFlow = vi.fn()
}: {
hasPendingChanges: () => boolean
acceptAllModuleActions: () => void
testFlow?: FlowAIChatHelpers['testFlow']
}): FlowAIChatHelpers {
return {
getFlowAndSelectedId: vi.fn(),
@@ -74,7 +82,7 @@ function createFlowHelpers({
rejectAllModuleActions: vi.fn(),
hasPendingChanges,
selectStep: vi.fn(),
testFlow: vi.fn(),
testFlow,
getLintErrors: vi.fn()
} as unknown as FlowAIChatHelpers
}
@@ -162,6 +170,27 @@ describe('AIChatManager autonomy mode', () => {
expect(applied).toBe(true)
})
it('does not pass the AI session id as a flow test conversation id in global mode', async () => {
const manager = new AIChatManager()
const testFlow = vi.fn(async () => 'job-flow-preview')
manager.isSessionChat = true
manager.sessionId = 'htc1xouxd96dcyo6ruqo39'
manager.setFlowHelpers(
createFlowHelpers({
hasPendingChanges: () => false,
acceptAllModuleActions: vi.fn(),
testFlow
})
)
manager.changeMode(AIMode.GLOBAL)
const jobId = await manager.helpers.testActiveFlow({ name: 'Ada' })
expect(jobId).toBe('job-flow-preview')
expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' })
})
})
describe('AIChatManager persisted autonomy default', () => {
@@ -76,7 +76,7 @@
onClick={() => onMenuOpen?.()}
startIcon={{ icon: Menu }}
iconOnly
></Button>
/>
</div>
<div class="flex-1 min-h-0">
{@render children?.()}
@@ -96,5 +96,13 @@
{/if}
</Splitpanes>
{:else}
{@render children?.()}
<div
class={classNames(
'flex-1 min-h-0 flex flex-col',
noBorder || $userStore?.operator || isMobile ? '' : isCollapsed ? 'pl-12' : 'pl-40',
'transition-all ease-in-out duration-200'
)}
>
{@render children?.()}
</div>
{/if}
@@ -3,9 +3,16 @@
import { CircleHelp } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { aiChatManager } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import type { UserQuestionDisplay } from './shared'
// Sessions inject a per-pane `AIChatManager` via context; outside of
// sessions getAiChatManager falls back to the global singleton. Without
// this, answers clicked inside a session would dispatch to the singleton's
// pending callbacks map (which doesn't have the session manager's question
// callback), and the AI loop would stall.
const aiChatManager = getAiChatManager()
interface Props {
toolCallId: string
userQuestion: UserQuestionDisplay
@@ -2,7 +2,10 @@
import { ChevronDown } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import { AIMode } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
const aiChatManager = getAiChatManager()
const modeLabel = (mode: AIMode) => mode.charAt(0).toUpperCase() + mode.slice(1) + ' mode'
@@ -1,7 +1,9 @@
<script lang="ts">
import { AlertTriangle } from 'lucide-svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { aiChatManager } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
const aiChatManager = getAiChatManager()
import DefaultDatabaseSelector from '$lib/components/raw_apps/DefaultDatabaseSelector.svelte'
import { workspaceStore } from '$lib/stores'
import { createDatatablesResource } from '$lib/components/raw_apps/datatableUtils.svelte'
@@ -69,4 +71,4 @@
description="Set the default datatable and schema for new tables. When table creation is enabled, AI can create tables here if needed."
/>
{/if}
</div>
</div>
@@ -11,6 +11,7 @@ interface ChatSchema extends IDBSchema {
displayMessages: DisplayMessage[]
title: string
lastModified: number
sessionId?: string
}
}
}
@@ -26,14 +27,21 @@ export default class HistoryManager {
title: string
id: string
lastModified: number
sessionId?: string
}
> = $state({})
private currentChatId: string = $state(createLongHash())
// When set, this manager is bound to a session: only chats tagged with this id
// are surfaced and new chats are saved with this id. When undefined (singleton),
// session-tagged chats are excluded from history.
private sessionId: string | undefined = $state(undefined)
private pastChats = $derived(
Object.values(this.savedChats)
.filter((c) => c.id !== this.currentChatId)
.filter((c) => (this.sessionId ? c.sessionId === this.sessionId : !c.sessionId))
.sort((a, b) => b.lastModified - a.lastModified)
)
@@ -69,10 +77,33 @@ export default class HistoryManager {
return this.currentChatId
}
setCurrentChatId(id: string) {
this.currentChatId = id
}
setSessionId(id: string | undefined) {
this.sessionId = id
}
async tagChatWithSession(chatId: string, sessionId: string) {
const existing = this.savedChats[chatId]
if (!existing || existing.sessionId === sessionId) return
const snapshot = $state.snapshot(existing)
const updated = { ...snapshot, sessionId }
this.savedChats = { ...this.savedChats, [chatId]: updated }
if (this.indexDB) {
await this.indexDB.put('chats', updated)
}
}
getPastChats() {
return this.pastChats
}
getAllSavedChats() {
return Object.values(this.savedChats)
}
async saveChat(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) {
if (displayMessages.length > 0) {
// we don't want to save the snapshot in the history
@@ -84,7 +115,8 @@ export default class HistoryManager {
})),
title: displayMessages[0].content.slice(0, 50),
id: this.currentChatId,
lastModified: Date.now()
lastModified: Date.now(),
...(this.sessionId ? { sessionId: this.sessionId } : {})
}
this.savedChats = {
...this.savedChats,
@@ -1,7 +1,9 @@
<script lang="ts">
import { Loader2, ChevronDown, ChevronRight, XCircle, Play } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { aiChatManager } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
const aiChatManager = getAiChatManager()
import type { ToolDisplayMessage } from './shared'
import { twMerge } from 'tailwind-merge'
import ToolContentDisplay from './ToolContentDisplay.svelte'
@@ -52,13 +54,12 @@
{#if activeUserQuestion}
<AskUserQuestionDisplay toolCallId={message.tool_call_id} userQuestion={activeUserQuestion} />
{:else}
<div
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
>
<div class="bg-surface border border-border-light rounded-md overflow-hidden font-mono text-xs">
<!-- Collapsible Header -->
<button
class={twMerge(
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
'w-full p-2 bg-surface-secondary/30 hover:bg-surface-hover transition-colors flex items-center justify-between text-left',
isExpanded ? 'border-b border-border-light' : '',
message.needsConfirmation ? 'opacity-80' : ''
)}
onclick={() => (isExpanded = !isExpanded)}
@@ -107,7 +108,7 @@
<div
class={twMerge(
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
hasParameters ? 'border-t border-border-light' : ''
)}
>
<Button
@@ -6,7 +6,9 @@
import type { FlowAIChatHelpers } from './core'
import { createInlineScriptSession } from './inlineScriptsUtils'
import { loadSchemaFromModule } from '$lib/components/flows/flowInfers'
import { aiChatManager } from '../AIChatManager.svelte'
import { getAiChatManager } from '../aiChatManagerContext'
const aiChatManager = getAiChatManager()
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { FlowCopilotContext } from '../../flow'
import type { ScriptLintResult } from '../shared'
@@ -1,10 +1,4 @@
import {
ScriptService,
type FlowModule,
type InputTransform,
type RawScript,
JobService
} from '$lib/gen'
import { type FlowModule, type InputTransform, type RawScript } from '$lib/gen'
import type {
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam
@@ -30,7 +24,8 @@ import {
formatScriptLintResult,
type ScriptLintResult,
createSearchWorkspaceTool,
createGetRunnableDetailsTool
createGetRunnableDetailsTool,
executeFlowStepTestRun
} from '../shared'
import { createWorkspaceMutationTools } from '../workspaceTools'
import type { ContextElement } from '../context'
@@ -183,7 +178,7 @@ const setFlowJsonToolSchema = z.object({
.optional()
.nullable()
.describe(
'JSON string containing the optional array of semantic flow groups (summary, note, autocollapse, start_id, end_id, color). Pass null to clear groups.'
'JSON string containing the optional array of semantic flow groups. Each group has summary, note, autocollapse, start_id, end_id, color. color MUST be one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes or other strings. Pass null to clear groups.'
)
})
@@ -442,99 +437,14 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
const stepId = args.stepId
const stepArgs = args.args || {}
// Find the step in the flow (includes preprocessor/failure modules)
let targetModule: FlowModule | undefined = findModuleInFlow(flow.value, stepId) ?? undefined
if (!targetModule) {
toolCallbacks.setToolStatus(toolId, {
content: `Step '${stepId}' not found in flow`,
error: `Step with id '${stepId}' does not exist in the current flow`
})
throw new Error(
`Step with id '${stepId}' not found in flow. Available steps: ${(flow.value.modules ?? []).map((m: FlowModule) => m.id).join(', ')}`
)
}
const module = targetModule
const moduleValue = module.value
if (moduleValue.type === 'rawscript') {
// Test raw script step
return executeTestRun({
jobStarter: () =>
JobService.runScriptPreview({
workspace: workspace,
requestBody: {
content: moduleValue.content ?? '',
language: moduleValue.language,
args:
module.id === SPECIAL_MODULE_IDS.PREPROCESSOR
? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...stepArgs }
: stepArgs
}
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Starting test run of step '${stepId}'...`,
contextName: 'script'
})
} else if (moduleValue.type === 'script') {
// Test script step - need to get the script content
const script = moduleValue.hash
? await ScriptService.getScriptByHash({
workspace: workspace,
hash: moduleValue.hash
})
: await ScriptService.getScriptByPath({
workspace: workspace,
path: moduleValue.path
})
return executeTestRun({
jobStarter: () =>
JobService.runScriptPreview({
workspace: workspace,
requestBody: {
content: script.content,
language: script.language,
args:
module.id === SPECIAL_MODULE_IDS.PREPROCESSOR
? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...stepArgs }
: stepArgs
}
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Starting test run of script step '${stepId}'...`,
contextName: 'script'
})
} else if (moduleValue.type === 'flow') {
// Test flow step
return executeTestRun({
jobStarter: () =>
JobService.runFlowByPath({
workspace: workspace,
path: moduleValue.path,
requestBody: stepArgs
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Starting test run of flow step '${stepId}'...`,
contextName: 'flow'
})
} else {
toolCallbacks.setToolStatus(toolId, {
content: `Step type '${moduleValue.type}' not supported for testing`,
error: `Cannot test step of type '${moduleValue.type}'`
})
throw new Error(
`Cannot test step of type '${moduleValue.type}'. Supported types: rawscript, script, flow`
)
}
return executeFlowStepTestRun({
flowValue: flow.value,
stepId,
args: stepArgs,
workspace,
toolCallbacks,
toolId
})
},
requiresConfirmation: true,
confirmationMessage: 'Run flow step test',
@@ -949,7 +859,7 @@ Use the \`set_flow_json\` tool to set the entire flow structure at once. Provide
- \`schema\`: Flow input schema in JSON Schema format (optional)
- \`preprocessor_module\`: Special module that runs before \`modules\` (optional, separate from \`modules\`)
- \`failure_module\`: Special module that runs on failure (optional, separate from \`modules\`)
- \`groups\`: Array of semantic groups for organizing modules in the editor (optional). Each group has \`summary\` (display name), \`note\` (markdown description shown below the group header — attached directly to the group, not a separate sticky note), \`autocollapse\`, \`start_id\`, \`end_id\`, and \`color\`. \`start_id\` and \`end_id\` must reference existing module IDs in the flow (not \`preprocessor\` or \`failure\`). Groups do not affect execution — they provide naming and collapsibility in the editor. Pass \`null\` to clear existing groups.
- \`groups\`: Array of semantic groups for organizing modules in the editor (optional). Each group has \`summary\` (display name), \`note\` (markdown description shown below the group header — attached directly to the group, not a separate sticky note), \`autocollapse\`, \`start_id\`, \`end_id\`, and \`color\`. \`start_id\` and \`end_id\` must reference existing module IDs in the flow (not \`preprocessor\` or \`failure\`). \`color\` MUST be one of these exact names: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — do NOT use hex codes, CSS colors, or any other strings. Omit \`color\` entirely if no preference and the editor will assign one automatically. Groups do not affect execution — they provide naming and collapsibility in the editor. Pass \`null\` to clear existing groups.
**Example - Simple flow:**
\`\`\`javascript
@@ -299,4 +299,23 @@ describe('validateFlowGroups', () => {
const result = validateFlowGroups([{ start_id: 'a', end_id: 'c', summary: 'G' }], moduleIds)
expect(result).toEqual([{ start_id: 'a', end_id: 'c', summary: 'G' }])
})
it('rejects an unknown color name', () => {
expect(() => validateFlowGroups([{ start_id: 'a', end_id: 'b', color: '#ff00aa' }])).toThrow(
/color must be one of/
)
expect(() => validateFlowGroups([{ start_id: 'a', end_id: 'b', color: 'magenta' }])).toThrow(
/color must be one of/
)
})
it('accepts a known color name', () => {
const result = validateFlowGroups([{ start_id: 'a', end_id: 'b', color: 'blue' }])
expect(result).toEqual([{ start_id: 'a', end_id: 'b', color: 'blue' }])
})
it('accepts a group with no color', () => {
const result = validateFlowGroups([{ start_id: 'a', end_id: 'b' }])
expect(result).toEqual([{ start_id: 'a', end_id: 'b' }])
})
})
@@ -1,8 +1,14 @@
import type { FlowModule, FlowValue, OpenFlow, RawScript } from '$lib/gen'
import { forEachFlowModule } from '$lib/components/flows/dfs'
import { findModuleInFlow } from '$lib/components/flows/flowTree'
import { NoteColor } from '$lib/components/graph/noteColors'
import type { InlineScriptSession } from './inlineScriptsUtils'
/** Allowed group color names — matches the NoteColor palette the group
* editor uses. Other strings would render with default-blue styling at best
* and break the color picker UI at worst. */
const ALLOWED_GROUP_COLORS = new Set<string>(Object.values(NoteColor))
type FlowLike = Pick<OpenFlow, 'value'> & {
schema?: Record<string, any>
}
@@ -71,6 +77,13 @@ export function validateFlowGroups(
)
}
}
if (g.color !== undefined && g.color !== null) {
if (typeof g.color !== 'string' || !ALLOWED_GROUP_COLORS.has(g.color)) {
throw new Error(
`Invalid group at index ${index}: color must be one of ${[...ALLOWED_GROUP_COLORS].join(', ')}`
)
}
}
return g as unknown as FlowGroup
})
}
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('monaco-editor', () => ({
editor: {},
@@ -48,8 +48,21 @@ vi.mock('$lib/gen', async () => {
getScriptByPath: vi.fn(async () => {
throw new Error('getScriptByPath mock not configured')
}),
queryHubScripts: vi.fn(async () => []),
getHubScriptContentByPath: vi.fn(async () => ''),
listScripts: vi.fn(async () => [])
}),
JobService: wrapService(actual.JobService, {
runScriptPreview: vi.fn(async () => 'job-script-preview'),
runFlowPreview: vi.fn(async () => 'job-flow-preview'),
runFlowByPath: vi.fn(async () => 'job-flow-by-path'),
getJob: vi.fn(async () => ({
type: 'CompletedJob',
success: true,
result: { ok: true },
logs: 'test logs'
}))
}),
FlowService: wrapService(actual.FlowService, {
existsFlowByPath: vi.fn(async () => false),
createFlow: vi.fn(async () => 'created'),
@@ -105,7 +118,15 @@ vi.mock('./rawAppBundlerBridge', () => ({
}))
}))
import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './core'
import {
globalTools,
globalToolsFor,
prepareGlobalSystemMessage,
prepareGlobalUserMessage,
setDeployedInSessionHandler,
setGetPreviewStatusHandler,
setOpenPreviewHandler
} from './core'
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
import { clearGlobalDrafts } from './userDraftAdapter'
import { bundleRawAppDraft } from './rawAppBundlerBridge'
@@ -113,6 +134,7 @@ import {
AppService,
FlowService,
HttpTriggerService,
JobService,
ResourceService,
ScheduleService,
ScriptService,
@@ -138,12 +160,13 @@ function getGlobalTool(name: string): Tool<{}> {
async function callGlobalTool(
name: string,
args: Record<string, unknown>,
callbacks: ToolCallbacks = toolCallbacks
callbacks: ToolCallbacks = toolCallbacks,
helpers: Record<string, unknown> = {}
): Promise<string> {
return getGlobalTool(name).fn({
args,
workspace: WORKSPACE,
helpers: {},
helpers,
toolCallbacks: callbacks,
toolId: `test-${name}`
})
@@ -158,6 +181,17 @@ function localStorageSnapshot(): string {
return values.join('\n')
}
async function withCompletedTestJob<T>(run: () => Promise<T>): Promise<T> {
vi.useFakeTimers()
try {
const promise = run()
await vi.advanceTimersByTimeAsync(1000)
return await promise
} finally {
vi.useRealTimers()
}
}
describe('global AI tools', () => {
beforeEach(() => {
__resetUserDraftForTesting()
@@ -166,6 +200,41 @@ describe('global AI tools', () => {
vi.clearAllMocks()
})
it('exposes hub search and path-aware test tools', () => {
const names = globalTools.map((tool) => tool.def.function.name)
expect(names).toContain('search_hub_scripts')
expect(names).toContain('test_run_script')
expect(names).toContain('test_run_flow')
expect(names).toContain('test_run_step')
})
it('searches hub scripts without fetching script contents', async () => {
vi.mocked(ScriptService.queryHubScripts).mockResolvedValueOnce([
{
version_id: 7,
app: 'slack',
summary: 'Send Message'
}
] as any)
const raw = await callGlobalTool('search_hub_scripts', {
query: 'slack message'
})
expect(ScriptService.queryHubScripts).toHaveBeenCalledWith({
text: 'slack message',
kind: 'script'
})
expect(ScriptService.getHubScriptContentByPath).not.toHaveBeenCalled()
expect(JSON.parse(raw)).toEqual([
{
path: 'hub/7/slack/send_message',
summary: 'Send Message'
}
])
})
it('redacts variable draft values when reading workspace items', async () => {
await callGlobalTool('write_variable', {
path: 'f/secrets/api_key',
@@ -1075,6 +1144,44 @@ describe('global AI tools', () => {
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('notifies the session preview (as raw_app) after deploying a raw app', async () => {
const onDeployed = vi.fn()
setDeployedInSessionHandler(onDeployed)
try {
UserDraft.save(
'raw_app',
'f/apps/report',
{
summary: 'AI report',
files: { '/index.tsx': 'console.log("app")' },
runnables: {},
data: { tables: [] }
},
{ workspace: WORKSPACE }
)
await callGlobalTool(
'deploy_workspace_item',
{ type: 'app', path: 'f/apps/report' },
toolCallbacks,
{
sessionId: 'sess-123'
}
)
// A raw app deploys under type 'app' but the preview addresses it as
// 'raw_app'; the calling session id is threaded through so the deploy
// reloads the issuing session's preview, not the UI-active one.
expect(onDeployed).toHaveBeenCalledWith({
sessionId: 'sess-123',
kind: 'raw_app',
path: 'f/apps/report'
})
} finally {
setDeployedInSessionHandler(undefined)
}
})
it('fills an empty rawscript module through set_flow_module_code', async () => {
await callGlobalTool('write_flow', {
path: 'f/flows/empty-module',
@@ -1100,7 +1207,7 @@ describe('global AI tools', () => {
module_id: 'empty_step',
code
})
).resolves.toContain('Updated local draft flow')
).resolves.toContain('Updated flow')
await expect(
callGlobalTool('read_flow_module_code', {
@@ -1165,6 +1272,368 @@ describe('global AI tools', () => {
expect(item.value.value).toBeUndefined()
})
it('test_run_script previews local draft script content by path', async () => {
const content = 'export async function main(name: string) {\n\treturn `hello ${name}`\n}'
await callGlobalTool('write_script', {
path: 'f/scripts/draft-test',
summary: 'Draft test script',
language: 'bun',
content
})
const result = await withCompletedTestJob(() =>
callGlobalTool('test_run_script', {
path: 'f/scripts/draft-test',
args: { name: 'Ada' }
})
)
expect(JobService.runScriptPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
path: 'f/scripts/draft-test',
content,
args: { name: 'Ada' },
language: 'bun'
}
})
expect(ScriptService.getScriptByPath).not.toHaveBeenCalled()
expect(result).toContain('Result (SUCCESS)')
expect(result).toContain('test logs')
})
it('test_run_script previews deployed script content when no local draft exists', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/deployed-test',
summary: 'Deployed test script',
content: 'def main(name):\n return name',
language: 'python3'
} as any)
await withCompletedTestJob(() =>
callGlobalTool('test_run_script', {
path: 'f/scripts/deployed-test',
args: { name: 'Grace' }
})
)
expect(ScriptService.getScriptByPath).toHaveBeenCalledWith({
workspace: WORKSPACE,
path: 'f/scripts/deployed-test'
})
expect(JobService.runScriptPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
path: 'f/scripts/deployed-test',
content: 'def main(name):\n return name',
args: { name: 'Grace' },
language: 'python3'
}
})
})
it('test_run_flow previews local draft flow content by path', async () => {
const modules = [{ id: 'start', value: { type: 'identity' } }]
await callGlobalTool('write_flow', {
path: 'f/flows/draft-test',
summary: 'Draft test flow',
modules: JSON.stringify(modules)
})
await withCompletedTestJob(() =>
callGlobalTool('test_run_flow', {
path: 'f/flows/draft-test',
args: { name: 'Ada' }
})
)
expect(FlowService.getFlowByPath).not.toHaveBeenCalled()
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
path: 'f/flows/draft-test',
value: { modules },
args: { name: 'Ada' }
}
})
})
it('test_run_flow previews deployed flow content when no local draft exists', async () => {
const modules = [{ id: 'deployed_start', value: { type: 'identity' } }]
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
path: 'f/flows/deployed-test',
summary: 'Deployed test flow',
value: { modules },
schema: {}
} as any)
await withCompletedTestJob(() =>
callGlobalTool('test_run_flow', {
path: 'f/flows/deployed-test',
args: { name: 'Grace' }
})
)
expect(FlowService.getFlowByPath).toHaveBeenCalledWith({
workspace: WORKSPACE,
path: 'f/flows/deployed-test'
})
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
path: 'f/flows/deployed-test',
value: { modules },
args: { name: 'Grace' }
}
})
})
it('test_run_flow uses the live flow editor test hook when the active editor matches the path', async () => {
UserDraft.save(
'flow',
'',
{
path: 'u/admin/live_flow',
summary: 'Live flow',
value: { modules: [{ id: 'live_step', value: { type: 'identity' } }] },
schema: {},
edited_by: '',
edited_at: '',
archived: false,
extra_perms: {}
},
{ workspace: WORKSPACE }
)
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'flow',
storagePath: '',
effectivePath: 'u/admin/live_flow'
})
const testActiveFlow = vi.fn(async () => 'job-live-flow')
const result = await withCompletedTestJob(() =>
callGlobalTool(
'test_run_flow',
{
path: 'u/admin/live_flow',
args: { name: 'Ada' }
},
toolCallbacks,
{ testActiveFlow }
)
)
expect(testActiveFlow).toHaveBeenCalledWith({ name: 'Ada' })
expect(FlowService.getFlowByPath).not.toHaveBeenCalled()
expect(JobService.runFlowPreview).not.toHaveBeenCalled()
expect(result).toContain('Result (SUCCESS)')
})
it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => {
UserDraft.save(
'flow',
'',
{
path: 'u/admin/live_flow_fallback',
summary: 'Live flow fallback',
value: { modules: [{ id: 'fallback_step', value: { type: 'identity' } }] },
schema: {},
edited_by: '',
edited_at: '',
archived: false,
extra_perms: {}
},
{ workspace: WORKSPACE }
)
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'flow',
storagePath: '',
effectivePath: 'u/admin/live_flow_fallback'
})
const testActiveFlow = vi.fn(async () => undefined)
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_flow',
{
path: 'u/admin/live_flow_fallback',
args: { name: 'Ada' }
},
toolCallbacks,
{ testActiveFlow }
)
)
expect(testActiveFlow).toHaveBeenCalledWith({ name: 'Ada' })
expect(FlowService.getFlowByPath).not.toHaveBeenCalled()
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
path: 'u/admin/live_flow_fallback',
value: { modules: [{ id: 'fallback_step', value: { type: 'identity' } }] },
args: { name: 'Ada' }
}
})
})
it('test_run_step previews rawscript steps from the local draft flow', async () => {
const content = 'export async function main(name: string) {\n\treturn name.toUpperCase()\n}'
await callGlobalTool('write_flow', {
path: 'f/flows/rawscript-step',
summary: 'Flow with rawscript',
modules: JSON.stringify([
{
id: 'format_name',
value: {
type: 'rawscript',
language: 'bun',
content,
input_transforms: {}
}
}
])
})
await withCompletedTestJob(() =>
callGlobalTool('test_run_step', {
path: 'f/flows/rawscript-step',
stepId: 'format_name',
args: { name: 'Ada' }
})
)
expect(JobService.runScriptPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
content,
language: 'bun',
args: { name: 'Ada' }
}
})
})
it('test_run_step prefers local script drafts for script steps', async () => {
const content = 'export async function main(name: string) {\n\treturn `draft ${name}`\n}'
await callGlobalTool('write_script', {
path: 'f/scripts/step-script',
summary: 'Step script',
language: 'bun',
content
})
await callGlobalTool('write_flow', {
path: 'f/flows/script-step',
summary: 'Flow with script step',
modules: JSON.stringify([
{
id: 'call_script',
value: {
type: 'script',
path: 'f/scripts/step-script',
input_transforms: {}
}
}
])
})
await withCompletedTestJob(() =>
callGlobalTool('test_run_step', {
path: 'f/flows/script-step',
stepId: 'call_script',
args: { name: 'Ada' }
})
)
expect(ScriptService.getScriptByPath).not.toHaveBeenCalled()
expect(JobService.runScriptPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
path: 'f/scripts/step-script',
content,
language: 'bun',
args: { name: 'Ada' }
}
})
})
it('test_run_step previews local draft subflows for flow steps', async () => {
const nestedModules = [{ id: 'nested_start', value: { type: 'identity' } }]
await callGlobalTool('write_flow', {
path: 'f/flows/nested-draft',
summary: 'Nested draft flow',
modules: JSON.stringify(nestedModules)
})
await callGlobalTool('write_flow', {
path: 'f/flows/parent-flow',
summary: 'Parent flow',
modules: JSON.stringify([
{
id: 'call_flow',
value: {
type: 'flow',
path: 'f/flows/nested-draft',
input_transforms: {}
}
}
])
})
await withCompletedTestJob(() =>
callGlobalTool('test_run_step', {
path: 'f/flows/parent-flow',
stepId: 'call_flow',
args: { name: 'Ada' }
})
)
expect(JobService.runFlowByPath).not.toHaveBeenCalled()
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
path: 'f/flows/nested-draft',
value: { modules: nestedModules },
args: { name: 'Ada' }
}
})
})
it('test_run_step lists nested step ids when a step is not found', async () => {
await callGlobalTool('write_flow', {
path: 'f/flows/nested-step-error',
summary: 'Flow with nested step',
modules: JSON.stringify([
{
id: 'loop_step',
value: {
type: 'forloopflow',
iterator: { type: 'static', value: [1] },
skip_failures: false,
modules: [
{
id: 'nested_script_step',
value: {
type: 'rawscript',
language: 'bun',
content: 'export async function main() { return 1 }',
input_transforms: {}
}
}
]
}
}
])
})
await expect(
callGlobalTool('test_run_step', {
path: 'f/flows/nested-step-error',
stepId: 'missing_nested_step',
args: {}
})
).rejects.toThrow(/Available steps: loop_step, nested_script_step/)
})
it('asks the user a question and returns the selected answer', async () => {
const callbacks: ToolCallbacks = {
setToolStatus: vi.fn(),
@@ -1283,6 +1752,9 @@ describe('prepareGlobalSystemMessage', () => {
expect(content).toContain(
'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft'
)
expect(content).toContain(
'After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step'
)
expect(content).toContain('If the user message includes an ACTIVE EDITOR section')
expect(content).not.toContain('AI draft')
expect(content).not.toContain('UserDraft')
@@ -1303,6 +1775,62 @@ describe('prepareGlobalSystemMessage', () => {
expect(discard.requiresConfirmation).toBe(true)
expect(deleteItem.requiresConfirmation).toBe(true)
})
describe('get_preview_status', () => {
afterEach(() => {
setGetPreviewStatusHandler(undefined)
setOpenPreviewHandler(undefined)
})
it('takes no arguments', () => {
const tool = getGlobalTool('get_preview_status')
expect(tool.def.function.parameters).toMatchObject({
type: 'object',
properties: {},
required: []
})
})
it('returns the session-only error when no handler is registered', async () => {
setGetPreviewStatusHandler(undefined)
const result = await callGlobalTool('get_preview_status', {})
expect(result).toBe('Error: get_preview_status is only available inside an AI session.')
})
it('dispatches to the registered session handler', async () => {
setGetPreviewStatusHandler(() => 'The preview is currently open showing script "u/me/foo".')
const result = await callGlobalTool('get_preview_status', {})
expect(result).toBe('The preview is currently open showing script "u/me/foo".')
})
})
})
describe('session-only preview tools gating', () => {
const toolNames = (sessionPreview: boolean) =>
globalToolsFor({ sessionPreview }).map((t) => t.def.function.name)
it('excludes open_preview / get_preview_status outside a session', () => {
const names = toolNames(false)
expect(names).not.toContain('open_preview')
expect(names).not.toContain('get_preview_status')
// other tools are still present
expect(names).toContain('write_script')
})
it('includes open_preview / get_preview_status inside a session', () => {
const names = toolNames(true)
expect(names).toContain('open_preview')
expect(names).toContain('get_preview_status')
// session set is the full globalTools
expect(names.length).toBe(globalTools.length)
})
it('mentions open_preview in the system prompt only when preview tools are enabled', () => {
const off = prepareGlobalSystemMessage(undefined, { previewTools: false }).content as string
const on = prepareGlobalSystemMessage(undefined, { previewTools: true }).content as string
expect(off).not.toContain('open_preview')
expect(on).toContain('open_preview')
})
})
describe('prepareGlobalUserMessage', () => {
@@ -4,6 +4,7 @@ import {
FlowService,
GcpTriggerService,
HttpTriggerService,
JobService,
KafkaTriggerService,
MqttTriggerService,
NatsTriggerService,
@@ -55,6 +56,9 @@ import type {
import { z } from 'zod'
import {
createToolDef,
createSearchHubScriptsTool,
executeFlowStepTestRun,
executeTestRun,
findAndReplace,
type CreatedResourceTriggerKind,
type Tool,
@@ -84,6 +88,8 @@ import {
type WorkspaceItemType
} from './workspaceItems'
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
import { userStore } from '$lib/stores'
import { get } from 'svelte/store'
import { bundleRawAppDraft } from './rawAppBundlerBridge'
import {
clearEphemeralSecretVariableDraftValue,
@@ -385,6 +391,49 @@ const patchFlowJsonSchema = z.object({
)
})
const testRunArgsSchema = z
.record(z.string(), z.any())
.nullable()
.optional()
.describe('Arguments to pass to the runnable. Omit or pass null when no arguments are needed.')
const testRunScriptSchema = z.object({
path: z.string().describe('Workspace path of the script to test.'),
args: testRunArgsSchema
})
const testRunScriptToolDef = createToolDef(
testRunScriptSchema,
'test_run_script',
'Execute a preview-style test run of a script by path, preferring local draft content when it exists.',
{ strict: false }
)
const testRunFlowSchema = z.object({
path: z.string().describe('Workspace path of the flow to test.'),
args: testRunArgsSchema
})
const testRunFlowToolDef = createToolDef(
testRunFlowSchema,
'test_run_flow',
'Execute a preview-style test run of a flow by path, preferring local draft content when it exists.',
{ strict: false }
)
const testRunStepSchema = z.object({
path: z.string().describe('Workspace path of the flow containing the step to test.'),
stepId: z.string().describe('The id of the step/module to test.'),
args: testRunArgsSchema
})
const testRunStepToolDef = createToolDef(
testRunStepSchema,
'test_run_step',
'Execute a test run of one step in a flow by path, preferring local draft flow/script content when it exists.',
{ strict: false }
)
// ============= App tools (raw apps) =============
const backendRunnableSchema = z
@@ -480,6 +529,17 @@ const deleteAppRunnableSchema = z.object({
key: z.string().describe('Key of the backend runnable to remove.')
})
const openPreviewSchema = z.object({
kind: z
.enum(['script', 'flow', 'raw_app'])
.describe(
'Item kind to preview. Use "raw_app" for code-based apps (created via init_app). The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.'
),
path: z.string().describe('Workspace path of the item to preview.')
})
const getPreviewStatusSchema = z.object({})
const FRAMEWORK_KEYS = [
'react19',
'react18',
@@ -514,10 +574,23 @@ const initAppSchema = z.object({
.describe('Optional datatable configuration. Omit unless the user asked to wire one up.')
})
const GLOBAL_SYSTEM_PROMPT = `You are Windmill's global workspace assistant.
const buildGlobalSystemPrompt = (
username: string,
previewTools: boolean
) => `You are Windmill's global workspace assistant.
The current user's workspace username is "${username}".
Use tools to inspect workspace items and create local drafts for scripts, flows, schedules, triggers, resources, variables, and raw apps.
Path conventions:
- Every workspace path has exactly three segments and starts with one of two namespaces:
- \`u/${username}/<name>\` — the current user's personal scope. Default for ad-hoc, exploratory, or scratch work.
- \`f/<folder>/<name>\` — a shared folder scope. The folder must already exist; bare \`f/<name>\` is INVALID and will fail.
- When the user gives a bare name without a namespace prefix (e.g. "create a flow called myflow"), default to \`u/${username}/<name>\`. Do NOT invent \`f/<name>\` — that is a structurally invalid path.
- If the request implies shared / team work but doesn't name a specific folder (e.g. "the marketing flow"), ask which folder to use rather than guessing. Call \`list_workspace_items\` with \`type: ['folder']\` (or rely on the user's hint) before assuming a folder exists.
- Only use an \`f/<folder>/<name>\` path when the user explicitly named the folder or you confirmed it exists.
Rules:
- Draft tools create or update local drafts only; they do not deploy or mutate deployed workspace items.
- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.
@@ -527,8 +600,14 @@ Rules:
- Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable".
- Use search_resource_types before write_resource.
- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.
- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer local drafts, so testing does not require deployment.
- 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.
- Keep context targeted.
- Keep context targeted.${
previewTools
? `
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.`
: ''
}
Flows:
- read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.<moduleId>".
@@ -1194,7 +1273,7 @@ function getScriptInstructions(language: ScriptLang | undefined): string {
- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.
- A script draft is a workspace item: \`{ type: 'script', path, summary?, language, value, isDraft }\` where \`value\` is the source code string.
- Use workspace paths such as \`f/folder/name\` or \`u/username/name\`. Preserve the current path/language when modifying unless the user asked to change them.
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` when the user gave a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.
- Use \`edit_script\` for small localized changes (provide \`old_string\`/\`new_string\`); use \`write_script\` for full rewrites.${note}
# Windmill script authoring reference (${selected})
@@ -1206,6 +1285,7 @@ function getFlowInstructions(): string {
return `# Global draft flow instructions
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` when the user gave a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist. Never invent a folder.
- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. The flow-structure arguments are JSON strings, matching the tool schema descriptions.
- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`.
- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`.
@@ -1233,7 +1313,7 @@ function getAppInstructions(): string {
return `# Global draft app instructions
- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.
- App drafts are addressed by workspace path (e.g. \`f/folder/my_app\`). The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to \`u/<current-user>/<name>\` for bare names; only use \`f/<folder>/<name>\` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
- To create a new app, use \`init_app\` with a path, optional summary, and a framework (\`react19\` / \`react18\` / \`svelte5\` / \`vue\`). Confirm framework + path + summary with the user before calling — do not silently default to \`react19\` even though it is the recommended choice. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
- \`init_app\` seeds a starter inline runnable named \`a\` (bun, \`main(x: string) => string\`) so the React/Svelte demo button works on first render. Replace or remove it once you start building real backend runnables.
- Frontend file paths start with \`/\` (e.g. \`/index.tsx\`, \`/App.tsx\`, \`/styles.css\`). Use \`write_app_file\` / \`patch_app_file\` / \`delete_app_file\`.
@@ -1296,6 +1376,7 @@ export const globalTools: Tool<{}>[] = [
return getInstructions(parsed.subject, parsed.language)
}
},
createSearchHubScriptsTool(false),
{
def: createToolDef(
askUserQuestionSchema,
@@ -1520,6 +1601,39 @@ export const globalTools: Tool<{}>[] = [
return patchFlowJson(parsed, ctx)
}
},
{
def: testRunScriptToolDef,
fn: async (ctx) => {
const parsed = testRunScriptSchema.parse(ctx.args)
return testRunScriptByPath(parsed, ctx)
},
requiresConfirmation: true,
confirmationMessage: 'Run script test',
showDetails: true,
autoCollapseDetails: false
},
{
def: testRunFlowToolDef,
fn: async (ctx) => {
const parsed = testRunFlowSchema.parse(ctx.args)
return testRunFlowByPath(parsed, ctx)
},
requiresConfirmation: true,
confirmationMessage: 'Run flow test',
showDetails: true,
autoCollapseDetails: false
},
{
def: testRunStepToolDef,
fn: async (ctx) => {
const parsed = testRunStepSchema.parse(ctx.args)
return testRunFlowStepByPath(parsed, ctx)
},
requiresConfirmation: true,
confirmationMessage: 'Run flow step test',
showDetails: true,
autoCollapseDetails: false
},
{
def: createToolDef(
deployWorkspaceItemSchema,
@@ -1533,7 +1647,7 @@ export const globalTools: Tool<{}>[] = [
confirmationMessage: 'Deploy local draft to workspace',
fn: async (ctx) => {
const parsed = deployWorkspaceItemSchema.parse(ctx.args)
return deployDraft(parsed, ctx)
return deployDraft(parsed, { ...ctx, sessionId: sessionIdFromCtx(ctx) })
}
},
{
@@ -1739,13 +1853,137 @@ export const globalTools: Tool<{}>[] = [
const parsed = deleteAppRunnableSchema.parse(ctx.args)
return deleteAppRunnable(parsed, ctx)
}
},
{
def: createToolDef(
openPreviewSchema,
'open_preview',
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
),
fn: async (ctx) => {
const parsed = openPreviewSchema.parse(ctx.args)
return openSessionPreview(parsed, sessionIdFromCtx(ctx))
}
},
{
def: createToolDef(
getPreviewStatusSchema,
'get_preview_status',
'Check whether the side-panel preview is open in this AI session and which item (kind + path) it is showing. Call this before offering or calling open_preview so you do not re-open a preview that is already showing the item you just edited. Only meaningful inside a session.'
),
fn: async (ctx) => getSessionPreviewStatus(sessionIdFromCtx(ctx))
}
]
// Tools that only make sense inside an AI session (they drive the session's
// side-panel preview). The regular global side-panel chat shouldn't even be
// offered them — see `globalToolsFor`.
export const SESSION_PREVIEW_TOOL_NAMES = new Set(['open_preview', 'get_preview_status'])
/**
* The global tool set for a given chat: the full `globalTools` for a session
* chat, or `globalTools` minus the session-only preview tools for the regular
* global side-panel chat.
*/
export function globalToolsFor({ sessionPreview }: { sessionPreview: boolean }): Tool<{}>[] {
return sessionPreview
? globalTools
: globalTools.filter((t) => !SESSION_PREVIEW_TOOL_NAMES.has(t.def.function.name))
}
type WriteDraftCtx = {
workspace: string
toolId: string
toolCallbacks: ToolCallbacks
// Calling session id (session chats only), threaded through so a deploy
// reloads the preview of the session that issued the deploy — not the
// UI-active one. Undefined for the global side-panel chat.
sessionId?: string
}
// Sessions are the only context where `open_preview` makes sense — the global
// singleton chat in the right side panel has nowhere to mount an editor pane.
// The session runtime registers a handler at construction time so the tool
// has somewhere to dispatch. When no session is active the handler is
// undefined and the tool returns a polite error.
// Per-manager tool helpers for a session chat. Each session's AIChatManager
// sets `helpers = { sessionId }`, so a tool call carries the *calling* session's
// id even when a different session is the UI-active one. Without this the
// handlers below would route a backgrounded session's tool call to whatever
// session the user happens to be viewing.
export type SessionToolHelpers = { sessionId?: string }
export type GlobalToolHelpers = SessionToolHelpers & {
testActiveFlow?: (args?: Record<string, any>) => Promise<string | undefined>
}
function sessionIdFromCtx(ctx: { helpers?: unknown }): string | undefined {
return (ctx.helpers as GlobalToolHelpers | undefined)?.sessionId
}
function activeFlowTestFromCtx(
ctx: { workspace: string; helpers?: unknown },
path: string
): GlobalToolHelpers['testActiveFlow'] | undefined {
const activeEditor = getActiveGlobalEditorContext(ctx.workspace)
if (activeEditor?.type !== 'flow' || activeEditor.path !== path) {
return undefined
}
return (ctx.helpers as GlobalToolHelpers | undefined)?.testActiveFlow
}
export type OpenPreviewHandler = (req: {
sessionId: string | undefined
kind: 'script' | 'flow' | 'raw_app'
path: string
}) => string
let openPreviewHandler: OpenPreviewHandler | undefined
export function setOpenPreviewHandler(handler: OpenPreviewHandler | undefined): void {
openPreviewHandler = handler
}
function openSessionPreview(
args: { kind: 'script' | 'flow' | 'raw_app'; path: string },
sessionId: string | undefined
) {
if (!openPreviewHandler) {
return 'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.'
}
return openPreviewHandler({ ...args, sessionId })
}
// Companion to `open_preview`: lets the assistant query the current preview
// state (open? which item?) so it can avoid re-opening a preview already
// showing the item it just edited. Registered by the session runtime
// alongside the open-preview handler.
export type GetPreviewStatusHandler = (sessionId: string | undefined) => string
let getPreviewStatusHandler: GetPreviewStatusHandler | undefined
export function setGetPreviewStatusHandler(handler: GetPreviewStatusHandler | undefined): void {
getPreviewStatusHandler = handler
}
function getSessionPreviewStatus(sessionId: string | undefined): string {
if (!getPreviewStatusHandler) {
return 'Error: get_preview_status is only available inside an AI session.'
}
return getPreviewStatusHandler(sessionId)
}
// Registered by the session runtime to reload the open preview after a chat
// deploy. Undefined outside a session.
export type DeployedInSessionHandler = (req: {
sessionId: string | undefined
kind: 'script' | 'flow' | 'raw_app'
path: string
}) => void
let deployedInSessionHandler: DeployedInSessionHandler | undefined
export function setDeployedInSessionHandler(handler: DeployedInSessionHandler | undefined): void {
deployedInSessionHandler = handler
}
type DraftConfig = Record<string, any>
@@ -1866,7 +2104,7 @@ function buildVariableDeployRequestBody(
function startDraftWrite(ctx: WriteDraftCtx, type: WorkspaceItemType, path: string): void {
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
content: `Writing draft ${type} "${path}"...`
content: `Saving ${type} "${path}" to local storage…`
})
}
@@ -1891,13 +2129,13 @@ function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDra
: stored
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
content: `${verb} local draft ${stored.type} "${stored.path}"`,
result: `Draft ${verb.toLowerCase()}`
content: `${verb} ${stored.type} "${stored.path}" in local storage`,
result: `Saved to local storage`
})
return JSON.stringify(
{
success: true,
message: `${verb} local draft ${stored.type} "${stored.path}". The workspace was not saved or deployed.`,
message: `${verb} ${stored.type} "${stored.path}" in local storage (a browser-only local draft, not a workspace draft). It was not deployed.`,
item: serializedItem
},
null,
@@ -2321,6 +2559,145 @@ async function setFlowModuleCode(
)
}
function normalizeTestRunArgs(args: Record<string, any> | null | undefined): Record<string, any> {
return args ?? {}
}
function flowDraftValueForPreview(flowDraft: FlowDraftValue): FlowValue {
return flowDraftAsEditableInput(flowDraft).value
}
async function loadScriptForFlowStep(
moduleValue: { path: string; hash?: string },
workspace: string
): Promise<{ content: string; language: ScriptLang }> {
const draft = getGlobalDraft(workspace, 'script', moduleValue.path)
if (draft) {
if (typeof draft.value !== 'string' || !draft.language) {
throw new Error(`Draft script "${moduleValue.path}" is missing content or language.`)
}
return { content: draft.value, language: draft.language }
}
const script = moduleValue.hash
? await ScriptService.getScriptByHash({ workspace, hash: moduleValue.hash })
: await ScriptService.getScriptByPath({ workspace, path: moduleValue.path })
return { content: script.content, language: script.language }
}
async function loadDraftFlowPreviewValue(
path: string,
workspace: string
): Promise<FlowValue | undefined> {
if (!getGlobalDraft(workspace, 'flow', path)) {
return undefined
}
const nestedFlow = await loadFlowDraftValue(path, workspace)
return flowDraftValueForPreview(nestedFlow.flow)
}
async function testRunScriptByPath(
args: z.infer<typeof testRunScriptSchema>,
ctx: WriteDraftCtx
): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const script = await loadScriptForEdit(args.path, workspace)
const testArgs = normalizeTestRunArgs(args.args)
return executeTestRun({
jobStarter: () =>
JobService.runScriptPreview({
workspace,
requestBody: {
path: args.path,
content: script.content,
args: testArgs,
language: script.language
}
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Running test for script "${args.path}"...`,
contextName: 'script'
})
}
async function testRunFlowByPath(
args: z.infer<typeof testRunFlowSchema>,
ctx: WriteDraftCtx
): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const testArgs = normalizeTestRunArgs(args.args)
const testActiveFlow = activeFlowTestFromCtx(ctx, args.path)
if (testActiveFlow) {
return executeTestRun({
jobStarter: async () => {
const jobId = await testActiveFlow(testArgs)
if (jobId) {
return jobId
}
const flow = await loadFlowDraftValue(args.path, workspace)
return JobService.runFlowPreview({
workspace,
requestBody: {
path: args.path,
value: flowDraftValueForPreview(flow.flow),
args: testArgs
}
})
},
workspace,
toolCallbacks,
toolId,
startMessage: `Starting flow test run for "${args.path}"...`,
contextName: 'flow'
})
}
const flow = await loadFlowDraftValue(args.path, workspace)
return executeTestRun({
jobStarter: () =>
JobService.runFlowPreview({
workspace,
requestBody: {
path: args.path,
value: flowDraftValueForPreview(flow.flow),
args: testArgs
}
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Starting flow test run for "${args.path}"...`,
contextName: 'flow'
})
}
async function testRunFlowStepByPath(
args: z.infer<typeof testRunStepSchema>,
ctx: WriteDraftCtx
): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const flow = await loadFlowDraftValue(args.path, workspace)
const flowValue = flowDraftValueForPreview(flow.flow)
const testArgs = normalizeTestRunArgs(args.args)
return executeFlowStepTestRun({
flowValue,
stepId: args.stepId,
args: testArgs,
workspace,
toolCallbacks,
toolId,
loadScript: loadScriptForFlowStep,
loadFlowPreviewValue: loadDraftFlowPreviewValue
})
}
async function initApp(
args: {
path: string
@@ -2345,7 +2722,7 @@ async function initApp(
}
toolCallbacks.setToolStatus(toolId, {
content: `Initializing app draft "${path}" with ${framework} template...`
content: `Saving app "${path}" to local storage (${framework} template)…`
})
const template = FRAMEWORK_TEMPLATES[framework]
@@ -2365,13 +2742,13 @@ async function initApp(
const stored = saveAppDraft(workspace, path, value)
toolCallbacks.setToolStatus(toolId, {
content: `Initialized app draft "${path}" (${framework})`,
result: 'Draft initialized'
content: `Saved app "${path}" to local storage (${framework})`,
result: 'Saved to local storage'
})
return JSON.stringify(
{
success: true,
message: `Initialized local draft app "${path}" from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}". Use write_app_file / write_app_runnable to evolve the draft.`,
message: `Initialized app "${path}" in local storage from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}" (a browser-only local draft, not a workspace draft). Use write_app_file / write_app_runnable to evolve it.`,
item: stored
},
null,
@@ -2428,12 +2805,12 @@ async function writeAppFile(
toolCallbacks.setToolStatus(toolId, {
content: `Updated ${target.filePath} in app "${args.path}"`,
result: 'Draft updated'
result: 'Saved to local storage'
})
return JSON.stringify(
{
success: true,
message: `Updated local draft app "${args.path}" with frontend file "${target.filePath}".`,
message: `Updated app "${args.path}" in local storage with frontend file "${target.filePath}".`,
item: stored
},
null,
@@ -2468,12 +2845,12 @@ async function deleteAppFile(
toolCallbacks.setToolStatus(toolId, {
content: `Removed ${target.filePath} from app "${args.path}"`,
result: 'Draft updated'
result: 'Saved to local storage'
})
return JSON.stringify(
{
success: true,
message: `Removed "${target.filePath}" from local draft app "${args.path}".`,
message: `Removed "${target.filePath}" from app "${args.path}" in local storage.`,
item: stored
},
null,
@@ -2551,12 +2928,12 @@ async function patchAppFile(
const stored = saveAppDraft(workspace, path, value, meta)
toolCallbacks.setToolStatus(toolId, {
content: `Patched ${target.filePath} in app "${path}"`,
result: 'Draft updated'
result: 'Saved to local storage'
})
return JSON.stringify(
{
success: true,
message: `Patched "${target.filePath}" in local draft app "${path}".`,
message: `Patched "${target.filePath}" in app "${path}" in local storage.`,
item: stored
},
null,
@@ -2594,12 +2971,12 @@ async function writeAppRunnable(
toolCallbacks.setToolStatus(toolId, {
content: `Updated runnable "${key}" in app "${path}"`,
result: 'Draft updated'
result: 'Saved to local storage'
})
return JSON.stringify(
{
success: true,
message: `Updated local draft app "${path}" with runnable "${key}".`,
message: `Updated app "${path}" in local storage with runnable "${key}".`,
item: stored
},
null,
@@ -2628,12 +3005,12 @@ async function deleteAppRunnable(
toolCallbacks.setToolStatus(toolId, {
content: `Removed runnable "${key}" from app "${path}"`,
result: 'Draft updated'
result: 'Saved to local storage'
})
return JSON.stringify(
{
success: true,
message: `Removed runnable "${key}" from local draft app "${path}".`,
message: `Removed runnable "${key}" from app "${path}" in local storage.`,
item: stored
},
null,
@@ -2719,13 +3096,13 @@ async function discardLocalDraft(
deleteGlobalDraft(workspace, type, path, triggerKind)
toolCallbacks.setToolStatus(toolId, {
content: `Discarded local draft ${type} "${path}"`,
result: 'Draft discarded'
content: `Discarded ${type} "${path}" from local storage`,
result: 'Discarded from local storage'
})
return JSON.stringify(
{
success: true,
message: `Discarded local draft ${type} "${path}". The deployed workspace item was not changed.`,
message: `Discarded the local-storage draft for ${type} "${path}". The deployed workspace item was not changed.`,
type,
path,
triggerKind
@@ -2744,7 +3121,7 @@ async function deployDraft(
},
ctx: WriteDraftCtx
): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const { workspace, toolId, toolCallbacks, sessionId } = ctx
const { type, path, trigger_kind: triggerKind, deployment_message: deploymentMessage } = args
if (type === 'trigger' && !triggerKind) {
@@ -2933,6 +3310,17 @@ async function deployDraft(
deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true })
// Reload the session preview if it's open on the deployed item. Map the
// deploy type to the preview kind — a raw app deploys under 'app' but the
// preview addresses it as 'raw_app'; non-previewable types map to undefined.
const previewKindByType: Partial<Record<WorkspaceItemType, 'script' | 'flow' | 'raw_app'>> = {
script: 'script',
flow: 'flow',
app: 'raw_app'
}
const kind = previewKindByType[type]
if (kind) deployedInSessionHandler?.({ sessionId, kind, path })
toolCallbacks.setToolStatus(toolId, {
content: `Deployed ${type} "${path}"`,
result: 'Deployed',
@@ -3010,9 +3398,11 @@ async function deleteWorkspaceItem(
}
export function prepareGlobalSystemMessage(
customPrompt?: string
customPrompt?: string,
opts?: { previewTools?: boolean }
): ChatCompletionSystemMessageParam {
let content = GLOBAL_SYSTEM_PROMPT
const username = get(userStore)?.username ?? ''
let content = buildGlobalSystemPrompt(username, opts?.previewTools ?? false)
if (customPrompt?.trim()) {
content = `${content}\n\nUSER GIVEN INSTRUCTIONS:\n${customPrompt.trim()}`
}
@@ -19,7 +19,7 @@ import { get } from 'svelte/store'
import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } from './context'
import { workspaceStore } from '$lib/stores'
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
import { findModuleInModules } from '$lib/components/flows/flowTree'
import { findModuleInFlow, findModuleInModules } from '$lib/components/flows/flowTree'
import type { FunctionParameters } from 'openai/resources/shared.mjs'
import { z } from 'zod'
import {
@@ -27,7 +27,9 @@ import {
FlowService,
JobService,
type CompletedJob,
type FlowValue,
type FlowModule,
type ScriptLang,
type Script,
type Flow
} from '$lib/gen'
@@ -1082,6 +1084,164 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
}
}
type FlowStepScriptLoader = (
moduleValue: { path: string; hash?: string },
workspace: string
) => Promise<{ content: string; language: ScriptLang }>
type FlowStepPreviewLoader = (path: string, workspace: string) => Promise<FlowValue | undefined>
export type FlowStepTestRunConfig = {
flowValue: FlowValue
stepId: string
args?: Record<string, any> | null
workspace: string
toolCallbacks: ToolCallbacks
toolId: string
loadScript?: FlowStepScriptLoader
loadFlowPreviewValue?: FlowStepPreviewLoader
}
function normalizeFlowStepArgs(args: Record<string, any> | null | undefined): Record<string, any> {
return args ?? {}
}
function flowStepArgsForModule(moduleId: string, args: Record<string, any>): Record<string, any> {
return moduleId === SPECIAL_MODULE_IDS.PREPROCESSOR
? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args }
: args
}
function getAvailableFlowStepIds(flowValue: FlowValue): string {
return Array.from(
new Set([
...extractAllModules(flowValue.modules ?? []).map((module: FlowModule) => module.id),
...(flowValue.preprocessor_module ? [flowValue.preprocessor_module.id] : []),
...(flowValue.failure_module ? [flowValue.failure_module.id] : [])
])
).join(', ')
}
async function loadDeployedScriptForFlowStep(
moduleValue: { path: string; hash?: string },
workspace: string
): Promise<{ content: string; language: ScriptLang }> {
const script = moduleValue.hash
? await ScriptService.getScriptByHash({ workspace, hash: moduleValue.hash })
: await ScriptService.getScriptByPath({ workspace, path: moduleValue.path })
return { content: script.content, language: script.language }
}
export async function executeFlowStepTestRun({
flowValue,
stepId,
args,
workspace,
toolCallbacks,
toolId,
loadScript = loadDeployedScriptForFlowStep,
loadFlowPreviewValue
}: FlowStepTestRunConfig): Promise<string> {
const targetModule = findModuleInFlow(flowValue, stepId) ?? undefined
if (!targetModule) {
toolCallbacks.setToolStatus(toolId, {
content: `Step "${stepId}" not found in flow`,
error: `Step with id "${stepId}" does not exist in the current flow`
})
throw new Error(
`Step with id "${stepId}" not found in flow. Available steps: ${getAvailableFlowStepIds(flowValue)}`
)
}
const moduleValue = targetModule.value
const stepArgs = normalizeFlowStepArgs(args)
if (moduleValue.type === 'rawscript') {
return executeTestRun({
jobStarter: () =>
JobService.runScriptPreview({
workspace,
requestBody: {
content: moduleValue.content ?? '',
language: moduleValue.language,
args: flowStepArgsForModule(targetModule.id, stepArgs)
}
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Starting test run of step "${stepId}"...`,
contextName: 'script'
})
}
if (moduleValue.type === 'script') {
const script = await loadScript(moduleValue, workspace)
return executeTestRun({
jobStarter: () =>
JobService.runScriptPreview({
workspace,
requestBody: {
path: moduleValue.path,
content: script.content,
language: script.language,
args: flowStepArgsForModule(targetModule.id, stepArgs)
}
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Starting test run of script step "${stepId}"...`,
contextName: 'script'
})
}
if (moduleValue.type === 'flow') {
const previewValue = await loadFlowPreviewValue?.(moduleValue.path, workspace)
if (previewValue) {
return executeTestRun({
jobStarter: () =>
JobService.runFlowPreview({
workspace,
requestBody: {
path: moduleValue.path,
value: previewValue,
args: stepArgs
}
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Starting test run of draft flow step "${stepId}"...`,
contextName: 'flow'
})
}
return executeTestRun({
jobStarter: () =>
JobService.runFlowByPath({
workspace,
path: moduleValue.path,
requestBody: stepArgs
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Starting test run of flow step "${stepId}"...`,
contextName: 'flow'
})
}
toolCallbacks.setToolStatus(toolId, {
content: `Step type "${moduleValue.type}" not supported for testing`,
error: `Cannot test step of type "${moduleValue.type}"`
})
throw new Error(
`Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow`
)
}
function formatLogs(logs: string | undefined): undefined | string {
if (logs && logs.trim()) {
if (logs.length <= MAX_LOG_LENGTH) {
@@ -13,7 +13,11 @@
import type { Flow, Job } from '$lib/gen'
import type { Trigger } from '$lib/components/triggers/utils'
import FlowAIChat from '../copilot/chat/flow/FlowAIChat.svelte'
import { aiChatManager, AIMode } from '../copilot/chat/AIChatManager.svelte'
import {
AIChatManager,
aiChatManager as singletonAiChatManager,
AIMode
} from '../copilot/chat/AIChatManager.svelte'
import type { GraphModuleState } from '../graph'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import type { ModulesTestStates } from '../modulesTest.svelte'
@@ -22,6 +26,8 @@
import { extractAllModules } from '../copilot/chat/shared'
import type { Snippet } from 'svelte'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
const sessionScopedManager = getContext<AIChatManager>('aiChatManager')
const aiChatManager = sessionScopedManager ?? singletonAiChatManager
interface Props {
loading: boolean
@@ -132,14 +138,18 @@
})
onMount(() => {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.FLOW)
if (!sessionScopedManager) {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.FLOW)
}
})
onDestroy(() => {
aiChatManager.flowOptions = undefined
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
if (!sessionScopedManager) {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
}
})
</script>
@@ -867,6 +867,7 @@
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
syncExternalCode
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
@@ -930,6 +931,7 @@
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
syncExternalCode
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
@@ -34,7 +34,7 @@
{#if $workerTags}
{#if $workerTags?.length > 0}
<div class="w-40">
{#if flowStore.val.tag == undefined || isPreprocessor}
{#if flowStore.val.tag == undefined || isPreprocessor || flowStore.val.value?.preserve_step_tags}
<WorkerTagSelect
{noLabel}
{placeholder}
@@ -63,7 +63,14 @@
let displayWorkerTagPicker = $state(false)
run(() => {
flowStore.val.tag ? (displayWorkerTagPicker = true) : null
if (flowStore.val.tag) {
displayWorkerTagPicker = true
} else if (flowStore.val.value.preserve_step_tags) {
// preserve_step_tags has no effect without a flow worker tag; clear it so it
// doesn't linger as invisible state when the tag is removed via any path
// (the picker, the toggle, or the YAML editor).
flowStore.val.value.preserve_step_tags = undefined
}
})
let activeAdvancedOptions = $derived([
@@ -78,6 +85,7 @@
{ name: 'Error Handler Muted', active: Boolean(flowStore.val.ws_error_handler_muted) },
{ name: 'Invisible to Others', active: Boolean(flowStore.val.visible_to_runner_only) },
{ name: 'Shared Directory', active: Boolean(flowStore.val.value.same_worker) },
{ name: 'Preserve Step Tags', active: Boolean(flowStore.val.value.preserve_step_tags) },
{ name: 'Cache Results', active: Boolean(flowStore.val.value.cache_ttl) },
{ name: 'Early Stop', active: Boolean(flowStore.val.value.skip_expr) },
{ name: 'Early Return', active: Boolean(flowStore.val.value.early_return) },
@@ -182,6 +190,8 @@
on:change={() => {
displayWorkerTagPicker = !displayWorkerTagPicker
if (!displayWorkerTagPicker) {
// Clearing the tag triggers the reactive block above, which also
// resets preserve_step_tags so it doesn't linger as invisible state.
flowStore.val.tag = undefined
}
}}
@@ -197,6 +207,25 @@
<div transition:slide={{ duration: 120 }} class="mt-2">
<WorkerTagPicker bind:tag={flowStore.val.tag} popupPlacement="top-end" />
</div>
{#if flowStore.val.tag}
<div
transition:slide={{ duration: 120 }}
class="mt-2 pl-4 border-l border-surface-selected"
>
<Toggle
textClass="font-medium"
size="xs"
bind:checked={flowStore.val.value.preserve_step_tags}
options={{
right: 'Preserve step worker tags',
rightTooltip:
'By default the flow worker tag above is propagated to and overrides every step, script and nested sub-flow. Enable this to instead let steps that declare their own worker tag run on it. Steps without their own tag still inherit the flow tag.',
rightDocumentationLink:
'https://www.windmill.dev/docs/core_concepts/worker_groups'
}}
/>
</div>
{/if}
{/if}
</div>
@@ -200,6 +200,9 @@
markRemovedAsShadowed?: boolean
controlsPosition?: 'top' | 'bottom'
outerDivClass?: string
/** Fires when the computed graph height changes. Diff views can use
* this to equalize heights of side-by-side graphs. */
onHeight?: (height: number) => void
}
let {
@@ -273,7 +276,8 @@
onMoveMultiple = undefined,
movingIds = undefined,
controlsPosition = 'top',
outerDivClass = ''
outerDivClass = '',
onHeight = undefined
}: Props = $props()
// Initialize note manager with fine-grained reactivity
@@ -759,6 +763,7 @@
const computed = maxBottom - minY
height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight)
}
onHeight?.(height)
}
$effect(() => {
@@ -0,0 +1,30 @@
import type { Plugin } from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import { rehypeGithubAlerts } from 'rehype-github-alerts'
/**
* Shared plugin chain for rendering user-supplied Markdown (script/flow/resource
* descriptions, flow-graph notes, the App "Markdown" component, ...).
*
* Order matters and is security-sensitive:
* 1. `gfmPlugin` — GitHub-flavored Markdown.
* 2. `rehypeRaw` — re-parses embedded raw HTML into live hast nodes.
* 3. `rehypeSanitize` — strips dangerous nodes (`<script>`, `<iframe>`,
* `<svg><script>`, `on*` handlers, `javascript:` URLs)
* from the raw-parsed tree. Without this stage the raw
* HTML reaches the DOM and executes (stored XSS).
* 4. `rehypeGithubAlerts` — runs AFTER sanitize so the trusted alert markup it
* injects (including inline SVG icons) is preserved
* without having to allowlist SVG for user input.
*
* Any Markdown sink that renders user input MUST use this chain rather than
* assembling its own `rehypeRaw` pipeline.
*/
export const markdownPlugins: Plugin[] = [
gfmPlugin(),
{ rehypePlugin: [rehypeRaw] },
{ rehypePlugin: [rehypeSanitize] },
{ rehypePlugin: [rehypeGithubAlerts] }
]
@@ -66,6 +66,9 @@
}
| undefined
diffDrawer?: DiffDrawer | undefined
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
/** Fired after a successful deploy; the session preview reloads on it. */
onDeploy?: (e: { path: string }) => void
/** Initial collapsed state for the file/runnable sidebar. The user's
* toggled preference is persisted under `sidebarStorageKey`; this prop
* only seeds the very first open. */
@@ -75,6 +78,14 @@
* preference. */
sidebarStorageKey?: string
liveEditorDraftStoragePath?: string
/** Initial value for the "Split with Preview" tab-bar toggle. Defaults
* to `true` (split mode, preview always pinned to the right). Set
* `false` when the editor mounts inside a context that wants single-
* view by default with the Preview tab selected — e.g. session
* previews, where the editor pane is already narrow. The user can
* still toggle the mode after mount; this prop only seeds the
* initial state. */
defaultSplitWithPreview?: boolean
}
let {
@@ -88,9 +99,12 @@
newPath = undefined,
savedApp = $bindable(undefined),
diffDrawer = undefined,
onNavigate,
onDeploy = undefined,
defaultSidebarCollapsed = false,
sidebarStorageKey = 'raw-app-sidebar-collapsed',
liveEditorDraftStoragePath = undefined
liveEditorDraftStoragePath = undefined,
defaultSplitWithPreview = true
}: Props = $props()
export const version: number | undefined = undefined
@@ -225,7 +239,9 @@
}
let tabs: TabItem[] = $state([previewTab])
let activeTabId: string = $state(PREVIEW_TAB_ID)
let splitWithPreview: boolean = $state(true)
// Seed from the prop, then own the state locally so the user's toggle
// after mount sticks even if the prop reference changes.
let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview))
const activeTabKind = $derived<'file' | 'runnable' | 'preview'>(
activeTabId === PREVIEW_TAB_ID
? 'preview'
@@ -255,11 +271,23 @@
const showRunnable = $derived(activeTabKind === 'runnable')
// Mount the UI Builder iframe the first time a file is shown (paneA has
// width then; mounting it at 0-width breaks the VS Code workbench), and
// keep it mounted so tab switches don't reload it.
// keep it mounted so tab switches don't reload it. Mount it as soon as
// either pane needs it: `showSource` for the source-editor view, OR the
// preview tab is active — the Preview iframe is fed by `preview`
// postMessages bundled by the UI Builder iframe, so it needs to be
// mounted even when the user opens the editor straight on Preview (e.g.
// session previews seeded with `defaultSplitWithPreview=false`).
let iframeShouldMount = $state(false)
$effect(() => {
if (showSource) iframeShouldMount = true
if (showSource || activeTabKind === 'preview') iframeShouldMount = true
})
// Width of the editor area (both inner panes). The UI Builder iframe is
// pre-mounted while it's the inactive tab so the editor is ready instantly;
// but the VS Code workbench inside crashes if it boots at 0 size. So while
// inactive we keep the iframe at this real width and hide it with
// `visibility` instead of collapsing it — Monaco boots correctly and
// revealing a file is just an unhide (no reload, no relayout, no latency).
let editorAreaWidth = $state(0)
// Inner pane sizes are a pure function of mode + active tab → derived.
// `paneARatio` is the user's last manual split drag (set by rememberPaneDrag).
@@ -994,7 +1022,11 @@
ensureFileTab(selectedDocument)
// Don't auto-activate — the user's tab choice wins.
// But if no file tab is currently active, fall in line.
if (activeTabKind === 'preview' && tabs.length === 2) {
// Skip this auto-activation in single-view-with-preview
// mode (the caller seeded `defaultSplitWithPreview=false`
// because Preview is the intended starting tab); the
// iframe's first setActiveDocument shouldn't fight that.
if (splitWithPreview && activeTabKind === 'preview' && tabs.length === 2) {
activateTab(id)
}
}
@@ -1158,9 +1190,14 @@
})
})
// Open a default file on mount (boots the iframe; avoids a blank preview).
// Layout isn't persisted — each open starts fresh in split mode.
// Open a default file on mount (boots the iframe in split mode and gives
// the user something to edit on the left). When the caller seeded
// `defaultSplitWithPreview=false` we instead want the Preview tab as the
// only-visible / active surface, so skip the file-tab activation — the
// iframe still boots via `populateFiles`/`setFilesInIframe` even without
// a selected document.
onMount(() => {
if (!splitWithPreview) return
if (tabs.length === 1) {
const def = pickDefaultFile(files)
if (def) activateTab(ensureFileTab(def))
@@ -1332,6 +1369,8 @@
{data}
{runnables}
{getBundle}
{onNavigate}
{onDeploy}
canUndo={historyManager.canUndo}
canRedo={historyManager.canRedo}
onUndo={handleUndo}
@@ -1415,6 +1454,7 @@
Preview previously hid every tab.
-->
<div
bind:clientWidth={editorAreaWidth}
class="h-full w-full min-h-0 {splitWithPreview && activeTabKind !== 'preview'
? 'tabs-content-split'
: 'tabs-content-single'}"
@@ -1448,7 +1488,21 @@
{/snippet}
</DraggableTabs>
<div class="flex-1 min-h-0 relative">
<div class="absolute inset-0" style="display: {showSource ? 'block' : 'none'}">
<!--
Keep the UI Builder iframe mounted at a real (non-zero) size even
when it isn't the active tab: a hidden 0×0 mount crashes the VS
Code workbench's layout ("Unable to figure out browser width and
height") and wedges it on "Loading editor" with no recovery. While
inactive we size it to the editor area's width and hide it with
`visibility` (not `display`), so Monaco boots correctly and
revealing a file is an instant unhide.
-->
<div
class="absolute inset-0"
style={showSource
? ''
: `right: auto; width: ${editorAreaWidth || 800}px; visibility: hidden; pointer-events: none;`}
>
{#if iframeShouldMount}
<iframe
bind:this={iframe}
@@ -2,7 +2,7 @@
import { Drawer, DrawerContent } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import { isMac, userPathPrefix } from '$lib/utils'
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
import { editPathFor } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
import { AppService, type Policy } from '$lib/gen'
@@ -58,11 +58,20 @@
// session-pane PR lands. Untyped getContext to avoid coupling to the
// AIChatManager class export (which lives on the chat-visuals PR).
const inSessionPane = !!getContext('aiChatManager')
// In a session pane the editor does NOT own the localStorage draft — the
// session runtime does, keyed by the session's (fork) workspace. So the
// `if (!inSessionPane) UserDraft.remove(...)` guards below skip the editor's
// own removal (it would target the wrong, main-`$workspaceStore` key). The
// session-side equivalent is RawAppEditorView's `onDeploy` →
// `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads
// the preview to the deployed version.
import { AIBtnClasses } from '../copilot/chat/AIButtonStyle'
import type { RawAppData } from './dataTableRefUtils'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
import { buildForkEditUrl } from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import UnsavedConfirmationModal from '../common/confirmationModal/UnsavedConfirmationModal.svelte'
import type { SavedAndModifiedValue } from '../common/confirmationModal/unsavedTypes'
// async function hash(message) {
// try {
@@ -115,7 +124,10 @@
onOpenYamlEditor?: () => void
sidebarCollapsed?: boolean
onToggleSidebar?: () => void
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
liveEditorDraftStoragePath?: string
// Fired after a successful deploy; lets the session preview reload.
onDeploy?: (e: { path: string }) => void
}
let {
@@ -140,13 +152,15 @@
onOpenYamlEditor = undefined,
sidebarCollapsed = false,
onToggleSidebar = undefined,
liveEditorDraftStoragePath = undefined
onNavigate = undefined,
liveEditorDraftStoragePath = undefined,
onDeploy = undefined
}: Props = $props()
let newEditedPath = $state(
untrack(() =>
newApp
? userPathPrefix($userStore?.username) + random_adj() + '_app'
? newPath || userPathPrefix($userStore?.username) + random_adj() + '_app'
: newPath || appPath || ''
)
)
@@ -269,8 +283,9 @@
}
closeSaveDrawer()
sendUserToast('App deployed successfully')
UserDraft.remove('raw_app', path)
if (!inSessionPane) UserDraft.remove('raw_app', path)
dispatch('savedNewAppPath', path)
onDeploy?.({ path })
} catch (e) {
sendUserToast(`Error creating app: ${e.body ?? e.message}`, true)
}
@@ -364,7 +379,6 @@
css
}
})
invalidatePicker($workspaceStore!, 'app')
invalidateWorkspacePaths($workspaceStore!)
savedApp = {
summary: summary,
@@ -381,10 +395,11 @@
closeSaveDrawer()
sendUserToast('App deployed successfully')
UserDraft.remove('raw_app', appPath)
if (!inSessionPane) UserDraft.remove('raw_app', appPath)
if (appPath !== npath) {
dispatch('savedNewAppPath', npath)
}
onDeploy?.({ path: npath })
}
async function setPublishState() {
@@ -513,6 +528,18 @@
let jobsDrawerOpen = $state(false)
function getInitialAndModifiedValues(): SavedAndModifiedValue {
return {
savedValue: savedApp,
modifiedValue: {
summary: summary,
value: app,
path: newEditedPath || savedApp?.path,
policy,
custom_path: customPath
}
}
}
let app = $derived(files ? { runnables: runnables, files, data } : undefined)
$effect(() => {
@@ -520,6 +547,14 @@
})
</script>
<!-- Inside a session pane the editor's content is continuously persisted to the
UserDraft (localStorage), so tearing the editor down on navigation loses
nothing — skip the unsaved-changes prompt. The standalone /apps_raw editor
keeps it. -->
{#if !inSessionPane}
<UnsavedConfirmationModal {diffDrawer} {getInitialAndModifiedValues} />
{/if}
<DeployOverrideConfirmationModal
{deployedBy}
{confirmCallback}
@@ -564,7 +599,7 @@
button: {
text: 'Looks good, deploy',
onClick: () => {
if (appPath == '') {
if (newApp || appPath == '') {
createApp(newEditedPath)
} else {
handleUpdateApp(newEditedPath)
@@ -585,7 +620,7 @@
startIcon={{ icon: Save }}
disabled={pathError != '' || customPathError != '' || app == undefined}
on:click={() => {
if (appPath == '') {
if (newApp || appPath == '') {
createApp(newEditedPath)
} else {
handleUpdateApp(newEditedPath)
@@ -604,6 +639,7 @@
{appPath}
{onLatest}
{savedApp}
rawApp
bind:summary
bind:customPath
bind:deploymentMsg
@@ -691,7 +727,7 @@
savedPath={appPath || newPath || undefined}
kind="app"
raw_app
onNavigate={(item) => goto(editPathFor(item))}
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
/>
<div></div>
</div>
+12 -1
View File
@@ -36,9 +36,20 @@ export interface ScriptBuilderProps {
savedPrimarySchedule?: ScheduleTrigger | undefined
functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined
children?: import('svelte').Snippet
onDeploy?: (e: { path: string; hash: string }) => void
// Fires on every successful deploy. `stay` is true for "Deploy & Stay here"
// and for lib scripts (the editor stays in place rather than navigating to
// the deployed item) — consumers should skip post-deploy navigation when set.
onDeploy?: (e: { path: string; hash: string; stay: boolean }) => void
onDeployError?: (e: { path: string; error: any }) => void
onHistoryRestore?: () => void
onSeeDetails?: (e: { path: string }) => void
onNavigate?: (item: WorkspaceItem) => void
// Forwarded to the underlying ScriptEditor. When true, the right-hand
// test/run pane opens collapsed. Used by the session preview.
initialTestPanelCollapsed?: boolean
// Treat the path as already chosen (seeds the path "dirty" flag) so the
// summary→path auto-slug for new scripts (initialPath == '') doesn't
// overwrite it. Used by the session preview, which opens AI-created scripts
// as new but with a path the AI already assigned.
initialPathChosen?: boolean
}
@@ -0,0 +1,181 @@
<script lang="ts">
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import type { Flow } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { flowDraftSig } from './flowDraftSig'
import { initFlowState } from '$lib/components/flows/flowState'
import SessionItemNotFound from './SessionItemNotFound.svelte'
import { sendUserToast } from '$lib/toast'
let {
runtime,
path,
workspaceId,
onNavigate,
isActiveSession = true
}: {
runtime: SessionRuntime
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
/**
* Only the visible session should claim the workspace's live-editor
* slot — without this, a hidden warm-mounted session can overwrite the
* active session's UserDraft live-editor target (one slot per
* (workspace, kind)), so chat actions like discard / "the open editor"
* resolve to the wrong session.
*/
isActiveSession?: boolean
} = $props()
let selectedId = $state('settings-metadata')
let diffDrawer: DiffDrawer | undefined = $state()
$effect(() => {
if (workspaceId && path) {
untrack(() => runtime.loadFlow(workspaceId, path))
}
})
// In a session pane, "restore" just reloads from the current state — the
// session target stays put. The Diff drawer's primary use here is viewing
// the diff; restore is best-effort.
async function restoreFromCurrentTarget() {
diffDrawer?.closeDrawer()
await runtime.loadFlow(workspaceId, path)
}
// Mark this editor as the "live editor" for the session's workspace so
// the chat's `isLiveDraft` hint and `discard_local_draft` tool resolve to
// this path. Same registration the regular /flows/edit page does on
// mount, scoped to the session's (forked) workspace.
// Gated on `isActiveSession`: warm-but-hidden session editors must not
// claim the workspace's single live-editor slot, else chat actions on the
// visible session resolve to the hidden one's path.
$effect(() => {
if (!workspaceId || !path) return
if (!isActiveSession) return
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: 'flow',
storagePath: path,
effectivePath: runtime.flowStore.val?.path ?? path
})
return () =>
UserDraft.clearLiveEditorDraft('flow', { workspace: workspaceId, storagePath: path })
})
// Bidirectional sync between this preview and `UserDraft<Flow>`.
// We hold a *live* handle (useMany) rather than reading via the static
// `UserDraft.get`. The handle materializes UserDraft's shared reactive
// `$state` cell for (workspace, 'flow', path), and that cell is what lets
// the chat's writes (UserDraft.save, from write_flow / patch_flow_json /
// set_flow_module_code) reach this preview. Without a live entry those
// writes only touch localStorage and the inbound effect below never
// re-fires. A reactive getter is used (not `use()`) because switching
// open_preview to another flow swaps `path` without remounting this view,
// so the handle must re-acquire.
//
// One-way-reactive discipline: inbound tracks only the handle's draft,
// outbound tracks only `flowStore.val`; the read on the "other side"
// inside each effect goes through `untrack()`. Without that asymmetry, a
// user keystroke would re-fire the inbound effect with the pre-keystroke
// stored value and revert the edit.
const draftHandles = UserDraft.useMany<Flow>(() => [
{ itemKind: 'flow', path, workspace: workspaceId }
])
let lastInboundSig: string | undefined = $state(undefined)
// Store → editor. Re-runs when the handle's draft changes (AI write from
// this session's chat or another session). flowStore reads are untracked
// so the editor's own mutations don't refire this effect.
$effect(() => {
if (!workspaceId || !path) return
const incoming = draftHandles[0]?.draft
if (!incoming) return
const sig = flowDraftSig(incoming)
untrack(() => {
if (runtime.loadedPath !== path) return
if (sig === lastInboundSig) return
const current = runtime.flowStore.val
if (!current) return
lastInboundSig = sig
runtime.flowStore.val = {
...current,
value: incoming.value,
schema: incoming.schema ?? current.schema,
summary: incoming.summary ?? current.summary
}
// flowStateStore is keyed by module_id; after an AI write the set
// of module ids may differ, so rebuild the UI state. This wipes
// per-module test args / preview output for the new flow — a
// known v1 trade-off, see the plan's caveats.
void initFlowState(runtime.flowStore.val, runtime.flowStateStore)
})
})
// Editor → store. Re-runs on any deep mutation of flowStore.val
// (modules, schema, module bodies). The store read is untracked.
// Debounced 150ms so a typing burst inside an inline rawscript editor
// results in one serialise-and-write instead of one per keystroke.
let outboundTimer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
if (!workspaceId || !path) return
if (runtime.loadedPath !== path) return
const flow = runtime.flowStore.val
if (!flow) return
const sig = flowDraftSig(flow)
if (sig === lastInboundSig) return
if (outboundTimer) clearTimeout(outboundTimer)
outboundTimer = setTimeout(() => {
untrack(() => {
const current = UserDraft.get<Flow>('flow', path, { workspace: workspaceId })
if (current && flowDraftSig(current) === sig) return
UserDraft.save('flow', path, flow, { workspace: workspaceId })
})
}, 150)
return () => {
if (outboundTimer) clearTimeout(outboundTimer)
}
})
</script>
{#if runtime.savedFlow.val}
<DiffDrawer
bind:this={diffDrawer}
restoreDeployed={restoreFromCurrentTarget}
restoreDraft={restoreFromCurrentTarget}
isFlow
/>
{/if}
{#if runtime.loadingFlow && !runtime.loadedPath}
<div class="p-4 text-secondary text-sm">Loading flow {path}…</div>
{:else if runtime.notFound && !runtime.loadedPath}
<SessionItemNotFound kind="flow" {path} {onNavigate} />
{:else}
<!-- customUi hides the in-editor "Flow AI Chat" button: the session already
has its own AI chat in the left pane, so the toggle is redundant here. -->
<FlowBuilder
flowStore={runtime.flowStore}
flowStateStore={runtime.flowStateStore}
initialPath={path}
newFlow={!runtime.savedFlow.val}
{selectedId}
loading={runtime.loadingFlow && !runtime.loadedPath}
bind:savedFlow={runtime.savedFlow.val}
{diffDrawer}
{onNavigate}
customUi={{ topBar: { aiBuilder: false } }}
onSaveDraft={() => runtime.scheduleForkComparisonRefresh()}
onDeploy={() => {
// FlowBuilder has no deploy toast and the session stays put, so toast
// here, then sync the preview to deployed (pulls the new locks + version_id).
sendUserToast('Deployed')
runtime.syncPreviewWithDeployed(workspaceId, 'flow', path)
}}
/>
{/if}
@@ -0,0 +1,732 @@
<script lang="ts">
import { parentFolderKey } from './forkDiffNav'
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import {
AlertTriangle,
ArrowRight,
ChevronDown,
ChevronRight,
Folder,
GitFork,
GitMerge,
Loader2,
Minus,
Pencil,
Plus,
User
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
import WorkspaceItemDiffViewer from '$lib/components/WorkspaceItemDiffViewer.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { DiffIcon, ExternalLink, SquareSplitHorizontal } from 'lucide-svelte'
import { WorkspaceService, type WorkspaceComparison, type WorkspaceItemDiff } from '$lib/gen'
import { getItemValue } from '$lib/utils_workspace_deploy'
import { userWorkspaces } from '$lib/stores'
import { editUrlFor as buildEditUrl } from './forkEditUrl'
let {
forkWorkspaceId,
parentWorkspaceId
}: { forkWorkspaceId: string; parentWorkspaceId: string } = $props()
let drawer: Drawer | undefined = $state(undefined)
let comparison: WorkspaceComparison | undefined = $state(undefined)
let loading = $state(false)
let error: string | undefined = $state(undefined)
let searchQuery = $state('')
let diffStyle = $state<'sbs' | 'inline'>('sbs')
const inlineDiff = $derived(diffStyle === 'inline')
const forkWs = $derived($userWorkspaces.find((w) => w.id === forkWorkspaceId))
const parentWs = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
export function open() {
drawer?.openDrawer()
void fetchComparison()
// Pull focus into the filter input so keyboard nav works without an
// extra click — drawer transition needs a tick first.
setTimeout(() => searchInputEl?.focus(), 50)
}
function openReview() {
goto(`/forks/compare?workspace_id=${encodeURIComponent(forkWorkspaceId)}`)
}
async function fetchComparison() {
loading = true
error = undefined
// Per-item raw diffs are cached for the lifetime of the drawer.
// `loadDiffFor` early-returns on cache hit, so without this reset an
// edit-then-reopen would show fresh summary/counts but stale expanded
// raw content for any item the user had already drilled into.
loadedDiffs = {}
summaries = {}
try {
comparison = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: forkWorkspaceId
})
// Diffs are expanded by default, so eagerly populate each row's
// content. Each loadDiffFor is idempotent and per-item, so
// rendering proceeds as values arrive.
if (comparison) {
for (const d of comparison.diffs) {
void loadDiffFor(d)
}
}
} catch (e) {
console.error('Fork diff: comparison failed', e)
error = `Failed to load comparison: ${e}`
comparison = undefined
} finally {
loading = false
}
}
type DiffStatus = 'added' | 'removed' | 'modified' | 'conflict'
function statusOf(d: WorkspaceItemDiff): DiffStatus {
if (d.exists_in_fork && !d.exists_in_source) return 'added'
if (!d.exists_in_fork && d.exists_in_source) return 'removed'
if (d.ahead > 0 && d.behind > 0) return 'conflict'
return 'modified'
}
function itemKey(d: WorkspaceItemDiff): string {
return `${d.kind}/${d.path}`
}
// Editor URL for a diff row, scoped to the fork workspace.
function editUrlFor(d: WorkspaceItemDiff): string | undefined {
return buildEditUrl(d, forkWorkspaceId)
}
const KIND_LABELS: Record<string, string> = {
script: 'Script',
flow: 'Flow',
app: 'App',
raw_app: 'Raw app',
resource: 'Resource',
variable: 'Variable',
resource_type: 'Resource type',
folder: 'Folder',
schedule: 'Schedule',
http_trigger: 'HTTP route',
websocket_trigger: 'Websocket trigger',
kafka_trigger: 'Kafka trigger',
nats_trigger: 'NATS trigger',
postgres_trigger: 'Postgres trigger',
mqtt_trigger: 'MQTT trigger',
sqs_trigger: 'SQS trigger',
gcp_trigger: 'GCP trigger',
azure_trigger: 'Azure trigger',
email_trigger: 'Email trigger'
}
// Lazily-loaded raw values per item, keyed by itemKey. Shaping (content
// vs metadata, YAML, lang detection) is owned by WorkspaceItemDiffViewer.
type LoadedDiff = {
state: 'loading' | 'ready' | 'error'
error?: string
parentRaw?: unknown
forkRaw?: unknown
}
let loadedDiffs: Record<string, LoadedDiff> = $state({})
// Per-item summary, derived from the fetched raw value so the tree on
// the left can show summary above the mono path (matches the picker).
let summaries: Record<string, string | undefined> = $state({})
async function loadDiffFor(d: WorkspaceItemDiff) {
const key = itemKey(d)
if (loadedDiffs[key]) return
loadedDiffs[key] = { state: 'loading' }
try {
// Source (parent) — empty for items only in fork. Fork — empty for
// items only in source. We swallow per-side errors so an "added"
// item still renders cleanly against an empty original.
const [parentRaw, forkRaw] = await Promise.all([
d.exists_in_source
? getItemValue(d.kind, d.path, parentWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined),
d.exists_in_fork
? getItemValue(d.kind, d.path, forkWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined)
])
loadedDiffs[key] = { state: 'ready', parentRaw, forkRaw }
// Prefer the fork's summary (the "current" side); fall back to parent.
const summary =
(forkRaw && typeof forkRaw === 'object' && (forkRaw as any).summary) ||
(parentRaw && typeof parentRaw === 'object' && (parentRaw as any).summary) ||
undefined
if (typeof summary === 'string' && summary.trim().length > 0) {
summaries[key] = summary
}
} catch (e) {
console.error('Fork diff: loadDiff failed', d, e)
loadedDiffs[key] = {
state: 'error',
error: String(e)
}
}
}
function onDetailsToggle(d: WorkspaceItemDiff, e: Event) {
const target = e.currentTarget as HTMLDetailsElement | null
if (target?.open) {
void loadDiffFor(d)
}
}
function statusBadgeColor(s: DiffStatus): 'green' | 'red' | 'orange' | 'blue' {
if (s === 'added') return 'green'
if (s === 'removed') return 'red'
if (s === 'conflict') return 'orange'
return 'blue'
}
const statusIcons = {
added: Plus,
removed: Minus,
modified: Pencil,
conflict: AlertTriangle
}
// File tree built from the diff paths. Top-level rows mirror
// WorkspaceItemDrillPicker: `f/foo` and `u/alice` collapse to a single
// "scope" row, then deeper segments split per `/`. Leaves carry their
// diff entry.
type FolderNode = {
type: 'folder'
name: string
fullPath: string
isScope: boolean
children: TreeNode[]
}
type FileNode = { type: 'file'; name: string; diff: WorkspaceItemDiff }
type TreeNode = FolderNode | FileNode
function buildTree(diffs: WorkspaceItemDiff[]): FolderNode {
const root: FolderNode = {
type: 'folder',
name: '',
fullPath: '',
isScope: false,
children: []
}
const folderCache = new Map<string, FolderNode>()
for (const d of diffs) {
const parts = d.path.split('/')
if (parts.length < 2) {
root.children.push({ type: 'file', name: d.path, diff: d })
continue
}
const scopeKey = parts.slice(0, 2).join('/')
let scope = folderCache.get(scopeKey)
if (!scope) {
scope = {
type: 'folder',
name: scopeKey,
fullPath: scopeKey,
isScope: true,
children: []
}
folderCache.set(scopeKey, scope)
root.children.push(scope)
}
if (parts.length === 2) {
scope.children.push({ type: 'file', name: scopeKey, diff: d })
continue
}
const rest = parts.slice(2)
let parent = scope
let folderKey = scopeKey
for (let i = 0; i < rest.length - 1; i++) {
folderKey = `${folderKey}/${rest[i]}`
let folder = folderCache.get(folderKey)
if (!folder) {
folder = {
type: 'folder',
name: rest[i],
fullPath: folderKey,
isScope: false,
children: []
}
folderCache.set(folderKey, folder)
parent.children.push(folder)
}
parent = folder
}
parent.children.push({ type: 'file', name: rest[rest.length - 1], diff: d })
}
const sortRec = (n: FolderNode) => {
n.children.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return a.name.localeCompare(b.name)
})
for (const c of n.children) if (c.type === 'folder') sortRec(c)
}
sortRec(root)
return root
}
// Searchable string per diff: path + summary (when loaded) + kind label.
// SearchItems' uFuzzy runs fuzzy matching over these. Reads `summaries`
// directly so the index re-derives as summaries trickle in from
// loadDiffFor.
function searchableText(d: WorkspaceItemDiff): string {
const parts = [d.path, KIND_LABELS[d.kind] ?? d.kind]
const s = summaries[itemKey(d)]
if (s) parts.push(s)
return parts.join(' ')
}
let searchedDiffs: (WorkspaceItemDiff & { marked?: string })[] | undefined = $state(undefined)
// Empty query bypasses SearchItems entirely so we don't wait a tick for
// the async filter to run after open.
const filteredDiffs = $derived.by(() => {
const c = comparison
if (!c) return [] as WorkspaceItemDiff[]
const q = searchQuery.trim()
if (!q) return c.diffs
return (searchedDiffs ?? []) as WorkspaceItemDiff[]
})
const tree = $derived.by(() => {
const c = comparison
return c ? buildTree(filteredDiffs) : undefined
})
function rowId(d: WorkspaceItemDiff): string {
return `fork-diff-${itemKey(d)}`
}
function scrollToDiff(d: WorkspaceItemDiff) {
const el = document.getElementById(rowId(d)) as HTMLDetailsElement | null
if (!el) return
el.open = true
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// ── Keyboard nav (matches WorkspaceItemDrillPicker) ─────────────────────
// Per-folder open/closed state. Defaults to open; user toggles via the
// <details> summary or via Enter when a folder row is highlighted.
let folderOpen: Record<string, boolean> = $state({})
function isFolderOpen(key: string): boolean {
return folderOpen[key] ?? true
}
function folderKey(node: FolderNode): string {
return `folder:${node.fullPath}`
}
type NavEntry =
| { type: 'folder'; key: string; node: FolderNode }
| { type: 'file'; key: string; diff: WorkspaceItemDiff }
function flattenVisible(node: FolderNode): NavEntry[] {
const out: NavEntry[] = []
const walk = (n: TreeNode) => {
if (n.type === 'file') {
out.push({ type: 'file', key: itemKey(n.diff), diff: n.diff })
return
}
const fkey = folderKey(n)
out.push({ type: 'folder', key: fkey, node: n })
if (isFolderOpen(fkey)) for (const c of n.children) walk(c)
}
for (const c of node.children) walk(c)
return out
}
const navEntries = $derived(tree ? flattenVisible(tree) : [])
const navKeys = $derived(navEntries.map((e) => e.key))
const entryByKey = $derived(new Map(navEntries.map((e) => [e.key, e])))
let highlightedKey: string | undefined = $state(undefined)
let mouseActive = $state(false)
let searchInputEl: HTMLInputElement | undefined = $state()
let sidebarRoot: HTMLElement | undefined = $state()
$effect(() => {
if (navKeys.length === 0) return
if (!highlightedKey || !navKeys.includes(highlightedKey)) {
highlightedKey = navKeys[0]
}
})
function scrollHighlightIntoView() {
if (!sidebarRoot || !highlightedKey) return
const el = sidebarRoot.querySelector<HTMLElement>(
`[data-nav-key="${CSS.escape(highlightedKey)}"]`
)
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
function moveHighlight(delta: 1 | -1) {
if (navKeys.length === 0) return
const cur = navKeys.indexOf(highlightedKey ?? '')
const next = cur < 0 ? 0 : (cur + delta + navKeys.length) % navKeys.length
highlightedKey = navKeys[next]
mouseActive = false
requestAnimationFrame(scrollHighlightIntoView)
}
function setHoverHighlight(key: string) {
// Same defense as the picker: ignore until the user actually moves the
// mouse, so a cursor parked over a row doesn't clobber the keyboard
// highlight when the layout shifts.
if (mouseActive) highlightedKey = key
}
function activateHighlighted() {
if (!highlightedKey) return
const entry = entryByKey.get(highlightedKey)
if (!entry) return
if (entry.type === 'file') {
scrollToDiff(entry.diff)
} else {
folderOpen[entry.key] = !isFolderOpen(entry.key)
}
}
// The folder containing an entry, as a folder key (or undefined if the
// entry is at the top scope and has no parent folder). Pure logic lives in
// forkDiffNav.parentFolderKey (unit-tested).
function parentFolderKeyFor(entry: NavEntry): string | undefined {
const path = entry.type === 'folder' ? entry.node.fullPath : entry.diff.path
return parentFolderKey(entry.type, path)
}
function firstChildKey(node: FolderNode): string | undefined {
const c = node.children[0]
if (!c) return undefined
return c.type === 'folder' ? folderKey(c) : itemKey(c.diff)
}
function selectKey(key: string) {
highlightedKey = key
mouseActive = false
requestAnimationFrame(scrollHighlightIntoView)
}
function handleSearchKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
moveHighlight(1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
moveHighlight(-1)
} else if (e.key === 'Enter') {
e.preventDefault()
activateHighlighted()
} else if (e.key === 'ArrowRight') {
// On a closed folder: open it. On an open folder: jump to its first
// child (folder or file). On a file: no-op.
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
if (!entry || entry.type !== 'folder') return
if (!isFolderOpen(entry.key)) {
e.preventDefault()
folderOpen[entry.key] = true
return
}
const child = firstChildKey(entry.node)
if (child) {
e.preventDefault()
selectKey(child)
}
} else if (e.key === 'ArrowLeft') {
// On an open folder: collapse it. On a closed folder (or a file):
// jump to the parent folder.
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
if (!entry) return
if (entry.type === 'folder' && isFolderOpen(entry.key)) {
e.preventDefault()
folderOpen[entry.key] = false
return
}
const parent = parentFolderKeyFor(entry)
if (parent && entryByKey.has(parent)) {
e.preventDefault()
selectKey(parent)
}
}
}
</script>
<SearchItems
filter={searchQuery}
items={comparison?.diffs ?? []}
bind:filteredItems={searchedDiffs}
f={(d: WorkspaceItemDiff) => searchableText(d)}
/>
{#snippet renderTreeNode(node: TreeNode, depth: number)}
{#if node.type === 'folder'}
{@const isUserScope = node.isScope && node.name.startsWith('u/')}
{@const fkey = folderKey(node)}
{@const open = isFolderOpen(fkey)}
{@const isHl = fkey === highlightedKey}
<details
{open}
ontoggle={(e) => (folderOpen[fkey] = (e.currentTarget as HTMLDetailsElement).open)}
class="select-none"
>
<summary
role="option"
aria-selected={isHl}
data-nav-key={fkey}
onmouseenter={() => setHoverHighlight(fkey)}
class="flex items-center gap-1.5 px-3 py-1 cursor-pointer text-xs font-medium font-mono text-emphasis hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl
? 'bg-surface-hover'
: ''}"
style="padding-left: {depth * 12 + 8}px"
>
<ChevronDown class="w-3 h-3 shrink-0 text-tertiary tree-chevron-open" />
<ChevronRight class="w-3 h-3 shrink-0 text-tertiary tree-chevron-closed" />
{#if isUserScope}
<User size={12} class="shrink-0 text-tertiary" />
{:else}
<Folder size={12} class="shrink-0 text-tertiary" />
{/if}
<span class="truncate" title={node.name}>{node.name}</span>
</summary>
<div>
{#each node.children as child}
{@render renderTreeNode(child, depth + 1)}
{/each}
</div>
</details>
{:else}
{@const status = statusOf(node.diff)}
{@const key = itemKey(node.diff)}
<WorkspaceItemRow
kind={node.diff.kind}
summary={summaries[key]}
secondary={node.name}
highlighted={key === highlightedKey}
navKey={key}
indent={depth * 12 + 20}
title={node.diff.path}
onclick={() => {
highlightedKey = key
scrollToDiff(node.diff)
}}
onmouseenter={() => setHoverHighlight(key)}
>
{#snippet extras()}
<span
class="w-1.5 h-1.5 rounded-full shrink-0 {status === 'added'
? 'bg-green-500'
: status === 'removed'
? 'bg-red-500'
: status === 'conflict'
? 'bg-orange-500'
: 'bg-blue-500'}"
></span>
{/snippet}
</WorkspaceItemRow>
{/if}
{/snippet}
<Drawer bind:this={drawer} size="1200px">
<DrawerContent
title="Fork changes"
on:close={() => drawer?.closeDrawer()}
documentationLink={undefined}
noPadding
overflow_y={false}
>
{#snippet titleExtra()}
<div class="flex items-center gap-2 text-xs text-secondary">
<GitFork class="w-3.5 h-3.5 shrink-0" />
<span class="font-medium truncate">{forkWs?.name ?? forkWorkspaceId}</span>
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
<span class="font-medium truncate">{parentWs?.name ?? parentWorkspaceId}</span>
{#if comparison}
<Badge color="transparent" class="ml-2">
{comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''}
</Badge>
{#if comparison.summary.conflicts > 0}
<Badge color="orange">
<AlertTriangle class="w-3 h-3 inline mr-1" />
{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''}
</Badge>
{/if}
{/if}
</div>
{/snippet}
{#snippet actions()}
<ToggleButtonGroup bind:selected={diffStyle} noWFull>
{#snippet children({ item })}
<ToggleButton
value="sbs"
label="Side-by-side"
icon={SquareSplitHorizontal}
tooltip="Side-by-side diff"
iconOnly
{item}
/>
<ToggleButton
value="inline"
label="Unified"
icon={DiffIcon}
tooltip="Unified diff"
iconOnly
{item}
/>
{/snippet}
</ToggleButtonGroup>
<Button variant="accent" unifiedSize="sm" startIcon={{ icon: GitMerge }} onclick={openReview}>
Review
</Button>
{/snippet}
<div class="flex flex-row h-full min-h-0">
{#if comparison && comparison.diffs.length > 0}
<aside
bind:this={sidebarRoot}
onmousemove={() => (mouseActive = true)}
class="flex-none w-56 border-r border-light flex flex-col min-h-0"
>
<div class="px-3 pt-3 pb-2 shrink-0">
<input
bind:this={searchInputEl}
type="search"
bind:value={searchQuery}
placeholder="Filter files..."
onkeydown={handleSearchKeydown}
class="w-full text-xs px-2 py-1 rounded border border-light bg-surface focus:outline-none focus:border-accent"
/>
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-3 flex flex-col gap-1">
{#if tree && tree.children.length > 0}
{#each tree.children as child}
{@render renderTreeNode(child, 0)}
{/each}
{:else}
<div class="text-2xs text-tertiary px-3 py-2">No matches</div>
{/if}
</div>
</aside>
{/if}
<main class="flex-1 min-w-0 overflow-y-auto">
<div class="px-3 pt-3 pb-4 flex flex-col gap-3">
{#if loading && !comparison}
<div class="flex items-center gap-2 text-sm text-secondary py-8 self-center">
<Loader2 class="w-4 h-4 animate-spin" />
Loading comparison...
</div>
{:else if error}
<div class="text-sm text-red-600 dark:text-red-400 py-4">{error}</div>
{:else if comparison?.skipped_comparison}
<div class="text-sm text-secondary py-4">
This fork was created before change tracking was added — diffs are not available.
</div>
{:else if comparison && comparison.diffs.length === 0}
<div class="text-sm text-secondary py-4"
>No changes between this fork and its parent.</div
>
{:else if comparison && filteredDiffs.length === 0}
<div class="text-sm text-secondary py-4">No files match "{searchQuery}".</div>
{:else if comparison}
<div class="flex flex-col gap-2">
{#each filteredDiffs as d (itemKey(d))}
{@const key = itemKey(d)}
{@const status = statusOf(d)}
{@const StatusIcon = statusIcons[status]}
{@const loaded = loadedDiffs[key]}
{@const editUrl = editUrlFor(d)}
<details
open
id={rowId(d)}
class="border border-light rounded-md bg-surface scroll-mt-2"
ontoggle={(e) => onDetailsToggle(d, e)}
>
<summary
class="sticky top-0 z-30 bg-surface flex items-center gap-2 px-3 py-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden border-b border-transparent rounded-md relative before:content-[''] before:absolute before:inset-0 before:bg-surface-hover before:opacity-0 before:pointer-events-none before:transition-opacity hover:before:opacity-100"
>
<ChevronDown
class="w-3.5 h-3.5 shrink-0 text-tertiary transition-transform chevron"
/>
<RowIcon kind={d.kind} size={14} />
<div class="min-w-0 flex-1">
{#if editUrl}
<a
href={editUrl}
target="_blank"
rel="noopener noreferrer"
title={d.path}
onclick={(e) => e.stopPropagation()}
class="group inline-flex items-center gap-1 max-w-full text-xs text-primary font-mono truncate hover:underline"
>
<span class="truncate">{d.path}</span>
<ExternalLink
class="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-60 transition-opacity"
/>
</a>
{:else}
<div class="text-xs text-primary font-mono truncate" title={d.path}>
{d.path}
</div>
{/if}
</div>
<div class="shrink-0 flex items-center gap-2">
{#if d.ahead > 0}
<span class="text-2xs text-secondary">{d.ahead} ahead</span>
{/if}
{#if d.behind > 0}
<span class="text-2xs text-secondary">{d.behind} behind</span>
{/if}
<Badge color={statusBadgeColor(status)}>
<StatusIcon class="w-3 h-3 inline mr-0.5" />
{status}
</Badge>
</div>
</summary>
<div
class="border-t border-light bg-surface-tertiary rounded-b-md overflow-hidden"
>
{#if !loaded || loaded.state === 'loading'}
<div class="flex items-center gap-2 text-xs text-secondary p-3">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
Loading diff…
</div>
{:else if loaded.state === 'error'}
<div class="text-xs text-red-600 dark:text-red-400">{loaded.error}</div>
{:else if loaded.state === 'ready'}
<WorkspaceItemDiffViewer
kind={d.kind}
originalRaw={loaded.parentRaw}
currentRaw={loaded.forkRaw}
{inlineDiff}
/>
{/if}
</div>
</details>
{/each}
</div>
{/if}
</div></main
></div
>
</DrawerContent>
</Drawer>
<style>
/* Diff rows use a ChevronDown; rotate it back when collapsed. */
details:not([open]) :global(.chevron) {
transform: rotate(-90deg);
}
/* Tree folder rows: swap chevrons based on the folder's open state. */
details:not([open]) > .tree-summary :global(.tree-chevron-open) {
display: none;
}
details[open] > .tree-summary :global(.tree-chevron-closed) {
display: none;
}
</style>
@@ -0,0 +1,158 @@
<script lang="ts">
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import type { RawAppDraft } from './appDraftCodec'
import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft } from './appDraftCodec'
import SessionItemNotFound from './SessionItemNotFound.svelte'
let {
runtime,
path,
workspaceId,
onNavigate,
isActiveSession = true
}: {
runtime: SessionRuntime
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
/**
* Only the visible session should claim the workspace's live-editor
* slot — without this, a hidden warm-mounted session can overwrite the
* active session's UserDraft live-editor target (one slot per
* (workspace, kind)), so chat actions like discard / "the open editor"
* resolve to the wrong session.
*/
isActiveSession?: boolean
} = $props()
let diffDrawer: DiffDrawer | undefined = $state()
$effect(() => {
if (workspaceId && path) {
untrack(() => runtime.loadRawApp(workspaceId, path))
}
})
async function restoreFromCurrentTarget() {
diffDrawer?.closeDrawer()
await runtime.loadRawApp(workspaceId, path)
}
// Mark this editor as the live editor draft for the session's workspace
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
// to this path — same registration the regular /apps_raw/edit page does.
// Gated on `isActiveSession`: warm-but-hidden session editors must not
// claim the workspace's single live-editor slot, else chat actions on the
// visible session resolve to the hidden one's path.
$effect(() => {
if (!workspaceId || !path) return
if (!isActiveSession) return
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: 'raw_app',
storagePath: path,
effectivePath: runtime.rawApp.val?.path ?? path
})
return () =>
UserDraft.clearLiveEditorDraft('raw_app', { workspace: workspaceId, storagePath: path })
})
// Bidirectional sync between this preview and `UserDraft<RawAppDraft>`.
// We hold a *live* handle (useMany) rather than reading via the static
// `UserDraft.get`: the handle materializes UserDraft's shared reactive
// `$state` cell for (workspace, 'raw_app', path), and that cell is what
// lets the chat's writes (UserDraft.save / setDraftAndMeta, from
// write_app_file / patch_app_file / write_app_runnable) reach this preview.
// Without a live entry those writes only touch localStorage and the inbound
// effect below never re-fires. A reactive getter is used (not `use()`)
// because switching open_preview to another app swaps `path` without
// remounting this view, so the handle must re-acquire.
//
// Same one-way-reactive discipline as ScriptEditorView: inbound tracks only
// the handle's draft, outbound tracks only rawApp.val; each side's read of
// the other goes through untrack() to break the keystroke-revert race.
const draftHandles = UserDraft.useMany<RawAppDraft>(() => [
{ itemKind: 'raw_app', path, workspace: workspaceId }
])
let lastInboundSig: string | undefined = $state(undefined)
// Store → editor. Re-runs when the handle's draft changes (chat write,
// other session edit).
$effect(() => {
if (!workspaceId || !path) return
const incoming = draftHandles[0]?.draft
if (!incoming) return
const sig = JSON.stringify(incoming)
untrack(() => {
if (runtime.loadedRawAppPath !== path) return
if (sig === lastInboundSig) return
const current = runtime.rawApp.val
if (!current) return
lastInboundSig = sig
runtime.rawApp.val = applyDraftToRuntimeRawApp(current, incoming)
})
})
// Editor → store. Debounced 150ms so a typing burst inside a frontend
// file's Monaco editor coalesces into one store write.
let outboundTimer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
if (!workspaceId || !path) return
if (runtime.loadedRawAppPath !== path) return
const raw = runtime.rawApp.val
if (!raw) return
const draft = runtimeRawAppToDraft(raw)
const sig = JSON.stringify(draft)
if (sig === lastInboundSig) return
if (outboundTimer) clearTimeout(outboundTimer)
outboundTimer = setTimeout(() => {
untrack(() => {
const current = UserDraft.get<RawAppDraft>('raw_app', path, { workspace: workspaceId })
if (current && JSON.stringify(current) === sig) return
UserDraft.save('raw_app', path, draft, { workspace: workspaceId })
})
}, 150)
return () => {
if (outboundTimer) clearTimeout(outboundTimer)
}
})
</script>
{#if runtime.savedRawApp.val}
<DiffDrawer
bind:this={diffDrawer}
restoreDeployed={restoreFromCurrentTarget}
restoreDraft={restoreFromCurrentTarget}
/>
{/if}
{#if runtime.loadingRawApp && !runtime.loadedRawAppPath}
<div class="p-4 text-secondary text-sm">Loading raw app {path}…</div>
{:else if runtime.notFoundRawApp && !runtime.loadedRawAppPath}
<SessionItemNotFound kind="raw_app" {path} {onNavigate} />
{:else if runtime.rawApp.val}
<RawAppEditor
bind:files={runtime.rawApp.val.files}
bind:runnables={runtime.rawApp.val.runnables}
bind:data={runtime.rawApp.val.data}
bind:summary={runtime.rawApp.val.summary}
newPath={runtime.rawApp.val.path}
{path}
policy={runtime.rawApp.val.policy}
bind:savedApp={runtime.savedRawApp.val}
newApp={!runtime.savedRawApp.val}
{diffDrawer}
{onNavigate}
onDeploy={(e) => {
// Sync the preview to deployed (raw apps deploy only from this editor).
runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path)
}}
defaultSidebarCollapsed
sidebarStorageKey="raw-app-sidebar-collapsed-preview"
defaultSplitWithPreview={false}
/>
{/if}
@@ -0,0 +1,224 @@
<script lang="ts">
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import { DraftService, ScriptService, type NewScript } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import SessionItemNotFound from './SessionItemNotFound.svelte'
import { sendUserToast } from '$lib/toast'
let {
runtime,
path,
workspaceId,
onNavigate,
initialTestPanelCollapsed = false,
isActiveSession = true
}: {
runtime: SessionRuntime
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
initialTestPanelCollapsed?: boolean
/**
* Only the visible session should claim the workspace's live-editor
* slot — without this, a hidden warm-mounted session can overwrite the
* active session's UserDraft live-editor target (one slot per
* (workspace, kind)), so chat actions like discard / "the open editor"
* resolve to the wrong session.
*/
isActiveSession?: boolean
} = $props()
let diffDrawer: DiffDrawer | undefined = $state()
$effect(() => {
if (workspaceId && path) {
untrack(() => runtime.loadScript(workspaceId, path))
}
})
// Restore actions for the diff drawer. The previous shared
// `loadScript`-based handler was a no-op: loadScript early-returns on the
// already-loaded path (and would re-read the local draft anyway). Instead
// reset the live UserDraft handle to the target baseline — the inbound
// effect then syncs the editor preview. Mirrors /scripts/edit's restore.
async function restoreDeployed() {
const saved = runtime.savedScript.val
if (!saved) {
sendUserToast('Could not restore to deployed', true)
return
}
diffDrawer?.closeDrawer()
// Drop the backend (DB) draft too, so "deployed" sticks across a reload.
if (saved.draft) {
try {
await DraftService.deleteDraft({ workspace: workspaceId, kind: 'script', path: saved.path })
saved.draft = undefined
} catch (e: any) {
sendUserToast(`Could not delete draft: ${e?.body ?? e}`, true)
return
}
}
const deployed = structuredClone($state.snapshot(saved)) as NewScript & { draft?: unknown }
delete deployed.draft
UserDraft.discard<NewScript>('script', path, deployed, { workspace: workspaceId })
}
async function restoreDraft() {
const backendDraft = runtime.savedScript.val?.draft as NewScript | undefined
if (!backendDraft) {
sendUserToast('Could not restore to draft', true)
return
}
diffDrawer?.closeDrawer()
UserDraft.discard<NewScript>('script', path, structuredClone($state.snapshot(backendDraft)), {
workspace: workspaceId
})
}
// Mark this editor as the live editor draft for the session's workspace
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
// to this path — same registration the regular /scripts/edit page does.
// Gated on `isActiveSession`: warm-but-hidden session editors must not
// claim the workspace's single live-editor slot, else chat actions on the
// visible session resolve to the hidden one's path.
$effect(() => {
if (!workspaceId || !path) return
if (!isActiveSession) return
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: 'script',
storagePath: path,
effectivePath: runtime.scriptStore.val?.path ?? path
})
return () =>
UserDraft.clearLiveEditorDraft('script', { workspace: workspaceId, storagePath: path })
})
// Bidirectional sync between this preview and `UserDraft<NewScript>`.
// The same path under the same workspace is shared with the session's
// chat (read_workspace_item / write_script / edit_script) and any other
// open editor on the same workspace.
//
// We hold a *live* handle (useMany) instead of reading via the static
// `UserDraft.get`. The handle materializes UserDraft's shared reactive
// `$state` cell for (workspace, 'script', path) — and that cell is what
// lets the chat's writes (UserDraft.save, from write_script / edit_script)
// reach this preview. Without a live entry those writes only touch
// localStorage and the inbound effect below never re-fires. A reactive
// getter is used (not `use()`) because switching open_preview to another
// script swaps `path` without remounting this view, so the handle must
// re-acquire.
//
// One-way-reactive discipline: inbound tracks ONLY the handle's `draft`
// (and reads `script.content` via untrack); outbound tracks ONLY
// `script.content` (and reads UserDraft via untrack). Without that
// asymmetry, a user keystroke would re-fire the inbound effect with the
// pre-keystroke stored value and revert the edit.
const draftHandles = UserDraft.useMany<NewScript>(() => [
{ itemKind: 'script', path, workspace: workspaceId }
])
let lastInboundContent: string | undefined = $state(undefined)
// Store → editor. Re-runs when the handle's draft changes (chat write,
// other session edit, …). `script.content` is read inside untrack so user
// keystrokes don't refire this effect.
$effect(() => {
if (!workspaceId || !path) return
const draft = draftHandles[0]?.draft
if (!draft || typeof draft.content !== 'string') return
const incoming = draft.content
untrack(() => {
if (runtime.loadedScriptPath !== path) return
const script = runtime.scriptStore.val
if (!script) return
if (incoming === script.content) return
lastInboundContent = incoming
script.content = incoming
if (draft.language) script.language = draft.language
if (draft.summary !== undefined) script.summary = draft.summary
})
})
// Editor → store. Re-runs on `script.content` mutation (user typing
// or inbound write). UserDraft is read inside untrack so writing here
// doesn't ping-pong the inbound effect. `UserDraft.save` persists
// immediately and, now that the entry is live, updates the same cell the
// inbound effect reads (the content guard there makes it a no-op).
$effect(() => {
if (!workspaceId || !path) return
if (runtime.loadedScriptPath !== path) return
const script = runtime.scriptStore.val
if (!script) return
const content = script.content
if (content === lastInboundContent) return
untrack(() => {
const current = UserDraft.get<NewScript>('script', path, { workspace: workspaceId })
if (current && current.content === content) return
UserDraft.save<NewScript>(
'script',
path,
{ ...(current ?? script), ...script },
{
workspace: workspaceId
}
)
})
})
</script>
{#if runtime.savedScript.val}
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
{/if}
{#if runtime.loadingScript && !runtime.loadedScriptPath}
<div class="p-4 text-secondary text-sm">Loading script {path}…</div>
{:else if runtime.notFoundScript && !runtime.loadedScriptPath}
<SessionItemNotFound kind="script" {path} {onNavigate} />
{:else if runtime.scriptStore.val}
<!--
A script with no backend version yet (AI-created, never saved or deployed
→ savedScript undefined) is a *new* script: pass an empty initialPath so
ScriptBuilder behaves exactly like /scripts/add — Save draft is enabled and
creates it on first save. On that save ScriptBuilder writes savedScript back
through the bind and sets its own initialPath to the path, flipping us into
edit mode (Save draft + Show diff) without navigating away.
-->
<ScriptBuilder
bind:script={runtime.scriptStore.val}
bind:savedScript={runtime.savedScript.val}
initialPath={runtime.savedScript.val ? path : ''}
initialPathChosen={true}
neverShowMeta={true}
fullyLoaded={!runtime.loadingScript}
disableHistoryChange={true}
{diffDrawer}
{onNavigate}
{initialTestPanelCollapsed}
onSaveDraft={async (e) => {
runtime.scheduleForkComparisonRefresh()
// Re-pin parent_hash to the latest version so the next Deploy's conflict
// check (which runs before deploy, while the session stays mounted)
// doesn't misfire.
try {
const latest = await ScriptService.getScriptLatestVersion({
workspace: workspaceId,
path: e.path
})
const cur = runtime.scriptStore.val
if (latest?.script_hash && cur) cur.parent_hash = latest.script_hash
} catch (err) {
console.error('Failed to sync parent_hash after save draft', err)
}
}}
onDeploy={(e) => {
// Fires on every deploy (primary, "Deploy & Stay here", and lib — we
// ignore e.stay since the session always stays). Toast, then sync the
// preview to the deployed version.
sendUserToast('Deployed')
runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path)
}}
/>
{/if}
@@ -0,0 +1,213 @@
<script lang="ts">
import {
Archive,
ArrowRight,
GitCompareArrows,
GitFork,
GitMerge,
GitPullRequestArrow,
GitPullRequestClosed,
MoveRight,
Trash2
} from 'lucide-svelte'
import { Button } from '$lib/components/common'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { goto } from '$lib/navigation'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
import { isCloudHosted } from '$lib/cloud'
import { deriveForkStatus, sessionState, type Session } from './sessionState.svelte'
import { getRuntime } from './sessionRuntime.svelte'
import ForkDiffDrawer from './ForkDiffDrawer.svelte'
let {
session,
onMove,
onCreateForkAndMove,
onArchive,
onDelete
}: {
session: Session
onMove?: (workspaceId: string) => void
onCreateForkAndMove?: (fork: {
parent_workspace_id: string
id: string
name: string
}) => void | Promise<void>
onArchive?: () => void
onDelete?: () => void
} = $props()
// The fork bar surfaces a committed workspace relationship — only
// visible after the session locked its workspace at first send. Drafts
// (workspace_id undefined) get nothing here.
const committedId = $derived(session.workspace_id)
const sessionWorkspace = $derived(
committedId ? $userWorkspaces.find((w) => w.id === committedId) : undefined
)
const parentWorkspaceId = $derived(sessionWorkspace?.parent_workspace_id ?? undefined)
const parentWorkspace = $derived(
parentWorkspaceId ? $userWorkspaces.find((w) => w.id === parentWorkspaceId) : undefined
)
const isFork = $derived(!!parentWorkspaceId)
// Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar.
// When forking isn't available the diff/review surface is moot.
const forksAllowed = $derived(
!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && $workspaceStore !== 'admins'
)
let diffDrawer: ForkDiffDrawer | undefined = $state(undefined)
// Comparison data lives on the shared SessionRuntime resource so any
// future consumer (e.g. the diff drawer, a merge action) reads the
// same cache and can invalidate it after mutating the fork.
const runtime = $derived(getRuntime(session.id))
const comparison = $derived(runtime?.forkComparison.val)
const totalDiffs = $derived(comparison?.summary?.total_diffs ?? 0)
const forkStatus = $derived(deriveForkStatus(session, $userWorkspaces, comparison))
const isUnavailable = $derived(forkStatus === 'unavailable')
$effect(() => {
if (!runtime || !committedId || !parentWorkspaceId) return
void runtime.ensureForkComparison(parentWorkspaceId, committedId)
})
function refreshComparison() {
if (!runtime || !committedId || !parentWorkspaceId) return
runtime.invalidateForkComparison()
void runtime.ensureForkComparison(parentWorkspaceId, committedId)
}
// Refresh when the AI finishes a turn (loading transitions true →
// false). Tool calls in that turn may have created / edited / deleted
// fork items, so the diff count needs to reflect them immediately.
let wasLoading = $state(false)
$effect(() => {
const isLoading = runtime?.manager.loading ?? false
if (wasLoading && !isLoading) refreshComparison()
wasLoading = isLoading
})
// Refresh when the tab regains visibility — covers edits made in
// another tab or by another user while we were away.
$effect(() => {
if (!runtime || !committedId || !parentWorkspaceId) return
function onVisibilityChange() {
if (document.visibilityState !== 'visible') return
if (sessionState.currentSessionId !== session.id) return
refreshComparison()
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => document.removeEventListener('visibilitychange', onVisibilityChange)
})
export const refresh = refreshComparison
function openReview() {
if (!committedId || isUnavailable) return
goto(`/forks/compare?workspace_id=${encodeURIComponent(committedId)}`)
}
</script>
{#if committedId && isUnavailable}
<!-- Fork workspace is no longer in the user's list (deleted, archived,
or access revoked). Surface an actionable banner: move the session
to a still-valid workspace, or discard it (archive / delete). The
chat input is disabled by SessionWrapper while this is shown. -->
<div class="flex flex-col gap-2 py-2 px-3 text-xs border rounded-md bg-surface-tertiary">
<div class="flex flex-row items-start gap-2">
<GitPullRequestClosed class="w-4 h-4 shrink-0 text-tertiary mt-0.5" />
<div class="flex flex-col min-w-0 flex-1">
<span class="text-primary font-medium">The fork has been archived or deleted</span>
<span class="text-2xs text-tertiary">
Move this session to another workspace, or discard it.
<span class="font-mono text-tertiary" title={committedId}>{committedId}</span>
</span>
</div>
</div>
<div class="flex flex-row items-center justify-end gap-1.5">
<WorkspaceFamilyPicker
onPick={(workspaceId) => onMove?.(workspaceId)}
onCreateFork={async (fork) => {
await onCreateForkAndMove?.(fork)
}}
createForkCaption="Created immediately and the session moved into it."
>
{#snippet trigger()}
<Button variant="default" unifiedSize="sm" startIcon={{ icon: MoveRight }}>
Move to workspace
</Button>
{/snippet}
</WorkspaceFamilyPicker>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Archive }}
dropdownItems={[{ label: 'Delete', icon: Trash2, onClick: () => onDelete?.() }]}
onclick={() => onArchive?.()}
>
Archive
</Button>
</div>
</div>
{:else if forksAllowed && isFork && sessionWorkspace && parentWorkspace && parentWorkspaceId && committedId}
{@const StatusIcon =
forkStatus === 'ahead'
? GitPullRequestArrow
: forkStatus === 'diverged'
? GitCompareArrows
: GitFork}
{@const statusColor =
forkStatus === 'ahead'
? 'text-blue-500'
: forkStatus === 'diverged'
? 'text-amber-500'
: 'text-secondary'}
{@const statusTitle =
forkStatus === 'ahead'
? 'Ahead of parent'
: forkStatus === 'diverged'
? 'Diverged from parent'
: forkStatus === 'in_sync'
? 'In sync with parent'
: 'Fork'}
<div
class="flex flex-row items-center justify-between gap-2 py-2 px-3 text-xs border rounded-md bg-surface-tertiary"
>
<div class="flex items-center gap-1.5 min-w-0">
<span title={statusTitle} class="inline-flex shrink-0">
<StatusIcon class="w-3.5 h-3.5 {statusColor}" />
</span>
<span class="truncate text-secondary" title={sessionWorkspace.name}>
{sessionWorkspace.name}
</span>
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
<span class="truncate text-secondary" title={parentWorkspace.name}>
{parentWorkspace.name}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="subtle"
unifiedSize="xs"
startIcon={{ icon: GitCompareArrows }}
disabled={totalDiffs === 0}
title="{totalDiffs} modified item{totalDiffs === 1 ? '' : 's'}"
onclick={() => diffDrawer?.open()}
>
{totalDiffs}
</Button>
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: GitMerge }}
onclick={openReview}
>
Review
</Button>
</div>
</div>
<ForkDiffDrawer bind:this={diffDrawer} forkWorkspaceId={committedId} {parentWorkspaceId} />
{/if}
@@ -0,0 +1,54 @@
<script lang="ts">
import EditorHeader from '$lib/components/EditorHeader.svelte'
import type { WorkspaceItem, WorkspaceItemKind } from '$lib/components/workspacePicker'
import type { SessionTarget } from './sessionState.svelte'
const KIND_NOT_FOUND_LABEL: Record<SessionTarget['kind'], string> = {
flow: 'Flow',
script: 'Script',
raw_app: 'Raw app'
}
let {
kind,
path,
onNavigate
}: {
kind: SessionTarget['kind']
path: string
onNavigate?: (item: WorkspaceItem) => void
} = $props()
// EditorHeader's `kind` prop is 'flow' | 'script' | 'app'; raw apps are
// flagged via the `raw_app` boolean so the picker routes to /apps_raw/...
const headerKind: WorkspaceItemKind = $derived(kind === 'raw_app' ? 'app' : kind)
const isRawApp = $derived(kind === 'raw_app')
// Local read-only mirrors so EditorHeader's bind:* doesn't write back into
// the parent's session state. The pen popover is disabled below so these
// stay quiet — but bindable references are still required by the API.
let summary = $state('')
let displayPath = $state(path)
$effect(() => {
displayPath = path
})
</script>
<div class="flex flex-col h-full">
<div class="flex h-12 items-center px-4 border-b border-border-light">
<EditorHeader
bind:summary
bind:path={displayPath}
savedPath={path}
kind={headerKind}
raw_app={isRawApp}
summaryEditable={false}
pathEditable={false}
{onNavigate}
/>
</div>
<div class="p-4 text-secondary text-sm">
{KIND_NOT_FOUND_LABEL[kind]} not found at path
<code class="font-mono">{path}</code>.
</div>
</div>
@@ -0,0 +1,629 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import {
Archive,
ArchiveRestore,
ChevronDown,
ChevronRight,
EllipsisVertical,
Filter,
MessageSquare,
Pencil,
PencilLine,
Plus,
Trash2
} from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { goto } from '$lib/navigation'
import { page } from '$app/state'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { slide } from 'svelte/transition'
import {
createSession,
deriveForkStatus,
getEffectiveWorkspaceId,
isForkSession,
renameSession,
selectSession,
sessionState,
setSessionArchived,
syncWorkspaceTo,
type Session
} from './sessionState.svelte'
import { forgetSessionSeen, unreadCountFor } from './sessionUnread.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import {
getOrCreateRuntime,
getRuntime,
getSessionChatStatus,
removeSession
} from './sessionRuntime.svelte'
import SessionStatusDot from './SessionStatusDot.svelte'
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { visibleWorkspaceIds } from './sessionScope.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
// Look up the cached fork comparison for a session through its runtime
// (if any). The deriveForkStatus helper handles the "no runtime yet"
// and "comparison not loaded" cases by returning undefined; we render
// a neutral fork icon in that interim, then upgrade to the proper
// status icon once the comparison lands.
function forkStatusFor(session: Session) {
return deriveForkStatus(session, $userWorkspaces, getRuntime(session.id)?.forkComparison.val)
}
function isForkFor(session: Session): boolean {
return isForkSession(session, $userWorkspaces)
}
// Compute the unread count for a session. Driven by the per-runtime
// displayMessages array vs. the localStorage-backed lastSeen map;
// both are reactive so the badge updates without polling.
function unreadFor(session: Session): number {
return unreadCountFor(session.id, getRuntime(session.id))
}
// Whether the composer for a session holds non-whitespace text. We
// read manager.instructions directly (not the derived chat status)
// so the draft cue still shows during streaming/needs-confirmation —
// those override the icon slot but shouldn't hide the fact that the
// user has unsent text in this session.
function hasDraft(session: Session): boolean {
const rt = getRuntime(session.id)
return !!rt && rt.manager.instructions.trim().length > 0
}
// Sessions piggyback on the same dev gate as the global AI chat — when
// the feature flag is off, the sidebar section is hidden entirely.
const globalEnabled = isGlobalAiEnabled()
// Only highlight the active session while we're actually on the session
// page — once the user navigates away, `currentSessionId` lingers but no
// row should appear selected.
const onSessionsPage = $derived(page.route.id?.includes('/sessions') ?? false)
interface Props {
isCollapsed?: boolean
}
let { isCollapsed = false }: Props = $props()
const sectionCollapsed = useLocalStorageValue(
'windmill_sessions_section_collapsed',
false,
'boolean'
)
const showArchived = useLocalStorageValue('windmill_sessions_show_archived', false, 'boolean')
let listRoot: HTMLDivElement | undefined = $state()
// Sessions visible in the current workspace (active workspace + its
// forks). Drafts (no committed workspace) are scoped by their
// pending workspace pick — set at create time to the workspace the
// user was in. Archived sessions are filtered out unless the user
// has opted in via the filter popover.
const visibleSessions = $derived(
sessionState.sessions.filter((s) => {
// Transient (not-yet-sent) sessions live as their own page but
// don't clutter the sidebar list.
if (s.transient) return false
if (s.archived && !showArchived.val) return false
const ws = getEffectiveWorkspaceId(s)
if (!ws) return false
if ($visibleWorkspaceIds.has(ws)) return true
// Unavailable sessions (committed workspace was deleted /
// archived / access revoked) stay visible everywhere so the
// user can resolve them — move, archive, or delete. They'd
// otherwise be permanently hidden the moment their workspace
// disappeared.
if (s.workspace_id && !$userWorkspaces.find((w) => w.id === s.workspace_id)) return true
return false
})
)
const archivedCount = $derived(
sessionState.sessions.filter((s) => {
if (!s.archived || s.transient) return false
const ws = getEffectiveWorkspaceId(s)
if (!ws) return false
if ($visibleWorkspaceIds.has(ws)) return true
if (s.workspace_id && !$userWorkspaces.find((w) => w.id === s.workspace_id)) return true
return false
}).length
)
// Sum of unread across every visible session — surfaced on the
// collapsed-sidebar chat icon so the user sees there's pending
// AI activity in some session without expanding the sidebar.
const totalUnread = $derived(visibleSessions.reduce((acc, s) => acc + unreadFor(s), 0))
// Clear any persisted collapsed state while the list is empty. The
// empty-state header is a plain label with no toggle, so a collapse
// carried over from a previous session (or another workspace) would
// otherwise hide the user's first new session with no way to expand
// it. Resetting here keeps the section expanded by default whenever
// the first session arrives. Guarded on the current value so it writes
// once (true → false) rather than looping.
$effect(() => {
if (visibleSessions.length === 0 && sectionCollapsed.val) {
sectionCollapsed.val = false
}
})
// Eagerly create a runtime per VISIBLE session so the status dot reflects
// the persisted chat (last message, pending confirmation, etc.) without
// requiring the user to open the session first. Sessions outside the
// current workspace scope are left cold to avoid opening IDB connections
// for unrelated work.
$effect(() => {
for (const session of visibleSessions) {
getOrCreateRuntime(session)
}
})
// Pre-fetch the fork comparison for every visible fork session so the
// sidebar icons reflect the right ahead/diverged state without
// requiring the user to click into each session. Cheap enough at
// typical session counts; falls back to a plain dot until the
// fetch lands.
$effect(() => {
if (sectionCollapsed.val) return
for (const session of visibleSessions) {
if (!session.workspace_id) continue
const ws = $userWorkspaces.find((w) => w.id === session.workspace_id)
if (!ws?.parent_workspace_id) continue
const rt = getRuntime(session.id)
if (!rt) continue
void rt.ensureForkComparison(ws.parent_workspace_id, session.workspace_id)
}
})
function isUnavailableFork(session: Session): boolean {
return !!session.workspace_id && !$userWorkspaces.find((w) => w.id === session.workspace_id)
}
async function activate(session: Session, restoreFocus: boolean = false) {
selectSession(session.id)
// If the session has a committed workspace different from the
// active one, switch globally so the editor/forks resolve correctly.
// Skip for unavailable forks — switching to a deleted workspace
// would error out and leave the user in limbo.
if (!isUnavailableFork(session)) {
syncWorkspaceTo(session.workspace_id)
}
// Refresh the fork diff count — users typically click back into a
// session after editing items elsewhere in the SPA, where neither
// the visibility-change nor the AI-loading signal would fire.
void getRuntime(session.id)?.refreshForkComparison()
await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`)
if (restoreFocus) {
// goto() resets focus to <body> — put it back on the active session button
// so subsequent arrow keys keep navigating the list.
requestAnimationFrame(() => {
const selected = listRoot?.querySelector<HTMLButtonElement>(
'button[data-session-button][aria-selected="true"]'
)
selected?.focus()
})
}
}
async function createAndOpen() {
await activate(createSession())
}
let editingId: string | undefined = $state(undefined)
let renameDraft = $state('')
function startRename(session: Session) {
editingId = session.id
renameDraft = session.summary ?? ''
}
function commitRename() {
const id = editingId
if (!id) return
renameSession(id, renameDraft)
editingId = undefined
}
function cancelRename() {
editingId = undefined
}
let pendingDelete: Session | undefined = $state(undefined)
// Default to also deleting the fork: it's tied to this session and would be
// orphaned otherwise. The user can still untick it in the modal.
let deleteAlsoFork = $state(true)
// Fork workspace tied to `pendingDelete`, if any, and still accessible.
const pendingDeleteForkId = $derived.by(() => {
const wsId = pendingDelete?.workspace_id
if (!wsId || !wsId.startsWith('wm-fork-')) return undefined
const ws = $userWorkspaces.find((w) => w.id === wsId)
if (!ws || !ws.parent_workspace_id) return undefined
return wsId
})
async function handleConfirmedDelete() {
const session = pendingDelete
const forkToDelete = deleteAlsoFork ? pendingDeleteForkId : undefined
// Capture the fork's parent before the workspace list is refreshed
// below — afterwards the fork is gone from $userWorkspaces and the
// lookup would return undefined.
const forkParentId = forkToDelete
? $userWorkspaces.find((w) => w.id === forkToDelete)?.parent_workspace_id
: undefined
pendingDelete = undefined
deleteAlsoFork = true
if (!session) return
const wasActive = sessionState.currentSessionId === session.id
removeSession(session.id)
forgetSessionSeen(session.id)
if (forkToDelete) {
try {
await WorkspaceService.deleteWorkspace({ workspace: forkToDelete })
sendUserToast(`Deleted forked workspace ${forkToDelete}`)
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e: any) {
sendUserToast(`Failed to delete fork ${forkToDelete}: ${e?.body ?? e}`, true)
}
}
// If the deleted fork was the active workspace, fall back to its parent
// so the user isn't stranded on a workspace that no longer exists.
if (forkToDelete && forkParentId && $workspaceStore === forkToDelete) {
syncWorkspaceTo(forkParentId)
}
if (wasActive) {
const next = sessionState.sessions[0]
if (next) await activate(next)
else await goto('/sessions')
}
}
function focusAt(index: number) {
const buttons = listRoot
? Array.from(listRoot.querySelectorAll<HTMLButtonElement>('button[data-session-button]'))
: []
if (buttons.length === 0) return
const wrapped = ((index % buttons.length) + buttons.length) % buttons.length
buttons[wrapped]?.focus()
}
function handleListKeydown(e: KeyboardEvent) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') {
return
}
const buttons = listRoot
? Array.from(listRoot.querySelectorAll<HTMLButtonElement>('button[data-session-button]'))
: []
if (buttons.length === 0) return
const current = buttons.indexOf(document.activeElement as HTMLButtonElement)
e.preventDefault()
if (e.key === 'ArrowDown') focusAt(current < 0 ? 0 : current + 1)
else if (e.key === 'ArrowUp') focusAt(current < 0 ? buttons.length - 1 : current - 1)
else if (e.key === 'Home') focusAt(0)
else if (e.key === 'End') focusAt(buttons.length - 1)
}
const menuItemBase = twMerge(
'text-secondary text-left font-normal text-xs',
'flex flex-row items-center gap-2 px-3 py-1.5 w-full',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)
</script>
{#if !globalEnabled}
<!-- Sessions hidden until the global-ai dev gate is enabled. -->
{:else if isCollapsed}
<div class="px-2 pt-3 pb-2 border-b border-light dark:border-gray-700">
<Menubar>
{#snippet children({ createMenu })}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<div class="relative">
<MenuButton
class="!text-xs"
icon={MessageSquare}
label="AI sessions"
{isCollapsed}
{trigger}
/>
{#if totalUnread > 0}
<span
class="absolute top-1 right-1 pointer-events-none inline-block w-2 h-2 rounded-full bg-blue-500"
aria-label="{totalUnread} unread message{totalUnread === 1
? ''
: 's'} across all sessions"
></span>
{/if}
</div>
{/snippet}
{#snippet children({ item })}
<div class="divide-y min-w-48" role="none">
<div class="py-1" role="none">
<MenuItem class={menuItemBase} onClick={createAndOpen} {item}>
<Plus size={14} />
New session
</MenuItem>
</div>
<div class="py-1" role="none">
{#each visibleSessions as session (session.id)}
{@const runtime = getRuntime(session.id)}
{@const status = runtime ? getSessionChatStatus(runtime) : 'idle'}
{@const isSelected =
onSessionsPage && session.id === sessionState.currentSessionId}
{@const unread = unreadFor(session)}
{@const draft = hasDraft(session)}
<MenuItem
class={twMerge(menuItemBase, isSelected ? 'bg-surface-hover' : '')}
onClick={() => activate(session)}
{item}
>
<SessionStatusDot
{status}
isFork={isForkFor(session)}
forkStatus={forkStatusFor(session)}
/>
<span
class={twMerge(
'truncate flex-1 text-left',
unread > 0 ? 'font-semibold text-primary' : ''
)}
>
{session.summary ?? 'Untitled session'}
</span>
{#if draft || unread > 0}
<span class="ml-auto shrink-0 inline-flex items-center gap-1">
{#if draft}
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Unsent draft" />
{/if}
{#if unread > 0}
<span
class="inline-flex items-center justify-center rounded-full bg-blue-500 text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
>
{unread > 9 ? '9+' : unread}
</span>
{/if}
</span>
{/if}
</MenuItem>
{/each}
</div>
</div>
{/snippet}
</Menu>
{/snippet}
</Menubar>
</div>
{:else}
<div class="px-2 pt-3 pb-2 flex flex-col gap-1 border-b border-light dark:border-gray-700">
<div class="flex flex-row items-center justify-between pl-1 pr-0.5">
{#if visibleSessions.length > 0}
<button
type="button"
onclick={() => (sectionCollapsed.val = !sectionCollapsed.val)}
class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 rounded px-1 -mx-1 py-0.5 hover:bg-surface-hover focus:outline-none"
aria-expanded={!sectionCollapsed.val}
>
AI sessions
{#if sectionCollapsed.val}
<ChevronRight size={10} />
{:else}
<ChevronDown size={10} />
{/if}
</button>
{:else}
<!-- No sessions yet: render the label as plain text (no collapse toggle). -->
<span
class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 px-1 -mx-1 py-0.5"
>
AI sessions
</span>
{/if}
<div class="flex flex-row items-center gap-0.5">
<Popover placement="bottom-end" usePointerDownOutside disableFocusTrap class="inline-flex">
{#snippet trigger()}
<button
type="button"
title="Filter sessions"
aria-label="Filter sessions"
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary {showArchived.val
? 'text-emphasis'
: ''}"
>
<Filter size={12} />
</button>
{/snippet}
{#snippet content()}
<div
class="w-56 p-2 bg-surface-tertiary dark:border rounded-md shadow-lg flex flex-col gap-1"
>
<Toggle
bind:checked={showArchived.val}
size="xs"
options={{ right: 'Show archived' }}
/>
{#if archivedCount > 0}
<span class="text-2xs text-tertiary pl-1">
{archivedCount} archived session{archivedCount === 1 ? '' : 's'}
</span>
{/if}
</div>
{/snippet}
</Popover>
<Button
variant="subtle"
size="xs2"
iconOnly
startIcon={{ icon: Plus }}
onclick={createAndOpen}
title="New session"
/>
</div>
</div>
{#if !sectionCollapsed.val}
<div
bind:this={listRoot}
transition:slide={{ duration: 180 }}
class="flex flex-col gap-0.5 max-h-[40vh] overflow-y-auto"
onkeydown={handleListKeydown}
role="listbox"
tabindex="-1"
>
{#each visibleSessions as session (session.id)}
{@const runtime = getRuntime(session.id)}
{@const status = runtime ? getSessionChatStatus(runtime) : 'idle'}
{@const isSelected = onSessionsPage && session.id === sessionState.currentSessionId}
{@const isEditing = editingId === session.id}
{@const unread = unreadFor(session)}
{@const draft = hasDraft(session)}
<div
class={twMerge(
'flex flex-row items-center group rounded',
isSelected ? 'bg-surface-hover text-primary' : 'hover:bg-surface-hover',
session.archived ? 'italic opacity-60' : ''
)}
>
{#if isEditing}
<span class="flex flex-row items-center gap-2 flex-1 px-2 py-1 min-w-0">
<SessionStatusDot
{status}
isFork={isForkFor(session)}
forkStatus={forkStatusFor(session)}
/>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={renameDraft}
onkeydown={(e) => {
if (e.key === 'Enter') commitRename()
else if (e.key === 'Escape') cancelRename()
}}
onblur={commitRename}
placeholder="Untitled session"
autofocus
spellcheck="false"
class="flex-1 min-w-0 bg-transparent border-0 outline-none text-xs font-normal text-primary"
/>
</span>
{:else}
<button
type="button"
data-session-button
role="option"
aria-selected={isSelected}
onclick={() => activate(session)}
class={twMerge(
'flex flex-row items-center gap-2 text-left text-xs font-normal focus:outline-none flex-1 min-w-0 px-2 py-1',
unread > 0 ? 'text-primary font-semibold' : 'text-secondary'
)}
>
<SessionStatusDot
{status}
isFork={isForkFor(session)}
forkStatus={forkStatusFor(session)}
/>
<span class="truncate flex-1">{session.summary ?? 'Untitled session'}</span>
{#if draft || unread > 0}
<span class="shrink-0 inline-flex items-center gap-1">
{#if draft}
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Unsent draft" />
{/if}
{#if unread > 0}
<span
class="inline-flex items-center justify-center rounded-full bg-blue-500 text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
>
{unread > 9 ? '9+' : unread}
</span>
{/if}
</span>
{/if}
</button>
<div
class="opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity pr-0.5"
>
<DropdownV2
fixedHeight={false}
placement="bottom-end"
items={[
{
displayName: 'Rename',
icon: Pencil,
action: () => startRename(session)
},
session.archived
? {
displayName: 'Unarchive',
icon: ArchiveRestore,
action: () => setSessionArchived(session.id, false)
}
: {
displayName: 'Archive',
icon: Archive,
action: () => setSessionArchived(session.id, true)
},
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
action: () => (pendingDelete = session)
}
]}
>
{#snippet buttonReplacement()}
<span
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
title="More"
>
<EllipsisVertical size={14} />
</span>
{/snippet}
</DropdownV2>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
<ConfirmationModal
open={!!pendingDelete}
title="Delete session"
confirmationText="Delete"
onConfirmed={handleConfirmedDelete}
onCanceled={() => {
pendingDelete = undefined
deleteAlsoFork = true
}}
>
<div class="flex flex-col gap-3">
<p>
Delete session <span class="font-medium text-primary"
>{pendingDelete?.summary ?? pendingDelete?.name}</span
>? This cannot be undone.
</p>
{#if pendingDeleteForkId}
<div class="flex items-start gap-2 border rounded-md p-3 bg-surface-secondary">
<Toggle size="xs" bind:checked={deleteAlsoFork} />
<div class="flex flex-col">
<span class="text-xs font-medium text-primary"
>Also delete forked workspace <span class="font-mono">{pendingDeleteForkId}</span></span
>
<span class="text-3xs text-tertiary"
>The fork won't be reachable from any other session — leaving it would orphan it.</span
>
</div>
</div>
{/if}
</div>
</ConfirmationModal>

Some files were not shown because too many files have changed in this diff Show More