mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
Merge remote-tracking branch 'origin/main' into datatable-roles-redesign
# Conflicts: # backend/ee-repo-ref.txt # frontend/src/lib/components/InstanceSettings.svelte
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.805.0"
|
||||
".": "1.808.0"
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ Open-source platform for internal tools, workflows, API integrations, background
|
||||
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill.
|
||||
- **Frontend UUIDs**: do not call `crypto.randomUUID()` in frontend code. Import `randomUUID` from `$lib/utils/uuid` instead.
|
||||
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy and `xhigh` reasoning, on `gpt-6-astra` rather than the action's `gpt-5.6-sol`; requires the `codex` CLI >= 0.153.4.
|
||||
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
|
||||
- **Domain guides**: `.claude/skills/native-trigger/`
|
||||
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
|
||||
- **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does.
|
||||
- **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags.
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
## [1.808.0](https://github.com/windmill-labs/windmill/compare/v1.807.0...v1.808.0) (2026-09-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* run and test scripts from the AI chat through an argument form ([#11001](https://github.com/windmill-labs/windmill/issues/11001)) ([a6abf2c](https://github.com/windmill-labs/windmill/commit/a6abf2c8a744e9ee6acf4830cdfbb84f2f95cb36))
|
||||
|
||||
## [1.807.0](https://github.com/windmill-labs/windmill/compare/v1.806.0...v1.807.0) (2026-09-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add a dismissible instance-wide announcement banner ([#11037](https://github.com/windmill-labs/windmill/issues/11037)) ([abf4c6c](https://github.com/windmill-labs/windmill/commit/abf4c6c2348014ea4401b9be62b5b15e5800e879))
|
||||
* batch chained DDL statements into a single migration ([#11038](https://github.com/windmill-labs/windmill/issues/11038)) ([656e609](https://github.com/windmill-labs/windmill/commit/656e609595833bc854f845d35cf43157e76d732f))
|
||||
* create the cloud workspace in onboarding, and teach the empty home ([#10959](https://github.com/windmill-labs/windmill/issues/10959)) ([fd35b47](https://github.com/windmill-labs/windmill/commit/fd35b4765843879cb2254f402c142fd7510f1916))
|
||||
* link from the public run view to the authenticated run page ([#11041](https://github.com/windmill-labs/windmill/issues/11041)) ([09b81a9](https://github.com/windmill-labs/windmill/commit/09b81a9294bed2795a1a7b688d0b02e0a61957ea))
|
||||
* make guest access unavailable on the shared cloud ([#11040](https://github.com/windmill-labs/windmill/issues/11040)) ([0b63e0a](https://github.com/windmill-labs/windmill/commit/0b63e0a6929088ff25def4fd6547cf61668251a5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ignore comments and continuations in python lockfiles ([#11035](https://github.com/windmill-labs/windmill/issues/11035)) ([90c4e10](https://github.com/windmill-labs/windmill/commit/90c4e1020a2ff896977648dd68b572413cea7709))
|
||||
* refetch an unparseable hub script cache entry instead of panicking ([#11033](https://github.com/windmill-labs/windmill/issues/11033)) ([88c3ebd](https://github.com/windmill-labs/windmill/commit/88c3ebdfc1325ffbea4521d854e71d231a6409c4))
|
||||
* stop a new AI session adopting a legacy sidebar chat ([#11039](https://github.com/windmill-labs/windmill/issues/11039)) ([1076b63](https://github.com/windmill-labs/windmill/commit/1076b638d987ba99c5c27e5478ca580534b9d572))
|
||||
|
||||
## [1.806.0](https://github.com/windmill-labs/windmill/compare/v1.805.0...v1.806.0) (2026-09-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* bring gitlab repositories to parity for git sync ([#10938](https://github.com/windmill-labs/windmill/issues/10938)) ([9444049](https://github.com/windmill-labs/windmill/commit/9444049d6013c77a5f25f01a736eb5cd741fb3e6))
|
||||
* draw a dbt column trace, across projects and the pipeline boundary ([#11014](https://github.com/windmill-labs/windmill/issues/11014)) ([33f9828](https://github.com/windmill-labs/windmill/commit/33f9828c3ed15fe63fccedc1550584f15c0490ab))
|
||||
* durable dbt state per environment, and `--defer` onto it ([#10975](https://github.com/windmill-labs/windmill/issues/10975)) ([621fac5](https://github.com/windmill-labs/windmill/commit/621fac55abcd1859e8c8c06e5f4412e61bb85d59))
|
||||
* ingest dbt column lineage and real column schemas from the engine's parquet index ([#10977](https://github.com/windmill-labs/windmill/issues/10977)) ([0139467](https://github.com/windmill-labs/windmill/commit/0139467b01b82e4b3d474ca3f205358fa607d19a))
|
||||
* let a worker group override the dependency cache object store ([#11019](https://github.com/windmill-labs/windmill/issues/11019)) ([de98adf](https://github.com/windmill-labs/windmill/commit/de98adf055835ab7c4d6305e7d5d3bdab915876b))
|
||||
* **nativets:** bound fetch on a peer that never answers ([#11026](https://github.com/windmill-labs/windmill/issues/11026)) ([785277e](https://github.com/windmill-labs/windmill/commit/785277e0bb2ea77b71a89dd4d389d439dfcf9e03))
|
||||
* recognize `// volume:` mounts in PHP scripts ([#11018](https://github.com/windmill-labs/windmill/issues/11018)) ([f081fb1](https://github.com/windmill-labs/windmill/commit/f081fb10705cadf99e99dfa786d1cc2ebf0447db))
|
||||
* report a WAC task failure the workflow body never awaited ([#11017](https://github.com/windmill-labs/windmill/issues/11017)) ([3e3a41d](https://github.com/windmill-labs/windmill/commit/3e3a41d418d4ee3fe060bd3acf3324f311d89c8a))
|
||||
* retry a workflow-as-code task from its task options ([#11013](https://github.com/windmill-labs/windmill/issues/11013)) ([d3f305d](https://github.com/windmill-labs/windmill/commit/d3f305db982b7c5dc49babf9bec8b62adcd2557d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* chain redeploys onto a retired path's version history ([#11029](https://github.com/windmill-labs/windmill/issues/11029)) ([0b37226](https://github.com/windmill-labs/windmill/commit/0b372260787edb7e9627ad4fb6637ad5f1024e0a))
|
||||
* make the native trigger disable/enable toggle actually save ([#11024](https://github.com/windmill-labs/windmill/issues/11024)) ([448fce9](https://github.com/windmill-labs/windmill/commit/448fce93f743d5b2ef2a2d4496eb2ec238594a0a))
|
||||
* offload php signature parsing from async workers ([#11027](https://github.com/windmill-labs/windmill/issues/11027)) ([2cb02e3](https://github.com/windmill-labs/windmill/commit/2cb02e3b3398db49f16377dd79dde2dd6fb5cc02))
|
||||
* reduce php parser stack use in debug workers ([#11025](https://github.com/windmill-labs/windmill/issues/11025)) ([2ae8509](https://github.com/windmill-labs/windmill/commit/2ae8509b14f112c9ef5b71321e8fd52596a16c10))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* reduce shared worker debug polling frames ([#11028](https://github.com/windmill-labs/windmill/issues/11028)) ([946756a](https://github.com/windmill-labs/windmill/commit/946756ae83deb4e7a93111edddbd4d98c596d5a9))
|
||||
|
||||
## [1.805.0](https://github.com/windmill-labs/windmill/compare/v1.804.0...v1.805.0) (2026-09-07)
|
||||
|
||||
|
||||
|
||||
@@ -132,6 +132,10 @@ export async function runEval<THelpers, TOutput>(
|
||||
setToolStatus: () => {},
|
||||
removeToolStatus: () => {},
|
||||
isPlanModeActive,
|
||||
// Accepts the run form exactly as the model prefilled it: there is nobody here to
|
||||
// edit the arguments, so a case can assert what the model proposed but never how
|
||||
// it reacts to the user changing something.
|
||||
requestRunArgs: async (_toolId, form) => form.args,
|
||||
onNewToken: (token: string) => {
|
||||
if (shouldEmitMessageStart) {
|
||||
onAssistantMessageStart?.();
|
||||
|
||||
@@ -86,6 +86,7 @@ vi.mock('$lib/gen', async () => {
|
||||
previewBenchmarkSchedule,
|
||||
runBenchmarkDatatableSql,
|
||||
runBenchmarkFlowByPath,
|
||||
runBenchmarkScriptByPath,
|
||||
runBenchmarkScriptPreview,
|
||||
updateBenchmarkDraft,
|
||||
listBenchmarkMcpTools
|
||||
@@ -279,6 +280,18 @@ vi.mock('$lib/gen', async () => {
|
||||
}
|
||||
return runBenchmarkScriptPreview({ workspace: data.workspace, requestBody })
|
||||
},
|
||||
runScriptByPath: async (data: {
|
||||
workspace: string
|
||||
path: string
|
||||
requestBody?: Record<string, unknown>
|
||||
}) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? runBenchmarkScriptByPath({
|
||||
workspace: data.workspace,
|
||||
path: data.path,
|
||||
args: data.requestBody
|
||||
})
|
||||
: actual.JobService.runScriptByPath(data),
|
||||
runFlowByPath: async (data: {
|
||||
workspace: string
|
||||
path: string
|
||||
|
||||
@@ -1974,6 +1974,78 @@
|
||||
judgeChecklist:
|
||||
- deletes the deployed script via delete_workspace_item rather than a raw API endpoint
|
||||
|
||||
- id: global-test33-run-deployed-script-with-form
|
||||
prompt: |-
|
||||
Run the deployed script `f/evals/global/format_greeting` for me with the name "ada".
|
||||
initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
# A session chat is where the run card has a preview pane beside it; run_script
|
||||
# itself is offered in every chat.
|
||||
sessionChat: true
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- run_script
|
||||
# A draft may declare different arguments than the deployed version being run, so
|
||||
# the names to prefill have to come from the deployed schema.
|
||||
- read_workspace_item
|
||||
forbiddenToolsUsed:
|
||||
- test_run_script
|
||||
- call_api_endpoint
|
||||
- write_script
|
||||
- deploy_workspace_item
|
||||
# An empty form pushes the work back onto the user, so the prefill is part of
|
||||
# what the tool is for.
|
||||
toolCallArgs:
|
||||
- tool: run_script
|
||||
field: args.name
|
||||
stringIncludesAnyOf:
|
||||
- ada
|
||||
# Running produces no draft, and the judge cannot observe runs; validate via tool use.
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- runs the deployed script through run_script rather than a preview test run or a raw API endpoint
|
||||
- passes the name "ada" so the confirmation form comes up prefilled
|
||||
|
||||
- id: global-test34-run-with-secret-from-variable
|
||||
prompt: |-
|
||||
Run the deployed `f/evals/global/billing_sync` for the account `acme` — use the billing
|
||||
API token we already keep in the workspace.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/billing_sync_with_secret_arg.json
|
||||
runtime:
|
||||
maxTurns: 10
|
||||
# A session chat is where the run card has a preview pane beside it; run_script
|
||||
# itself is offered in every chat.
|
||||
sessionChat: true
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- run_script
|
||||
forbiddenToolsUsed:
|
||||
- write_script
|
||||
- deploy_workspace_item
|
||||
# A secret argument is filled by naming the variable that holds it: the value stays in
|
||||
# the variable and only its path travels. A literal reaches the job as a reference too,
|
||||
# minted on the way in, but it stays in the tool call the model emitted.
|
||||
toolCallArgs:
|
||||
- tool: run_script
|
||||
field: args.api_token
|
||||
stringIncludesAnyOf:
|
||||
- "$var:f/evals/global/stripe_api_token"
|
||||
- tool: run_script
|
||||
field: args.account
|
||||
stringIncludesAnyOf:
|
||||
- acme
|
||||
# Running produces no draft, and the judge cannot observe runs; validate via tool use.
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- fills the secret argument with a reference to the existing workspace variable rather than a literal token
|
||||
- passes the account "acme"
|
||||
- does not invent or guess the token's value
|
||||
|
||||
- id: global-undo-created-draft
|
||||
prompt: |-
|
||||
Create a draft Postgres resource at `u/admin/scratch_db` for host db.example.com port 5432, database `orders`, user `app`, and tell me what fields it ended up with.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"workspace": {
|
||||
"variables": [
|
||||
{
|
||||
"path": "f/evals/global/stripe_api_token",
|
||||
"value": "sk_live_do_not_leak_me",
|
||||
"is_secret": true,
|
||||
"description": "Token used by the billing sync job",
|
||||
"labels": ["billing"]
|
||||
}
|
||||
],
|
||||
"scripts": [
|
||||
{
|
||||
"path": "f/evals/global/billing_sync",
|
||||
"summary": "Sync billing records",
|
||||
"description": "Syncs billing records for one account, authenticating with an API token.",
|
||||
"language": "bun",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_token": {
|
||||
"type": "string",
|
||||
"password": true,
|
||||
"description": "API token to authenticate with"
|
||||
}
|
||||
},
|
||||
"required": ["account", "api_token"]
|
||||
},
|
||||
"content": "export async function main(account: string, api_token: string) {\n return `synced ${account}`\n}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+121
-121
@@ -1958,18 +1958,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bon"
|
||||
version = "3.10.0"
|
||||
version = "3.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e3fac94a66da67200398458a25412bcc3f9b6443b5119a6cad9cf3ccfcd8cc6"
|
||||
checksum = "60eafe0d77c3a2fc292c1d1346c3041b33c0a108085a2afabf672b70f69dbbc9"
|
||||
dependencies = [
|
||||
"bon-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bon-macros"
|
||||
version = "3.10.0"
|
||||
version = "3.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4654961ad0494e4774c5c60b4cb4cd0ae9b9d92d039d901638b1dba97ebebf5"
|
||||
checksum = "bd0f9631d8aaaee112c41985d675ef269e02acbd4f33122836af4f0c5f699ff6"
|
||||
dependencies = [
|
||||
"darling 0.24.1",
|
||||
"ident_case",
|
||||
@@ -6194,9 +6194,9 @@ checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
@@ -10326,11 +10326,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
@@ -10378,7 +10378,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"http 1.5.0",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"tower-service",
|
||||
@@ -10396,7 +10396,7 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"http 1.5.0",
|
||||
"hyper 1.11.1",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"reqwest-middleware",
|
||||
"retry-policies",
|
||||
"thiserror 2.0.20",
|
||||
@@ -10506,7 +10506,7 @@ dependencies = [
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rand 0.10.2",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"rmcp-macros",
|
||||
"schemars 1.2.2",
|
||||
"serde",
|
||||
@@ -11484,11 +11484,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.22.0"
|
||||
version = "3.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
|
||||
checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64 0.23.1",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -11505,14 +11505,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.22.0"
|
||||
version = "3.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
|
||||
checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"darling 0.24.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -14747,7 +14747,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14772,7 +14772,7 @@ dependencies = [
|
||||
"prometheus",
|
||||
"rand 0.9.0",
|
||||
"rdkafka",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"rumqttc",
|
||||
"rustls 0.23.35",
|
||||
"serde",
|
||||
@@ -14835,7 +14835,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -14851,7 +14851,7 @@ dependencies = [
|
||||
"http 1.5.0",
|
||||
"lazy_static",
|
||||
"mime_guess",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -14868,7 +14868,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14881,7 +14881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -14931,7 +14931,7 @@ dependencies = [
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"regex",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"rsa",
|
||||
"rust-embed",
|
||||
"rustls 0.23.35",
|
||||
@@ -15021,7 +15021,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15044,7 +15044,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15061,7 +15061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15071,7 +15071,7 @@ dependencies = [
|
||||
"jsonwebtoken 8.3.0",
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -15087,7 +15087,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15097,7 +15097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15114,7 +15114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
@@ -15136,7 +15136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15145,7 +15145,7 @@ dependencies = [
|
||||
"candle-transformers",
|
||||
"hf-hub",
|
||||
"lazy_static",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -15159,7 +15159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15175,7 +15175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15197,7 +15197,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15218,7 +15218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15232,7 +15232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15246,7 +15246,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"rand 0.9.0",
|
||||
"rdkafka",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"rmcp",
|
||||
"rumqttc",
|
||||
"serde",
|
||||
@@ -15267,7 +15267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15292,7 +15292,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15301,7 +15301,7 @@ dependencies = [
|
||||
"hex",
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"semver 1.0.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -15320,7 +15320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15342,7 +15342,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15362,7 +15362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15373,7 +15373,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"prometheus",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sql-builder",
|
||||
@@ -15400,7 +15400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15429,7 +15429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -15441,7 +15441,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -15465,7 +15465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15479,7 +15479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15514,7 +15514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -15528,7 +15528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15547,7 +15547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -15609,7 +15609,7 @@ dependencies = [
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"regex",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"reqwest-middleware",
|
||||
"reqwest-retry",
|
||||
"rsa",
|
||||
@@ -15653,7 +15653,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -15673,7 +15673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -15688,7 +15688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -15715,7 +15715,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15732,7 +15732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -15748,7 +15748,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15756,7 +15756,7 @@ dependencies = [
|
||||
"futures",
|
||||
"http 1.5.0",
|
||||
"oauth2",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"rmcp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -15769,7 +15769,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15781,7 +15781,7 @@ dependencies = [
|
||||
"http 1.5.0",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -15800,7 +15800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -15825,7 +15825,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -15843,7 +15843,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"object_store",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
@@ -15860,7 +15860,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15878,7 +15878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -15887,7 +15887,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15899,7 +15899,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15911,7 +15911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -15923,7 +15923,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15935,7 +15935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15947,7 +15947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -15958,7 +15958,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -15969,7 +15969,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -15981,7 +15981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -15992,7 +15992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16014,7 +16014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16026,7 +16026,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16040,7 +16040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16057,7 +16057,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16070,7 +16070,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16082,7 +16082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16100,7 +16100,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -16116,7 +16116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16132,7 +16132,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16146,7 +16146,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16167,7 +16167,7 @@ dependencies = [
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"regex",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
@@ -16185,7 +16185,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -16209,7 +16209,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"rcgen",
|
||||
"regex",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"rustls 0.23.35",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -16225,7 +16225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -16236,7 +16236,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16251,7 +16251,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"magic-crypt",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -16271,7 +16271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16295,7 +16295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16306,7 +16306,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"rand 0.9.0",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sql-builder",
|
||||
@@ -16328,7 +16328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-amqp"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16355,7 +16355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16370,7 +16370,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -16388,7 +16388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16408,7 +16408,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16423,7 +16423,7 @@ dependencies = [
|
||||
"jsonwebtoken 8.3.0",
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -16442,7 +16442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16478,7 +16478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16501,7 +16501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16525,7 +16525,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16549,7 +16549,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16584,7 +16584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16612,7 +16612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16637,7 +16637,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.1",
|
||||
@@ -16656,7 +16656,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -16712,7 +16712,7 @@ dependencies = [
|
||||
"rand 0.9.0",
|
||||
"rcgen",
|
||||
"regex",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest 0.13.5",
|
||||
"reqwest-middleware",
|
||||
"rsa",
|
||||
"rust_decimal",
|
||||
@@ -16774,7 +16774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -17474,18 +17474,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
version = "0.8.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.56"
|
||||
version = "0.8.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -88,7 +88,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
db8f60b66729b0e2a206f80905f667054bd9db69
|
||||
e45cc4160f2a179f3975923cbba21f5acd2ef9b2
|
||||
|
||||
+24
-24
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6274,7 +6274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6286,7 +6286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6295,7 +6295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6307,7 +6307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6319,7 +6319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6331,7 +6331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6343,7 +6343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6377,7 +6377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6400,7 +6400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6422,7 +6422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6434,7 +6434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6465,7 +6465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6478,7 +6478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6508,7 +6508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6524,7 +6524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6540,7 +6540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6586,7 +6586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.805.0"
|
||||
version = "1.808.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Guests are unavailable on the shared cloud (`CLOUD_HOSTED`).
|
||||
//!
|
||||
//! One test in its own binary on purpose: `CLOUD_HOSTED` is read once into a
|
||||
//! `lazy_static`, so it must be set before anything reads it and cannot be unset for a
|
||||
//! sibling test in the same process.
|
||||
//!
|
||||
//! Users from the `base` fixture:
|
||||
//! test-user (admin, token SECRET_TOKEN)
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
|
||||
const GUEST_TOKEN: &str = "GUEST_SECRET_TOKEN";
|
||||
const APP_PATH: &str = "u/test-user/guest_app";
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn the_cloud_admits_no_guest(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// Before the server starts, so the flag is what the whole process sees.
|
||||
unsafe { std::env::set_var("CLOUD_HOSTED", "true") };
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
// The workspace switch cannot be turned on, so no policy can lean on it.
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
assert!(
|
||||
resp.text().await?.contains("self-hosted"),
|
||||
"the refusal must name what guests need"
|
||||
);
|
||||
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&json!({
|
||||
"path": APP_PATH,
|
||||
"summary": "Guest app",
|
||||
"value": {},
|
||||
"policy": { "execution_mode": "guest", "triggerables": {} }
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400, "an app cannot be deployed to guests");
|
||||
|
||||
// Nor can a key be configured for the JWT way in — the refusal lands before the
|
||||
// outbound JWKS fetch it would otherwise make.
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_jwt_key")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "jwks_url": "https://issuer.example.com/.well-known/jwks.json" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400, "a guest JWT key cannot be configured");
|
||||
// Clearing one stays allowed: a key nobody can use is still worth removing.
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_jwt_key")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
|
||||
// An app already stored in guest mode — pushed by git-sync, or deployed before the
|
||||
// instance became a cloud one — advertises no entry either.
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&json!({
|
||||
"path": APP_PATH,
|
||||
"summary": "Guest app",
|
||||
"value": {},
|
||||
"policy": { "execution_mode": "publisher", "triggerables": {} }
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
||||
sqlx::query(
|
||||
"UPDATE app SET policy = jsonb_set(policy, '{execution_mode}', '\"guest\"')
|
||||
WHERE path = $1 AND workspace_id = 'test-workspace'",
|
||||
)
|
||||
.bind(APP_PATH)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query("UPDATE workspace_settings SET guest_access_enabled = true WHERE workspace_id = 'test-workspace'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Deploying it again is not refused: only widening an app into guests is, so a
|
||||
// git-sync push of one already stored that way keeps working (and keeps being inert).
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/apps/update/{APP_PATH}")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({
|
||||
"policy": { "execution_mode": "guest", "triggerables": {} }
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"an app already stored in guest mode must stay deployable: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let resp = authed(
|
||||
client().get(format!("{ws}/apps/secret_of/{APP_PATH}")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "reading the share secret must succeed");
|
||||
let secret: String = resp.text().await?;
|
||||
let resp = client()
|
||||
.get(format!("{ws}/apps_u/guest_entry/{secret}"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
404,
|
||||
"a guest app must not advertise entry where guests are unavailable"
|
||||
);
|
||||
|
||||
// And a session issued before the instance became a cloud one stops on its next
|
||||
// request: the door re-reads the switch, so the credential itself is not enough.
|
||||
sqlx::query(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, scopes, workspace_id, expiration)
|
||||
VALUES (encode(sha256($1::bytea), 'hex'), 'GUEST_SECR', $2, 'guest@example.com',
|
||||
'guest_session', $3, 'test-workspace', now() + interval '8 hours')",
|
||||
)
|
||||
.bind(GUEST_TOKEN.as_bytes())
|
||||
.bind(GUEST_TOKEN)
|
||||
.bind(vec![
|
||||
"guest".to_string(),
|
||||
"users:read".to_string(),
|
||||
format!("apps:read:{APP_PATH}"),
|
||||
format!("apps:run:{APP_PATH}"),
|
||||
])
|
||||
.execute(&db)
|
||||
.await?;
|
||||
// `whoami` is where an admitted guest resolves as `role: guest`, so a 401 here is
|
||||
// the door refusing the credential rather than a route saying no.
|
||||
let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"a guest session must not authenticate where guests are unavailable"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1485,3 +1485,44 @@ async fn declarative_sync_rejects_an_unusable_webhook_base_url(db: Pool<Postgres
|
||||
"the other settings in the same apply must not have been written either"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same contract for the announcement banner: this path owns its validation, and a value
|
||||
/// that lands here unchecked reaches every user's browser. A rejected banner must not be
|
||||
/// half-applied either.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn declarative_sync_rejects_an_unusable_instance_banner(db: Pool<Postgres>) {
|
||||
clear_settings_and_configs(&db).await;
|
||||
let before = count_global_settings(&db).await;
|
||||
|
||||
let mut desired = BTreeMap::new();
|
||||
desired.insert(
|
||||
"base_url".to_string(),
|
||||
serde_json::json!("https://wm.example.com"),
|
||||
);
|
||||
desired.insert(
|
||||
"instance_banner".to_string(),
|
||||
serde_json::json!({ "enabled": true, "message": "down", "link": "javascript:alert(1)" }),
|
||||
);
|
||||
|
||||
let err = windmill_common::instance_config::sync_global_settings_declarative(
|
||||
&db,
|
||||
&BTreeMap::new(),
|
||||
&desired,
|
||||
)
|
||||
.await
|
||||
.expect_err("a javascript: banner link must fail the sync");
|
||||
assert!(
|
||||
err.to_string().contains("instance_banner"),
|
||||
"the error should name the offending setting, got: {err}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
count_global_settings(&db).await,
|
||||
before,
|
||||
"validation must run before anything is applied"
|
||||
);
|
||||
assert!(
|
||||
get_global_setting(&db, "base_url").await.is_none(),
|
||||
"the other settings in the same apply must not have been written either"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -627,3 +627,121 @@ async fn test_root_job_span_relocated_to_inbound_trace() {
|
||||
expected_uuid_trace
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// RESOURCE ATTRIBUTES (OTEL_RESOURCE_ATTRIBUTES)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
fn resource_attrs() -> std::collections::HashMap<String, String> {
|
||||
otlp_service_resource(
|
||||
&windmill_common::utils::Mode::Worker,
|
||||
"fallback-host",
|
||||
"dev",
|
||||
)
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_otlp_resource_merges_env_attributes_without_losing_windmill_identity() {
|
||||
// These take precedence over the hostname argument and over OTEL_RESOURCE_ATTRIBUTES,
|
||||
// so clear them or an ambient one fails the assertions below for an unrelated reason.
|
||||
for var in [
|
||||
"OTEL_HOST_NAME",
|
||||
"OTEL_SERVICE_NAME",
|
||||
"OTEL_SERVICE_VERSION",
|
||||
] {
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
std::env::set_var(
|
||||
"OTEL_RESOURCE_ATTRIBUTES",
|
||||
"k8s.pod.uid=abc-123,service.name=injected,host.name=injected",
|
||||
);
|
||||
let attrs = resource_attrs();
|
||||
std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES");
|
||||
|
||||
// Attributes the deployment injects reach the exporters.
|
||||
assert_eq!(
|
||||
attrs.get("k8s.pod.uid").map(String::as_str),
|
||||
Some("abc-123")
|
||||
);
|
||||
// OTEL_RESOURCE_ATTRIBUTES is the secondary resource, so Windmill's own values still win.
|
||||
assert_eq!(
|
||||
attrs.get("service.name").map(String::as_str),
|
||||
Some("windmill-worker")
|
||||
);
|
||||
assert_eq!(
|
||||
attrs.get("host.name").map(String::as_str),
|
||||
Some("fallback-host")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_otlp_resource_dedicated_overrides_win() {
|
||||
// A deployment sets these per pod, e.g. from Kubernetes downward-API labels. The
|
||||
// competing service.name must lose: the spec ranks OTEL_SERVICE_NAME above it.
|
||||
std::env::set_var("OTEL_RESOURCE_ATTRIBUTES", "service.name=should-lose");
|
||||
std::env::set_var("OTEL_SERVICE_NAME", "windmill-workers");
|
||||
std::env::set_var("OTEL_SERVICE_VERSION", "1.802.0");
|
||||
std::env::set_var("OTEL_HOST_NAME", "pod-7");
|
||||
let overridden = resource_attrs();
|
||||
|
||||
// An empty value means unset, which is what the downward API yields for a missing label.
|
||||
for var in [
|
||||
"OTEL_SERVICE_NAME",
|
||||
"OTEL_SERVICE_VERSION",
|
||||
"OTEL_HOST_NAME",
|
||||
] {
|
||||
std::env::set_var(var, "");
|
||||
}
|
||||
let empty = resource_attrs();
|
||||
for var in [
|
||||
"OTEL_SERVICE_NAME",
|
||||
"OTEL_SERVICE_VERSION",
|
||||
"OTEL_HOST_NAME",
|
||||
"OTEL_RESOURCE_ATTRIBUTES",
|
||||
] {
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
let unset = resource_attrs();
|
||||
|
||||
assert_eq!(
|
||||
overridden.get("service.name").map(String::as_str),
|
||||
Some("windmill-workers")
|
||||
);
|
||||
assert_eq!(
|
||||
overridden.get("service.version").map(String::as_str),
|
||||
Some("1.802.0")
|
||||
);
|
||||
assert_eq!(
|
||||
overridden.get("host.name").map(String::as_str),
|
||||
Some("pod-7")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
empty.get("service.name").map(String::as_str),
|
||||
Some("windmill-worker")
|
||||
);
|
||||
assert_eq!(
|
||||
empty.get("host.name").map(String::as_str),
|
||||
Some("fallback-host")
|
||||
);
|
||||
assert_eq!(
|
||||
empty.get("service.version").map(String::as_str),
|
||||
Some(windmill_common::utils::GIT_VERSION)
|
||||
);
|
||||
|
||||
// With nothing set at all — the default deployment — SdkProvidedResourceDetector still
|
||||
// contributes service.name = "unknown_service". Ours has to overwrite it.
|
||||
assert_eq!(
|
||||
unset.get("service.name").map(String::as_str),
|
||||
Some("windmill-worker")
|
||||
);
|
||||
assert_eq!(
|
||||
unset.get("service.version").map(String::as_str),
|
||||
Some(windmill_common::utils::GIT_VERSION)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,11 +59,11 @@ use windmill_common::{
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
|
||||
DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
|
||||
GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
|
||||
HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES,
|
||||
RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING,
|
||||
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
|
||||
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
|
||||
WS_BASE_URL_SETTING,
|
||||
HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, INSTANCE_BANNER_SETTING,
|
||||
MAX_RETENTION_OVERRIDE_WORKSPACES, RETENTION_PERIOD_SECS_OVERRIDES_SETTING,
|
||||
RUFF_CONFIG_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING,
|
||||
WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING,
|
||||
WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WS_BASE_URL_SETTING,
|
||||
},
|
||||
instance_config::{self, ApplyMode, InstanceConfig},
|
||||
server::Smtp,
|
||||
@@ -1177,6 +1177,18 @@ async fn run_setting_pre_write_hook(
|
||||
}
|
||||
}
|
||||
}
|
||||
INSTANCE_BANNER_SETTING => {
|
||||
match value {
|
||||
// Clearing (delete row) is handled by the caller; allow it through.
|
||||
serde_json::Value::Null => {}
|
||||
serde_json::Value::String(s) if s.trim().is_empty() => {}
|
||||
v => {
|
||||
windmill_common::global_settings::validate_instance_banner(v).map_err(|e| {
|
||||
error::Error::BadRequest(format!("{INSTANCE_BANNER_SETTING}: {e}"))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1317,6 +1329,7 @@ pub async fn get_global_setting(
|
||||
&& key != APP_WORKSPACED_ROUTE_SETTING
|
||||
&& key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING
|
||||
&& key != WS_BASE_URL_SETTING
|
||||
&& key != INSTANCE_BANNER_SETTING
|
||||
{
|
||||
require_super_admin(&db, &authed).await?;
|
||||
}
|
||||
|
||||
@@ -4939,6 +4939,9 @@ async fn edit_guest_access(
|
||||
Json(EditGuestAccess { guest_access_enabled }): Json<EditGuestAccess>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
if guest_access_enabled {
|
||||
windmill_common::workspaces::require_guest_support()?;
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!(
|
||||
@@ -4995,6 +4998,11 @@ async fn edit_guest_jwt_key(
|
||||
"Set a PEM public key or a JWKS URL, not both".to_string(),
|
||||
));
|
||||
}
|
||||
// Clearing stays allowed wherever guests are: a key nobody can use is still worth
|
||||
// removing.
|
||||
if public_key.is_some() || jwks_url.is_some() {
|
||||
windmill_common::workspaces::require_guest_support()?;
|
||||
}
|
||||
if let Some(pem) = public_key.as_deref() {
|
||||
windmill_common::guest_jwt::decoding_key_from_pem(pem)?;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.805.0
|
||||
version: 1.808.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -6102,7 +6102,8 @@ paths:
|
||||
account; the `guest` app execution mode admits them. Off by default. Re-read
|
||||
where a guest session is minted and at the auth door on every guest request, so
|
||||
turning it off takes effect immediately, for sessions already issued and for
|
||||
apps whose policy already says `guest`.
|
||||
apps whose policy already says `guest`. Turning it *on* is refused with a 400
|
||||
where guests are unavailable (the shared cloud); turning it off always works.
|
||||
operationId: editGuestAccess
|
||||
tags:
|
||||
- workspace
|
||||
@@ -6137,7 +6138,8 @@ paths:
|
||||
URL, at most one. Both empty clears the workspace key; off cloud, verification then
|
||||
falls back to the instance issuer (`JWT_EXT_JWKS_URL`) if one is set, else no guest
|
||||
JWT is accepted (`guest_access_enabled` is the on/off switch). Workspace-admin gated.
|
||||
The key is validated before it is stored.
|
||||
The key is validated before it is stored. Setting a key is refused with a 400 where
|
||||
guests are unavailable (the shared cloud); clearing one always works.
|
||||
operationId: editGuestJwtKey
|
||||
tags:
|
||||
- workspace
|
||||
@@ -9339,7 +9341,8 @@ paths:
|
||||
description: >-
|
||||
The custom-path counterpart of `getGuestEntry`. Unauthenticated; 404 unless
|
||||
the app's execution mode is `guest` AND its workspace has
|
||||
`guest_access_enabled` AND the instance has not set `guest_access_disabled`.
|
||||
`guest_access_enabled` AND the instance has not set `guest_access_disabled`,
|
||||
and never on a deployment where guests are unavailable (the shared cloud).
|
||||
Returns the workspace too, since a custom URL may not carry it.
|
||||
operationId: getGuestEntryByCustomPath
|
||||
tags:
|
||||
@@ -13521,8 +13524,9 @@ paths:
|
||||
Unauthenticated: what a signed-out visitor reads to learn that signing in
|
||||
would let them in. 404 unless the app's execution mode is `guest` AND the
|
||||
workspace has `guest_access_enabled` AND the instance has not set the
|
||||
`guest_access_disabled` global setting, so it says nothing about apps that
|
||||
are not open to guests. Discloses only the app path, to a caller already
|
||||
`guest_access_disabled` global setting, and never on a deployment where guests
|
||||
are unavailable (the shared cloud), so it says nothing about apps that are not
|
||||
open to guests. Discloses only the app path, to a caller already
|
||||
holding the share secret.
|
||||
operationId: getGuestEntry
|
||||
tags:
|
||||
@@ -25982,6 +25986,30 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/projects:
|
||||
get:
|
||||
summary: list the hub's published projects
|
||||
description: |
|
||||
Forwards to the configured Hub's public project catalogue and returns its
|
||||
status code and raw response body. Readable by any workspace member: the
|
||||
listing is not workspace-scoped, and it is proxied only because the Hub's
|
||||
listing endpoint sends no CORS header. Refused with 400 when the instance
|
||||
has the Hub disabled, in which case no outbound request is made.
|
||||
operationId: listHubProjects
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
"400":
|
||||
description: the Hub is disabled on this instance
|
||||
|
||||
/w/{workspace}/hub/project:
|
||||
get:
|
||||
summary: get the hub project linked to a workspace folder
|
||||
@@ -29513,7 +29541,12 @@ components:
|
||||
to one seat: `billable_guests`, `guest_seats`); every other plan and build
|
||||
admits no new email until the count drops. `instance_enabled` is the superadmin
|
||||
switch (`guest_access_disabled` global setting) every workspace switch sits under.
|
||||
`available` is whether this deployment can have guests at all: false on the shared
|
||||
cloud, where guest access requires a self-hosted or dedicated deployment, and every
|
||||
other field and switch is then moot.
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
instance_enabled:
|
||||
type: boolean
|
||||
guest_count:
|
||||
@@ -29533,6 +29566,7 @@ components:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- available
|
||||
- instance_enabled
|
||||
- guest_count
|
||||
- window_days
|
||||
|
||||
@@ -343,6 +343,29 @@ fn refuse_unscopable_guest_app(path: &str, mode: ExecutionMode) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse *widening* an app into guests where the deployment has none
|
||||
/// (`instance_supports_guests`). Only the transition is refused, like the protection
|
||||
/// rule below it: an app already stored in the mode — deployed before the instance
|
||||
/// became a cloud one, or pushed by git-sync — keeps deploying, and keeps being inert,
|
||||
/// since every guest gate refuses it anyway. `deployed_mode` is what the app is stored
|
||||
/// as, `None` when it is being created.
|
||||
fn refuse_guest_mode_where_unavailable(
|
||||
path: &str,
|
||||
mode: ExecutionMode,
|
||||
deployed_mode: Option<ExecutionMode>,
|
||||
) -> Result<()> {
|
||||
if !matches!(mode, ExecutionMode::Guest)
|
||||
|| deployed_mode == Some(ExecutionMode::Guest)
|
||||
|| windmill_common::workspaces::instance_supports_guests()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(Error::BadRequest(format!(
|
||||
"app {path} cannot be set to Guests: {}",
|
||||
windmill_common::workspaces::GUESTS_UNAVAILABLE_MESSAGE
|
||||
)))
|
||||
}
|
||||
|
||||
/// Gate a viewer on the app's `execution_mode`, as far as can be decided without an
|
||||
/// ACL probe. `Ok(true)` means already authorized — anonymous admits anyone, guest
|
||||
/// admits anyone signed in; `Ok(false)` means the caller is a member and still owes
|
||||
@@ -2518,6 +2541,7 @@ async fn create_app_internal<'a>(
|
||||
// even when the caller did not.
|
||||
app.policy.set_execution_mode(app.policy.execution_mode());
|
||||
refuse_unscopable_guest_app(&app.path, app.policy.execution_mode())?;
|
||||
refuse_guest_mode_where_unavailable(&app.path, app.policy.execution_mode(), None)?;
|
||||
if let Some(rule) = deployment_rule_for_mode(app.policy.execution_mode()) {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
w_id,
|
||||
@@ -3565,6 +3589,13 @@ async fn update_app_internal<'a>(
|
||||
ns.path.as_deref().unwrap_or(path),
|
||||
npolicy.execution_mode(),
|
||||
)?;
|
||||
// An unreadable deployed policy reads as not already-in-mode, the strict
|
||||
// direction, as for the protection rule below.
|
||||
refuse_guest_mode_where_unavailable(
|
||||
ns.path.as_deref().unwrap_or(path),
|
||||
npolicy.execution_mode(),
|
||||
deployed_policy.as_ref().map(|d| d.execution_mode()),
|
||||
)?;
|
||||
if let Some(rule) =
|
||||
deployment_rule_for_mode(npolicy.execution_mode()).filter(|_| !authed.is_admin)
|
||||
{
|
||||
|
||||
@@ -6,13 +6,14 @@ use axum::{
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
Extension, Router,
|
||||
};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use windmill_common::{
|
||||
error::{to_anyhow, Error},
|
||||
global_settings::{load_value_from_global_settings, DISABLE_HUB_SETTING},
|
||||
utils::require_admin,
|
||||
HUB_BASE_URL,
|
||||
DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL,
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
@@ -48,6 +49,7 @@ pub fn workspaced_service() -> Router {
|
||||
post(discard_project_update),
|
||||
)
|
||||
.route("/project", get(get_project_by_source))
|
||||
.route("/projects", get(list_projects))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -548,6 +550,84 @@ async fn get_project_by_source(ctx: HubPublishCtx) -> Result<impl IntoResponse,
|
||||
ctx.get("/projects/by_source").await
|
||||
}
|
||||
|
||||
/// Whether this instance points at the public hub. Compared by parsed host rather than by the
|
||||
/// string: `hub_base_url` is stored as the operator typed it, so `http://`, a port, a trailing
|
||||
/// slash, a mixed-case scheme or host, userinfo and a trailing dot all name the same public
|
||||
/// host — and each spelling that failed to match would send a member's token there. Parsing is
|
||||
/// what `reqwest` does with the same string a line later, so this reads the host the request
|
||||
/// will actually go to.
|
||||
///
|
||||
/// A value that does not parse answers "not the public hub", so the caller attaches the token —
|
||||
/// harmless, because `reqwest` cannot build a request from that same value: it is rejected
|
||||
/// before a connection is opened, and the token never reaches a socket.
|
||||
fn is_public_hub(hub: &str) -> bool {
|
||||
fn host_of(url: &str) -> Option<String> {
|
||||
let parsed = url::Url::parse(url.trim()).ok()?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
parsed
|
||||
.host_str()?
|
||||
.trim_end_matches('.')
|
||||
.to_ascii_lowercase(),
|
||||
)
|
||||
}
|
||||
match (host_of(hub), host_of(DEFAULT_HUB_BASE_URL)) {
|
||||
(Some(host), Some(default_host)) => host == default_host,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// The hub's project catalogue. Read by any workspace member rather than through
|
||||
// `HubPublishCtx`, which requires an admin: nothing here is workspace-scoped or
|
||||
// publishing-related. It exists at all because the hub's listing endpoint sends no
|
||||
// CORS header, so the browser cannot read it directly the way it reads a single
|
||||
// project. `accept: application/json` is what makes the hub answer with JSON.
|
||||
//
|
||||
// The caller's token is sent only to a hub this instance was pointed at deliberately.
|
||||
// Every other route here is admin-only; this one is not, so forwarding a member's
|
||||
// bearer token to `hub.windmill.dev` would put a credential replayable against this
|
||||
// instance on a host outside it — for a listing that needs no credential at all.
|
||||
async fn list_projects(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Tokened { token }: Tokened,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
// `disable_hub` turns the hub off for a closed instance, and this handler makes an
|
||||
// outbound request. The frontend hides its entry points on the same setting, but that
|
||||
// is presentation: an authenticated member can call this route directly, so the refusal
|
||||
// has to live here.
|
||||
let disabled = load_value_from_global_settings(&db, DISABLE_HUB_SETTING)
|
||||
.await?
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if disabled {
|
||||
return Err(Error::BadRequest(
|
||||
"The hub is disabled on this instance".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let hub = (**HUB_BASE_URL.load()).clone();
|
||||
let url = format!("{}/projects", hub);
|
||||
let mut req = HTTP_CLIENT.get(&url).header("accept", "application/json");
|
||||
if !is_public_hub(&hub) {
|
||||
req = req.bearer_auth(&token);
|
||||
}
|
||||
let res = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("hub request failed: {e}")))?;
|
||||
|
||||
let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let text = res
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("hub response read failed: {e}")))?;
|
||||
|
||||
Ok((status, text))
|
||||
}
|
||||
|
||||
async fn submit_project(
|
||||
ctx: HubPublishCtx,
|
||||
Path((_workspace, slug)): Path<(String, ProjectSlug)>,
|
||||
@@ -645,3 +725,42 @@ async fn forward_to_hub<T: Serialize>(
|
||||
|
||||
Ok((status, text))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_public_hub;
|
||||
|
||||
#[test]
|
||||
fn public_hub_recognized_in_every_spelling() {
|
||||
// The predicate decides whether a workspace member's bearer token leaves the
|
||||
// instance, so both directions matter: a miss on the public hub sends the token
|
||||
// to windmill.dev, and a false match withholds it from a private hub that needs it.
|
||||
// Every spelling here is one `hub_base_url` can hold and `reqwest` will still send.
|
||||
for hub in [
|
||||
"https://hub.windmill.dev",
|
||||
"http://hub.windmill.dev/",
|
||||
"HTTPS://hub.windmill.dev",
|
||||
"https://HUB.WINDMILL.DEV",
|
||||
"https://hub.windmill.dev:443",
|
||||
"https://hub.windmill.dev.",
|
||||
"https://hub.windmill.dev/some/path",
|
||||
" https://hub.windmill.dev ",
|
||||
] {
|
||||
assert!(is_public_hub(hub), "{hub} should be the public hub");
|
||||
}
|
||||
for hub in [
|
||||
"https://hub.internal.example",
|
||||
"https://hub.windmill.dev.evil.example",
|
||||
"https://windmill.dev",
|
||||
// The host is what the request goes to, whatever precedes the `@`.
|
||||
"https://hub.windmill.dev@hub.internal.example",
|
||||
// Unparseable, or not a scheme a request can be built from. Grouped with the
|
||||
// private hubs because the caller then attaches the token, which is harmless here:
|
||||
// `reqwest` rejects the same value before opening a connection.
|
||||
"hub.windmill.dev",
|
||||
"ftp://hub.windmill.dev",
|
||||
] {
|
||||
assert!(!is_public_hub(hub), "{hub} should not be the public hub");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,112 @@ pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app";
|
||||
/// `base_url` when unset; set it when the browser-facing URL is not reachable
|
||||
/// from GitHub and a separate ingress fronts the API for inbound webhooks.
|
||||
pub const GITHUB_APP_WEBHOOK_BASE_URL_SETTING: &str = "github_app_webhook_base_url";
|
||||
/// Instance-wide announcement rendered above every page of the app (maintenance
|
||||
/// windows, incidents). Readable by any authenticated user, unlike most settings:
|
||||
/// the banner exists to be shown to everyone, so it must never hold anything the
|
||||
/// whole instance may not see.
|
||||
pub const INSTANCE_BANNER_SETTING: &str = "instance_banner";
|
||||
|
||||
/// Ceiling on the banner message. The banner is a one-or-two-line strip above every
|
||||
/// page, so anything longer is a layout accident rather than an announcement.
|
||||
pub const INSTANCE_BANNER_MESSAGE_MAX_LEN: usize = 500;
|
||||
|
||||
/// Ceiling on the banner's link label, which renders as a button inside that same strip.
|
||||
pub const INSTANCE_BANNER_LINK_LABEL_MAX_LEN: usize = 60;
|
||||
|
||||
/// Validate an [`INSTANCE_BANNER_SETTING`] value.
|
||||
///
|
||||
/// The banner is the one setting rendered to every user of the instance, so its
|
||||
/// shape is checked at the boundary rather than trusted from the writer: a value
|
||||
/// that reaches the browser malformed breaks the layout for everyone at once.
|
||||
///
|
||||
/// The link is restricted to http(s) so a stored `javascript:`/`data:` URL can
|
||||
/// never become the href of an anchor every user sees.
|
||||
///
|
||||
/// Only shapes that would *misrender* are rejected. An enabled banner with no message
|
||||
/// is left alone deliberately: it renders as nothing, and every write path here runs
|
||||
/// under the bulk settings save, so rejecting it would fail an admin's whole settings
|
||||
/// edit — retention, SMTP and all — over a half-typed announcement.
|
||||
pub fn validate_instance_banner(value: &serde_json::Value) -> Result<(), String> {
|
||||
let obj = value
|
||||
.as_object()
|
||||
.ok_or_else(|| "must be a JSON object".to_string())?;
|
||||
|
||||
// Field types are checked before their contents. Every read below is an `as_str`/
|
||||
// `as_bool`, which reports a wrong-typed field as absent — so without this a
|
||||
// `"link": 123` would skip the URL checks entirely and be stored, and the settings
|
||||
// form would then throw on it (`link.trim()` on a number) instead of rendering.
|
||||
for (field, expected, ok) in [
|
||||
(
|
||||
"enabled",
|
||||
"a boolean",
|
||||
obj.get("enabled").is_none_or(|v| v.is_boolean()),
|
||||
),
|
||||
(
|
||||
"dismissible",
|
||||
"a boolean",
|
||||
obj.get("dismissible").is_none_or(|v| v.is_boolean()),
|
||||
),
|
||||
(
|
||||
"message",
|
||||
"a string",
|
||||
obj.get("message").is_none_or(|v| v.is_string()),
|
||||
),
|
||||
(
|
||||
"severity",
|
||||
"a string",
|
||||
obj.get("severity").is_none_or(|v| v.is_string()),
|
||||
),
|
||||
(
|
||||
"link",
|
||||
"a string",
|
||||
obj.get("link").is_none_or(|v| v.is_string()),
|
||||
),
|
||||
(
|
||||
"link_label",
|
||||
"a string",
|
||||
obj.get("link_label").is_none_or(|v| v.is_string()),
|
||||
),
|
||||
] {
|
||||
if !ok {
|
||||
return Err(format!("{field} must be {expected}"));
|
||||
}
|
||||
}
|
||||
|
||||
for (field, max) in [
|
||||
("message", INSTANCE_BANNER_MESSAGE_MAX_LEN),
|
||||
("link_label", INSTANCE_BANNER_LINK_LABEL_MAX_LEN),
|
||||
] {
|
||||
let len = obj
|
||||
.get(field)
|
||||
.and_then(|v| v.as_str())
|
||||
.map_or(0, |s| s.chars().count());
|
||||
if len > max {
|
||||
return Err(format!("{field} must be at most {max} characters"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(severity) = obj.get("severity").and_then(|v| v.as_str()) {
|
||||
if !matches!(severity, "info" | "warning" | "error") {
|
||||
return Err("severity must be one of info, warning, error".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(link) = obj.get("link").and_then(|v| v.as_str()) {
|
||||
if !link.trim().is_empty() {
|
||||
let url = url::Url::parse(link.trim())
|
||||
.map_err(|e| format!("link must be an absolute http(s) URL: {e}"))?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return Err("link must use the http or https scheme".to_string());
|
||||
}
|
||||
if !url.has_host() {
|
||||
return Err("link must include a host".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a [`GITHUB_APP_WEBHOOK_BASE_URL_SETTING`] value.
|
||||
///
|
||||
@@ -590,6 +696,61 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_banner_rejects_unsafe_and_malformed_values() {
|
||||
// The link becomes the href of an anchor shown to every user of the instance,
|
||||
// so a non-http(s) scheme must not survive a write.
|
||||
for link in [
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"vbscript:msgbox(1)",
|
||||
"not-a-url",
|
||||
"https://",
|
||||
] {
|
||||
let banner = serde_json::json!({ "enabled": true, "message": "down", "link": link });
|
||||
assert!(
|
||||
validate_instance_banner(&banner).is_err(),
|
||||
"link '{link}' should be rejected"
|
||||
);
|
||||
}
|
||||
// A wrong-typed field reads as absent to every accessor here, so without an
|
||||
// explicit type check it would skip validation and be stored.
|
||||
for bad in [
|
||||
serde_json::json!({ "enabled": true, "message": "down", "link": 123 }),
|
||||
serde_json::json!({ "enabled": true, "message": "down", "link_label": ["a"] }),
|
||||
serde_json::json!({ "enabled": true, "message": { "text": "down" } }),
|
||||
serde_json::json!({ "enabled": true, "message": "down", "severity": 2 }),
|
||||
serde_json::json!({ "enabled": "yes", "message": "down" }),
|
||||
serde_json::json!({ "enabled": true, "message": "down", "dismissible": "no" }),
|
||||
] {
|
||||
assert!(
|
||||
validate_instance_banner(&bad).is_err(),
|
||||
"{bad} should be rejected"
|
||||
);
|
||||
}
|
||||
// The strip is one or two lines tall; both of its texts are bounded.
|
||||
for (field, over) in [
|
||||
("message", INSTANCE_BANNER_MESSAGE_MAX_LEN + 1),
|
||||
("link_label", INSTANCE_BANNER_LINK_LABEL_MAX_LEN + 1),
|
||||
] {
|
||||
let mut banner = serde_json::json!({ "enabled": true, "message": "down" });
|
||||
banner[field] = serde_json::Value::String("x".repeat(over));
|
||||
assert!(
|
||||
validate_instance_banner(&banner).is_err(),
|
||||
"an over-long {field} should be rejected"
|
||||
);
|
||||
}
|
||||
// Enabled with no message renders as nothing and must stay writable: every path
|
||||
// into this validator is a bulk settings save, so rejecting it would fail an
|
||||
// admin's unrelated edits over a half-typed announcement.
|
||||
assert!(validate_instance_banner(&serde_json::json!({ "enabled": true })).is_ok());
|
||||
let ok = serde_json::json!({
|
||||
"enabled": true, "message": "down", "severity": "warning",
|
||||
"link": "https://status.example.com", "dismissible": false
|
||||
});
|
||||
assert!(validate_instance_banner(&ok).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_base_url_matches_the_ui_validator() {
|
||||
// Kept in lockstep with `isValidWebhookBaseUrl` in
|
||||
|
||||
@@ -579,6 +579,15 @@ pub async fn jwks_key_for(url: &str, token: &str) -> Result<(DecodingKey, Vec<Al
|
||||
/// Verify `token` for `w_id` against whatever key the workspace configured. A PEM key
|
||||
/// ignores `kid`; a JWKS selects by it.
|
||||
pub async fn verify_for_workspace(db: &DB, w_id: &str, token: &str) -> Result<GuestJwtClaims> {
|
||||
// The admit check downstream refuses these anyway; refusing here keeps a deployment
|
||||
// with no guests from parsing attacker-supplied JWTs at all, and names the reason in
|
||||
// the log the caller writes.
|
||||
if !crate::workspaces::instance_supports_guests() {
|
||||
return Err(Error::NotAuthorized(format!(
|
||||
"guest JWT refused: {}",
|
||||
crate::workspaces::GUESTS_UNAVAILABLE_MESSAGE
|
||||
)));
|
||||
}
|
||||
if token.len() > MAX_GUEST_JWT_LEN {
|
||||
return Err(Error::NotAuthorized(format!(
|
||||
"guest JWT refused: token is longer than {MAX_GUEST_JWT_LEN} bytes"
|
||||
|
||||
@@ -1338,6 +1338,16 @@ pub async fn sync_global_settings_declarative(
|
||||
}
|
||||
}
|
||||
|
||||
let banner_key = crate::global_settings::INSTANCE_BANNER_SETTING;
|
||||
match desired.get(banner_key) {
|
||||
None | Some(serde_json::Value::Null) => {}
|
||||
Some(serde_json::Value::String(s)) if s.trim().is_empty() => {}
|
||||
Some(banner) => crate::global_settings::validate_instance_banner(banner)
|
||||
// The validator's messages name the offending field and its expected type,
|
||||
// never the submitted value, so they are safe to surface here.
|
||||
.map_err(|e| anyhow::anyhow!("{banner_key}: {e}"))?,
|
||||
}
|
||||
|
||||
let diff = diff_global_settings(current, desired, ApplyMode::Replace);
|
||||
apply_settings_diff(db, &diff).await?;
|
||||
|
||||
|
||||
@@ -250,23 +250,37 @@ pub async fn get_full_hub_script_by_path(
|
||||
let version = path_iterator
|
||||
.next()
|
||||
.ok_or_else(|| Error::internal_err(format!("expected hub path to have version number")))?;
|
||||
// A cache entry that cannot be read or parsed counts as a miss rather than an error:
|
||||
// a truncated write leaves a file that exists but deserializes to nothing, and refetching
|
||||
// it is always preferable to failing the job push it was read for.
|
||||
let cache_path = format!("{}/{version}", *HUB_CACHE_DIR);
|
||||
let script;
|
||||
if tokio::fs::metadata(&cache_path).await.is_err() {
|
||||
script = get_full_hub_script_by_path_inner(path, http_client, db).await?;
|
||||
if let Err(e) = crate::worker::write_file(
|
||||
&HUB_CACHE_DIR,
|
||||
&version,
|
||||
&serde_json::to_string(&script).map_err(to_anyhow)?,
|
||||
) {
|
||||
tracing::error!("failed to write hub script {path} to cache: {e}");
|
||||
} else {
|
||||
tracing::info!("wrote hub script {path} to cache");
|
||||
let cached = match tokio::fs::read_to_string(&cache_path).await {
|
||||
Ok(content) => serde_json::from_str::<HubScript>(&content)
|
||||
.inspect_err(|e| {
|
||||
tracing::error!("hub script cache at {cache_path} is unparseable, refetching: {e}")
|
||||
})
|
||||
.ok(),
|
||||
Err(e) => {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
tracing::error!("hub script cache at {cache_path} is unreadable, refetching: {e}");
|
||||
}
|
||||
None
|
||||
}
|
||||
} else {
|
||||
let cache_content = tokio::fs::read_to_string(cache_path).await?;
|
||||
script = serde_json::from_str(&cache_content).unwrap();
|
||||
};
|
||||
if let Some(script) = cached {
|
||||
tracing::info!("read hub script {path} from cache");
|
||||
return Ok(script);
|
||||
}
|
||||
|
||||
let script = get_full_hub_script_by_path_inner(path, http_client, db).await?;
|
||||
if let Err(e) = crate::worker::write_file(
|
||||
&HUB_CACHE_DIR,
|
||||
&version,
|
||||
&serde_json::to_string(&script).map_err(to_anyhow)?,
|
||||
) {
|
||||
tracing::error!("failed to write hub script {path} to cache: {e}");
|
||||
} else {
|
||||
tracing::info!("wrote hub script {path} to cache");
|
||||
}
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
@@ -2574,6 +2574,42 @@ pub fn split_python_requirements<T: AsRef<str>>(requirements: T) -> Vec<String>
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Byte offset of the comment marker, per pip's rule: a `#` at line start or preceded by
|
||||
/// whitespace. A `#` elsewhere belongs to the requirement (`pkg @ https://h/p.whl#sha256=…`).
|
||||
fn requirement_comment_start(line: &str) -> Option<usize> {
|
||||
line.char_indices()
|
||||
.find(|(i, c)| *c == '#' && (*i == 0 || line[..*i].ends_with(char::is_whitespace)))
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// The installable requirement carried by one lockfile line, or `None` for a comment, a
|
||||
/// `-r`/`-e`/`--flag` directive, or a blank.
|
||||
///
|
||||
/// Windmill installs a lockfile one entry at a time as a `uv pip install` argument, so
|
||||
/// requirements-file syntax a file-level parser would absorb is an unparseable package name
|
||||
/// here and has to be stripped first.
|
||||
pub fn requirement_from_lockfile_line(line: &str) -> Option<&str> {
|
||||
let requirement = match requirement_comment_start(line) {
|
||||
Some(i) => &line[..i],
|
||||
None => line,
|
||||
}
|
||||
.trim()
|
||||
// Continuations are stripped, not joined: right for `--generate-hashes` locks, whose
|
||||
// continued lines are `--hash=` flags this function drops, but a lock continuing onto a
|
||||
// marker or extra would lose it.
|
||||
.trim_end_matches('\\')
|
||||
.trim_end();
|
||||
|
||||
(!requirement.is_empty() && !requirement.starts_with('-')).then_some(requirement)
|
||||
}
|
||||
|
||||
/// Whether a lockfile line continues onto the next one. The continued lines reach the
|
||||
/// installer as entries of their own rather than being joined, so a caller that cares what
|
||||
/// they carried — `--hash=` pins, for a `--generate-hashes` lock — has to say so itself.
|
||||
pub fn lockfile_line_has_continuation(line: &str) -> bool {
|
||||
line.trim_end().ends_with('\\')
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Clone, Copy, Default, Debug)]
|
||||
#[repr(u32)]
|
||||
pub enum PyVAlias {
|
||||
@@ -2730,6 +2766,52 @@ mod tests {
|
||||
ids.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Fixtures are verbatim `uv pip compile` output (uv 0.11.28): split and inline
|
||||
/// annotation styles, and `--generate-hashes`.
|
||||
#[test]
|
||||
fn test_requirement_from_lockfile_line() {
|
||||
assert_eq!(requirement_from_lockfile_line(" # via httpx"), None);
|
||||
assert_eq!(requirement_from_lockfile_line(" # via"), None);
|
||||
assert_eq!(requirement_from_lockfile_line(" # anyio"), None);
|
||||
assert_eq!(
|
||||
requirement_from_lockfile_line(" # via -r .tmp/requirements.in"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_from_lockfile_line("anyio==4.15.1 \\"),
|
||||
Some("anyio==4.15.1")
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_from_lockfile_line(
|
||||
" --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7 \\"
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(requirement_from_lockfile_line("# py: 3.11"), None);
|
||||
assert_eq!(requirement_from_lockfile_line("-r other.txt"), None);
|
||||
assert_eq!(
|
||||
requirement_from_lockfile_line("--index-url https://x"),
|
||||
None
|
||||
);
|
||||
assert_eq!(requirement_from_lockfile_line(" "), None);
|
||||
assert_eq!(
|
||||
requirement_from_lockfile_line("httpx==0.27.0"),
|
||||
Some("httpx==0.27.0")
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_from_lockfile_line("httpx==0.27.0 # via -r requirements.in"),
|
||||
Some("httpx==0.27.0")
|
||||
);
|
||||
// A `#` not preceded by whitespace is part of the requirement, not a comment.
|
||||
assert_eq!(
|
||||
requirement_from_lockfile_line("wmill @ https://h/wmill.whl#sha256=abc"),
|
||||
Some("wmill @ https://h/wmill.whl#sha256=abc")
|
||||
);
|
||||
|
||||
assert!(lockfile_line_has_continuation("anyio==4.15.1 \\"));
|
||||
assert!(!lockfile_line_has_continuation("anyio==4.15.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_job_oom_score_adj() {
|
||||
assert_eq!(parse_job_oom_score_adj(Some("300")), 300);
|
||||
|
||||
@@ -864,8 +864,12 @@ pub async fn guest_count_in_window<'c, E: sqlx::Executor<'c, Database = sqlx::Po
|
||||
/// The instance's standing against the guest allowance, as every surface reports it.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct GuestUsage {
|
||||
/// Whether this deployment can admit guests at all ([`instance_supports_guests`]).
|
||||
/// Off, every other field is moot and no switch below can turn guests on.
|
||||
pub available: bool,
|
||||
/// The superadmin switch (`GUEST_ACCESS_DISABLED_SETTING`), which every workspace
|
||||
/// switch sits under.
|
||||
/// switch sits under. Reported as stored, so a superadmin sees what they set even
|
||||
/// where `available` overrules it.
|
||||
pub instance_enabled: bool,
|
||||
/// Distinct guest emails over the trailing `window_days`.
|
||||
pub guest_count: i64,
|
||||
@@ -878,9 +882,33 @@ pub struct GuestUsage {
|
||||
pub guest_seats: i64,
|
||||
}
|
||||
|
||||
/// SQL for "the instance admits guests": the superadmin switch, absent meaning on. The
|
||||
/// setting is read as text before the cast so `true` and `"true"` both count.
|
||||
fn instance_admits_guests_sql() -> String {
|
||||
/// What a caller is told when it asks for guests on a deployment that cannot have them.
|
||||
pub const GUESTS_UNAVAILABLE_MESSAGE: &str =
|
||||
"Guest access is not available on Windmill Cloud. It requires a self-hosted instance \
|
||||
or a dedicated Windmill Cloud deployment.";
|
||||
|
||||
/// Whether guests can exist on this deployment at all. They cannot on the shared cloud:
|
||||
/// a guest is an identity Windmill itself never vouched for, admitted on the say-so of
|
||||
/// whoever runs the instance, which is not a call a multi-tenant deployment can make for
|
||||
/// its tenants. Folded into every guest gate below, so a workspace switch or an app
|
||||
/// policy left saying `guest` is inert rather than honored.
|
||||
pub fn instance_supports_guests() -> bool {
|
||||
!*crate::worker::CLOUD_HOSTED
|
||||
}
|
||||
|
||||
/// [`instance_supports_guests`] as an error, for the writes that would otherwise store a
|
||||
/// setting that can never take effect.
|
||||
pub fn require_guest_support() -> Result<()> {
|
||||
if instance_supports_guests() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BadRequest(GUESTS_UNAVAILABLE_MESSAGE.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// SQL for the superadmin switch alone, absent meaning on. The setting is read as text
|
||||
/// before the cast so `true` and `"true"` both count.
|
||||
fn instance_switch_sql() -> String {
|
||||
format!(
|
||||
"NOT COALESCE((SELECT (value #>> '{{}}')::boolean FROM global_settings \
|
||||
WHERE name = '{}'), false)",
|
||||
@@ -888,9 +916,18 @@ fn instance_admits_guests_sql() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// SQL for "the instance admits guests": the superadmin switch, under
|
||||
/// [`instance_supports_guests`].
|
||||
fn instance_admits_guests_sql() -> String {
|
||||
if !instance_supports_guests() {
|
||||
return "false".to_string();
|
||||
}
|
||||
instance_switch_sql()
|
||||
}
|
||||
|
||||
pub async fn guest_usage(db: &crate::DB) -> Result<GuestUsage> {
|
||||
let instance_admits = instance_admits_guests_sql();
|
||||
let instance_enabled: bool = sqlx::query_scalar(&format!("SELECT {instance_admits}"))
|
||||
let instance_switch = instance_switch_sql();
|
||||
let instance_enabled: bool = sqlx::query_scalar(&format!("SELECT {instance_switch}"))
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("reading the instance guest switch: {e:#}")))?;
|
||||
@@ -902,6 +939,7 @@ pub async fn guest_usage(db: &crate::DB) -> Result<GuestUsage> {
|
||||
0
|
||||
};
|
||||
Ok(GuestUsage {
|
||||
available: instance_supports_guests(),
|
||||
instance_enabled,
|
||||
guest_count,
|
||||
window_days: GUEST_WINDOW_DAYS,
|
||||
|
||||
@@ -37,8 +37,9 @@ use windmill_common::{
|
||||
scripts::ScriptLang,
|
||||
utils::calculate_hash,
|
||||
worker::{
|
||||
copy_dir_recursively, is_allowed_file_location, pad_string, split_python_requirements,
|
||||
write_file, Connection, PyVAlias, PythonAnnotations, WORKER_CONFIG,
|
||||
copy_dir_recursively, is_allowed_file_location, lockfile_line_has_continuation, pad_string,
|
||||
requirement_from_lockfile_line, split_python_requirements, write_file, Connection,
|
||||
PyVAlias, PythonAnnotations, WORKER_CONFIG,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -227,9 +228,9 @@ fn filter_pip_local_dependencies(lines: Vec<String>) -> (Vec<String>, Vec<String
|
||||
/// `(kept, ignored)`. A line is ignored when it is not a `#` comment and matches any of
|
||||
/// `compiled_deps`. Kept separate from config/regex loading so it can be unit-tested.
|
||||
fn filter_lines_by_deps(lines: Vec<String>, compiled_deps: &[Regex]) -> (Vec<String>, Vec<String>) {
|
||||
let (ignored, kept): (Vec<String>, Vec<String>) = lines
|
||||
.into_iter()
|
||||
.partition(|s| !s.starts_with('#') && compiled_deps.iter().any(|dep| dep.is_match(s)));
|
||||
let (ignored, kept): (Vec<String>, Vec<String>) = lines.into_iter().partition(|s| {
|
||||
!s.trim_start().starts_with('#') && compiled_deps.iter().any(|dep| dep.is_match(s))
|
||||
});
|
||||
|
||||
(kept, ignored)
|
||||
}
|
||||
@@ -323,6 +324,11 @@ pub async fn uv_pip_compile(
|
||||
"compile",
|
||||
"-q",
|
||||
"--no-header",
|
||||
// The `#`-line filter applied to the output below only catches whole-line
|
||||
// annotations, and uv's annotation style is configurable: `[pip]
|
||||
// annotation-style = "line"` in the worker HOME's uv.toml emits them inline
|
||||
// ("anyio==4.15.1 # via httpx"), which that filter keeps.
|
||||
"--no-annotate",
|
||||
file,
|
||||
"--strip-extras",
|
||||
"-o",
|
||||
@@ -2520,11 +2526,23 @@ pub async fn handle_python_reqs(
|
||||
// Find out if there is already cached dependencies
|
||||
// If so, skip them
|
||||
let mut in_cache = vec![];
|
||||
if requirements
|
||||
.iter()
|
||||
.any(|r| lockfile_line_has_continuation(r))
|
||||
{
|
||||
tracing::warn!(workspace_id = %w_id, job_id = %job_id, "lockfile continues entries across lines; the continued lines are dropped");
|
||||
append_logs(
|
||||
job_id,
|
||||
w_id,
|
||||
"\n[!] lockfile continues entries across lines and the continued lines are dropped: `--hash=` pins, extras and markers written that way do not apply\n".to_string(),
|
||||
conn,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
for req in &requirements {
|
||||
// Ignore python version annotation backed into lockfile
|
||||
if req.starts_with('#') || req.starts_with('-') || req.trim().is_empty() {
|
||||
let Some(req) = requirement_from_lockfile_line(req) else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let py_prefix = &py_version.to_cache_dir(false);
|
||||
|
||||
let venv_p = format!(
|
||||
|
||||
+1
-1
@@ -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.805.0";
|
||||
export const VERSION = "v1.808.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
|
||||
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
|
||||
// dependency (main → workspace → utils → main) that triggers a TDZ.
|
||||
// Re-exported from main.ts for backwards compatibility.
|
||||
export const VERSION = "1.805.0";
|
||||
export const VERSION = "1.808.0";
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
|
||||
without any identifying data leaving the instance.
|
||||
|
||||
It currently carries 42 registered actions across seventeen features (`ai_session`, `ai_chat`,
|
||||
It currently carries 48 registered actions across eighteen features (`ai_session`, `ai_chat`,
|
||||
`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `flow_editor`, `flow_run`,
|
||||
`flow_step`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`, `usage_meter`,
|
||||
`sso_groups_claim`). Nearly all of the
|
||||
`flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`,
|
||||
`usage_meter`, `sso_groups_claim`). Nearly all of the
|
||||
product is uninstrumented, so new user-facing work is the opportunity to change that.
|
||||
|
||||
## When to instrument
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.805.0",
|
||||
"version": "1.808.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.805.0",
|
||||
"version": "1.808.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -5579,9 +5579,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/driver.js": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.3.6.tgz",
|
||||
"integrity": "sha512-g2nNuu+tWmPpuoyk3ffpT9vKhjPz4NrJzq6mkRDZIwXCrFhrKdDJ9TX5tJOBpvCTBrBYjgRQ17XlcQB15q4gMg==",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.8.0.tgz",
|
||||
"integrity": "sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dts-bundle-generator": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.805.0",
|
||||
"version": "1.808.0",
|
||||
"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",
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TutorialRouter from './tutorials/TutorialRouter.svelte'
|
||||
import BackgroundRunnablesTutorial from './tutorials/app/BackgroundRunnablesTutorial.svelte'
|
||||
import ConnectionTutorial from './tutorials/app/ConnectionTutorial.svelte'
|
||||
|
||||
let tutorialRouter: TutorialRouter | undefined = $state(undefined)
|
||||
|
||||
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
|
||||
tutorialRouter?.runTutorialById(id, options)
|
||||
}
|
||||
</script>
|
||||
|
||||
<TutorialRouter
|
||||
bind:this={tutorialRouter}
|
||||
tutorials={[
|
||||
{
|
||||
id: 'backgroundrunnables',
|
||||
component: BackgroundRunnablesTutorial,
|
||||
name: 'backgroundrunnables',
|
||||
supportsSkipSteps: true
|
||||
},
|
||||
{
|
||||
id: 'connection',
|
||||
component: ConnectionTutorial,
|
||||
name: 'connection',
|
||||
supportsSkipSteps: true
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -1078,6 +1078,7 @@
|
||||
{otherArgs}
|
||||
{helperScript}
|
||||
{workspace}
|
||||
{disabled}
|
||||
bind:value
|
||||
format={format ?? ''}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Modal2 from './common/modal/Modal2.svelte'
|
||||
import NewDataTableMigrationModal from './workspaceSettings/NewDataTableMigrationModal.svelte'
|
||||
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
|
||||
import { splitSqlStatements, isDdlStatement } from './sqlDdl'
|
||||
import { joinSqlStatements, splitSqlRuns } from './sqlDdl'
|
||||
import { logDdlGuardChoice } from './workspaceSettings/datatableTelemetry'
|
||||
import { CornerDownLeft } from 'lucide-svelte'
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
type Choice = 'run' | 'migrate' | 'cancel'
|
||||
|
||||
let promptStatement = $state<string | undefined>(undefined)
|
||||
let promptStatements = $state<string[]>([])
|
||||
let promptOpen = $state(false)
|
||||
let resolvePrompt: ((choice: Choice) => void) | undefined = undefined
|
||||
let resolveMigrationClosed: ((created: boolean) => void) | undefined = undefined
|
||||
@@ -23,11 +23,15 @@
|
||||
// toast action after a migration is created here.
|
||||
let migrationsModal = $state<DataTableMigrationsButton | undefined>(undefined)
|
||||
|
||||
// The block is shown as-is in the prompt and becomes the migration body, where
|
||||
// every statement inside the BEGIN; ... END; frame must be `;`-terminated.
|
||||
let promptSql = $derived(joinSqlStatements(promptStatements))
|
||||
|
||||
function finishPrompt(choice: Choice) {
|
||||
const r = resolvePrompt
|
||||
resolvePrompt = undefined
|
||||
promptOpen = false
|
||||
promptStatement = undefined
|
||||
promptStatements = []
|
||||
r?.(choice)
|
||||
}
|
||||
|
||||
@@ -49,10 +53,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function promptDdl(statement: string): Promise<Choice> {
|
||||
function promptDdl(statements: string[]): Promise<Choice> {
|
||||
return new Promise((resolve) => {
|
||||
resolvePrompt = resolve
|
||||
promptStatement = statement
|
||||
promptStatements = statements
|
||||
promptOpen = true
|
||||
})
|
||||
}
|
||||
@@ -66,49 +70,51 @@
|
||||
|
||||
// Open the prefilled new-migration modal. Resolves with whether a migration
|
||||
// was actually created (false if the user cancelled / closed it).
|
||||
function openMigrationModal(statement: string): Promise<boolean> {
|
||||
function openMigrationModal(sql: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
resolveMigrationClosed = (created: boolean) => resolve(created)
|
||||
newMigrationModal?.open({ codeUp: statement })
|
||||
newMigrationModal?.open({ codeUp: sql })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect `code` for DDL statements. For each one, prompt the user to run it
|
||||
* anyway or turn it into a migration (prompts shown one at a time). Returns
|
||||
* whether to proceed and the code to run (with migrated statements stripped).
|
||||
* Inspect `code` for DDL statements. Each run of adjacent DDL statements is
|
||||
* prompted for once (runs shown one at a time) and becomes a single migration,
|
||||
* so a chain of schema changes applies in one transaction instead of asking
|
||||
* once per statement. Returns whether to proceed and the code to run (with
|
||||
* migrated statements stripped).
|
||||
*/
|
||||
export async function guard(
|
||||
code: string
|
||||
): Promise<{ proceed: boolean; code: string; ranMigration: boolean }> {
|
||||
migrationRan = false
|
||||
const statements = splitSqlStatements(code)
|
||||
if (!statements.some((s) => isDdlStatement(s))) {
|
||||
const runs = splitSqlRuns(code)
|
||||
if (!runs.some((r) => r.isDdl)) {
|
||||
return { proceed: true, code, ranMigration: false }
|
||||
}
|
||||
|
||||
const kept: string[] = []
|
||||
for (const statement of statements) {
|
||||
if (!isDdlStatement(statement)) {
|
||||
kept.push(statement)
|
||||
for (const run of runs) {
|
||||
if (!run.isDdl) {
|
||||
kept.push(...run.statements)
|
||||
continue
|
||||
}
|
||||
// Re-prompt for this statement until the user makes a terminal choice;
|
||||
// Re-prompt for this run until the user makes a terminal choice;
|
||||
// cancelling the migration modal returns to the prompt with the DDL intact.
|
||||
for (;;) {
|
||||
const choice = await promptDdl(statement)
|
||||
const choice = await promptDdl(run.statements)
|
||||
if (choice === 'cancel') {
|
||||
logDdlGuardChoice('cancelled')
|
||||
return { proceed: false, code, ranMigration: migrationRan }
|
||||
}
|
||||
if (choice === 'run') {
|
||||
logDdlGuardChoice('run_anyway')
|
||||
kept.push(statement)
|
||||
kept.push(...run.statements)
|
||||
break
|
||||
}
|
||||
// migrate: only strip the statement once a migration is actually
|
||||
// migrate: only strip the statements once a migration is actually
|
||||
// created; if the modal was cancelled, loop back to the prompt.
|
||||
const created = await openMigrationModal(statement)
|
||||
const created = await openMigrationModal(joinSqlStatements(run.statements))
|
||||
if (created) {
|
||||
logDdlGuardChoice('migrated')
|
||||
break
|
||||
@@ -116,14 +122,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
return { proceed: true, code: kept.join(';\n'), ranMigration: migrationRan }
|
||||
return { proceed: true, code: joinSqlStatements(kept), ranMigration: migrationRan }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
|
||||
<Modal2
|
||||
title="Schema change detected"
|
||||
title={promptStatements.length > 1 ? 'Schema changes detected' : 'Schema change detected'}
|
||||
fixedWidth="md"
|
||||
fixedHeight="adaptive"
|
||||
bind:isOpen={promptOpen}
|
||||
@@ -131,12 +137,17 @@
|
||||
>
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<p class="text-sm text-secondary">
|
||||
This looks like a schema-changing (DDL) statement. Schema changes are best tracked as
|
||||
migrations rather than run ad-hoc. Create a migration for it instead?
|
||||
{#if promptStatements.length > 1}
|
||||
These {promptStatements.length} consecutive statements are schema-changing (DDL). Schema changes
|
||||
are best tracked as migrations rather than run ad-hoc. Create a single migration for them instead?
|
||||
{:else}
|
||||
This looks like a schema-changing (DDL) statement. Schema changes are best tracked as
|
||||
migrations rather than run ad-hoc. Create a migration for it instead?
|
||||
{/if}
|
||||
</p>
|
||||
<pre
|
||||
class="text-xs whitespace-pre-wrap font-mono bg-surface-secondary rounded p-3 max-h-48 overflow-auto"
|
||||
>{promptStatement ?? ''}</pre
|
||||
>{promptSql}</pre
|
||||
>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="default" size="sm" on:click={() => finishPrompt('run')}>Run anyway</Button>
|
||||
|
||||
@@ -1245,7 +1245,6 @@
|
||||
<FlowModuleSchemaMap
|
||||
bind:this={flowModuleSchemaMap}
|
||||
disableAi
|
||||
disableTutorials
|
||||
smallErrorHandler={true}
|
||||
disableStaticInputs
|
||||
localModuleStates={showJobStatus ? localModuleStates : {}}
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
name: string
|
||||
/** Workspace the helper script runs in; defaults to the nav workspace. */
|
||||
workspace?: string
|
||||
/** Reaches the fallback editor too, which is what renders when there is no
|
||||
* `helperScript` — a caller disabling this argument means all of it. */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -42,7 +45,8 @@
|
||||
helperScript,
|
||||
format,
|
||||
otherArgs: otherArgs,
|
||||
workspace = undefined
|
||||
workspace = undefined,
|
||||
disabled = false
|
||||
}: Props = $props()
|
||||
|
||||
let [inputType, entrypoint] = $derived(format.includes('-') ? format.split('-', 2) : [format, ''])
|
||||
@@ -190,7 +194,7 @@
|
||||
items={safeSelectItems(items || [])}
|
||||
placeholder="Select items"
|
||||
noItemsMsg={_items.status === 'loading' ? 'Loading...' : 'No items found'}
|
||||
disabled={_items.status === 'loading'}
|
||||
disabled={disabled || _items.status === 'loading'}
|
||||
/>
|
||||
{:else if inputType === 'dynselect'}
|
||||
<Select
|
||||
@@ -199,6 +203,7 @@
|
||||
{items}
|
||||
bind:filterText
|
||||
loading={!open && _items.status === 'loading'}
|
||||
{disabled}
|
||||
clearable
|
||||
noItemsMsg={_items.status === 'loading' ? 'Loading...' : 'No items found'}
|
||||
/>
|
||||
@@ -222,7 +227,7 @@
|
||||
{#await import('$lib/components/JsonEditor.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default code={JSON.stringify(value, null, 2)} bind:value />
|
||||
<Module.default code={JSON.stringify(value, null, 2)} {disabled} bind:value />
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
||||
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
|
||||
|
||||
import { createRawSnippet, getContext, setContext, untrack } from 'svelte'
|
||||
import { getContext, setContext, untrack } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import CenteredPage from './CenteredPage.svelte'
|
||||
import { Button } from './common'
|
||||
@@ -64,30 +64,13 @@
|
||||
import { setNoteEditorContext } from './graph/noteEditor.svelte'
|
||||
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
|
||||
import { cleanFlow } from './flows/utils.svelte'
|
||||
import {
|
||||
DiffIcon,
|
||||
HistoryIcon,
|
||||
FileJson,
|
||||
Settings,
|
||||
Undo,
|
||||
Redo,
|
||||
BookOpen,
|
||||
Circle,
|
||||
CheckCircle,
|
||||
RefreshCw,
|
||||
CheckCheck,
|
||||
Disc
|
||||
} from 'lucide-svelte'
|
||||
import { DiffIcon, HistoryIcon, FileJson, Settings, Undo, Redo, Disc } from 'lucide-svelte'
|
||||
import Awareness from './Awareness.svelte'
|
||||
import { getAllModules } from './flows/flowExplorer'
|
||||
import { type FlowCopilotContext } from './copilot/flow'
|
||||
import { loadFlowModuleState } from './flows/flowStateUtils.svelte'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import FlowTutorials from './FlowTutorials.svelte'
|
||||
import FlowHistory from './flows/FlowHistory.svelte'
|
||||
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
|
||||
import { tutorialsToDo } from '$lib/stores'
|
||||
import { getTutorialIndex } from '$lib/tutorials/config'
|
||||
import EditorHeader from './EditorHeader.svelte'
|
||||
import AutosaveIndicator from './AutosaveIndicator.svelte'
|
||||
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
|
||||
@@ -1158,21 +1141,11 @@
|
||||
setContext('FlowCopilotContext', flowCopilotContext)
|
||||
|
||||
let renderCount = $state(0)
|
||||
let flowTutorials: FlowTutorials | undefined = $state(undefined)
|
||||
|
||||
let jsonViewerDrawer: Drawer | undefined = $state(undefined)
|
||||
let yamlEditorDrawer: Drawer | undefined = $state(undefined)
|
||||
let flowHistory: FlowHistory | undefined = $state(undefined)
|
||||
|
||||
export function triggerTutorial() {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const tutorial = urlParams.get('tutorial')
|
||||
|
||||
if (tutorial) {
|
||||
flowTutorials?.runTutorialById(tutorial)
|
||||
}
|
||||
}
|
||||
|
||||
let baseMenuItems: Item[] = $state([])
|
||||
|
||||
const mod = isMac() ? '⌘' : 'Ctrl+'
|
||||
@@ -1196,56 +1169,6 @@
|
||||
disabled: $history.index === $history.history.length - 1,
|
||||
shortcut: `${mod}⇧Z`
|
||||
},
|
||||
{
|
||||
displayName: 'Tutorials',
|
||||
icon: BookOpen,
|
||||
separatorTop: true,
|
||||
extra: (() => {
|
||||
const remaining = [
|
||||
getTutorialIndex('flow-live-tutorial'),
|
||||
getTutorialIndex('troubleshoot-flow')
|
||||
].filter((i) => $tutorialsToDo.includes(i)).length
|
||||
return remaining > 0
|
||||
? createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<span class="ml-auto inline-flex items-center justify-center w-4 h-4 text-[10px] font-medium text-white rounded-full bg-surface-accent-primary">${remaining}</span>`
|
||||
}))
|
||||
: undefined
|
||||
})(),
|
||||
submenuItems: [
|
||||
{
|
||||
displayName: 'Build a flow',
|
||||
action: () => flowTutorials?.runTutorialById('flow-live-tutorial'),
|
||||
icon: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial'))
|
||||
? Circle
|
||||
: CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial'))
|
||||
? undefined
|
||||
: 'green'
|
||||
},
|
||||
{
|
||||
displayName: 'Fix a broken flow',
|
||||
action: () => flowTutorials?.runTutorialById('troubleshoot-flow'),
|
||||
icon: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))
|
||||
? Circle
|
||||
: CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))
|
||||
? undefined
|
||||
: 'green'
|
||||
},
|
||||
{
|
||||
displayName: 'Reset tutorials',
|
||||
action: () => resetAllTodos(),
|
||||
icon: RefreshCw,
|
||||
separatorTop: true
|
||||
},
|
||||
{
|
||||
displayName: 'Skip tutorials',
|
||||
action: () => skipAllTodos(),
|
||||
icon: CheckCheck
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
displayName: 'Test flow & record',
|
||||
icon: Disc,
|
||||
@@ -1515,14 +1438,7 @@
|
||||
{#if $enterpriseLicense && !newFlow && !inSessionPane}
|
||||
<Awareness />
|
||||
{/if}
|
||||
<div class="relative">
|
||||
<Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} />
|
||||
{#if $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) || $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))}
|
||||
<span
|
||||
class="absolute top-0.5 right-0.5 block w-2 h-2 rounded-full bg-surface-accent-primary pointer-events-none"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
<Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} />
|
||||
{#if diffEnabled && !diffInMenu}
|
||||
<!-- A disabled <button> fires no pointer events, so a title/tooltip on
|
||||
it never shows on hover. pointer-events-none on the button lets the
|
||||
@@ -1658,13 +1574,6 @@
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
<FlowTutorials
|
||||
bind:this={flowTutorials}
|
||||
on:reload={() => {
|
||||
renderCount += 1
|
||||
}}
|
||||
/>
|
||||
|
||||
<FlowAssetsHandler
|
||||
modules={flowStore.val.value.modules}
|
||||
enableParser
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TutorialRouter from './tutorials/TutorialRouter.svelte'
|
||||
import FlowBuilderLiveTutorial from './tutorials/FlowBuilderLiveTutorial.svelte'
|
||||
import TroubleshootFlowTutorial from './tutorials/TroubleshootFlowTutorial.svelte'
|
||||
|
||||
let tutorialRouter: TutorialRouter | undefined = $state(undefined)
|
||||
|
||||
export function runTutorialById(id: string) {
|
||||
tutorialRouter?.runTutorialById(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<TutorialRouter
|
||||
bind:this={tutorialRouter}
|
||||
tutorials={[
|
||||
{
|
||||
id: 'flow-live-tutorial',
|
||||
component: FlowBuilderLiveTutorial
|
||||
},
|
||||
{
|
||||
id: 'troubleshoot-flow',
|
||||
component: TroubleshootFlowTutorial
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -23,9 +23,13 @@
|
||||
project: ImportProjectSummary
|
||||
/** Where the project is coming from, shown next to the author. */
|
||||
hubHost?: string
|
||||
/** The project's prose, when the caller has it. Falls back to the one-line summary. */
|
||||
description?: string
|
||||
/** Off where what the import will create is already spelled out below the card. */
|
||||
showCounts?: boolean
|
||||
}
|
||||
|
||||
let { project, hubHost = 'hub.windmill.dev' }: Props = $props()
|
||||
let { project, hubHost = 'hub.windmill.dev', description, showCounts = true }: Props = $props()
|
||||
|
||||
// Protocol-relative on purpose: the same hub is https in production and plain
|
||||
// http when it's a local dev instance, and this way the link follows whichever
|
||||
@@ -83,7 +87,7 @@
|
||||
class="shrink-0 text-tertiary opacity-0 transition group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-secondary">{project.summary}</p>
|
||||
<p class="mt-0.5 line-clamp-4 text-xs text-secondary">{description || project.summary}</p>
|
||||
<p class="mt-1 text-xs text-tertiary">
|
||||
by <span class="font-medium text-secondary">{project.author}</span>
|
||||
· <span class="font-mono">{project.slug}</span>
|
||||
@@ -91,9 +95,11 @@
|
||||
|
||||
<!-- What the import will create, aligned under the title rather than in a
|
||||
band of its own: the counts belong to the project above them. -->
|
||||
<div class="mt-3">
|
||||
<ProjectContentBadges counts={project.counts} />
|
||||
</div>
|
||||
{#if showCounts}
|
||||
<div class="mt-3">
|
||||
<ProjectContentBadges counts={project.counts} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- The integrations, minus whichever one is already standing in as the logo. -->
|
||||
|
||||
@@ -27,6 +27,27 @@
|
||||
/** From the hub, for the counts — the export is only fetched during the run. */
|
||||
project?: ImportProjectSummary
|
||||
onFolderChange: (folder: string) => void
|
||||
/**
|
||||
* Whether to ask which folder the project lands in. Off where the destination was
|
||||
* not chosen either — importing into the workspace you are already in is one
|
||||
* decision, and `f/<slug>` is the answer nobody needs to be asked for.
|
||||
*/
|
||||
chooseFolder?: boolean
|
||||
/**
|
||||
* Whether to spell out what import does to resources and triggers. It is about landing
|
||||
* on top of what a workspace already holds — a resource it will not overwrite, a
|
||||
* trigger it re-creates disabled — so a destination with nothing in it has nothing to
|
||||
* warn about, and the setup step that follows is where the values get filled in.
|
||||
*/
|
||||
showNotes?: boolean
|
||||
/**
|
||||
* Fill the height given rather than hugging the content, with the actions pinned to the
|
||||
* bottom. For a surface of a fixed size — a paged dialog, whose height is the taller
|
||||
* page — where content-height buttons would float mid-panel. `sticky` as well as
|
||||
* `mt-auto`: a page taller than the box scrolls, and a row that only sat at the end of
|
||||
* the content would scroll out of reach with it.
|
||||
*/
|
||||
fillHeight?: boolean
|
||||
onFinish: () => void
|
||||
/** True once the run reveals data tables the destination has yet to configure. */
|
||||
setupPending?: boolean
|
||||
@@ -51,6 +72,9 @@
|
||||
onFolderChange,
|
||||
onFinish,
|
||||
onBack,
|
||||
chooseFolder = true,
|
||||
showNotes = true,
|
||||
fillHeight = false,
|
||||
setupPending = false,
|
||||
setupUndecided = false,
|
||||
onExecution,
|
||||
@@ -312,12 +336,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-4 {fillHeight ? 'flex-1' : ''}">
|
||||
<!-- Only when the destination already exists. A workspace created by this run is
|
||||
empty, so there is nothing for the project to sit next to and nothing to
|
||||
choose between — asking would be a question with one answer. It lands in
|
||||
f/<slug>/ either way; `installProject` creates the folder as it imports. -->
|
||||
{#if existingWorkspace}
|
||||
{#if existingWorkspace && chooseFolder}
|
||||
<div class="max-w-sm">
|
||||
<!-- The workspace rides on the field label rather than getting a line of its own:
|
||||
the folder is the only thing being chosen, and naming its container is what
|
||||
@@ -364,26 +388,32 @@
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<!-- `info`, not `warning`: nothing here has gone wrong, it is what import does. Borderless
|
||||
so the collapsed row sits under the checklist as a note rather than competing with it
|
||||
— `bgClass` is the only lever, the border is baked into each type's classes. -->
|
||||
<Alert
|
||||
type="info"
|
||||
title="What import does to resources and triggers"
|
||||
size="xs"
|
||||
bgClass="border-0"
|
||||
collapsible
|
||||
>
|
||||
Resources are imported as empty stubs — set their values after import; one whose path is
|
||||
already in the workspace is left exactly as it is and reported as already there, so a value
|
||||
you have since filled in is never overwritten. Trigger kinds are
|
||||
recreated disabled, except GCP and Azure triggers, which manage cloud subscriptions at creation
|
||||
and must be re-created manually after filling their resource. Kafka, NATS, SQS, GCP and Azure
|
||||
triggers all require Enterprise. Triggers that reference a resource depend on stubs imported
|
||||
empty, so fill in the resource value before re-enabling the trigger.
|
||||
</Alert>
|
||||
{#if showNotes}
|
||||
<!-- `info`, not `warning`: nothing here has gone wrong, it is what import does. Borderless
|
||||
so the collapsed row sits under the checklist as a note rather than competing with it
|
||||
— `bgClass` is the only lever, the border is baked into each type's classes. -->
|
||||
<Alert
|
||||
type="info"
|
||||
title="What import does to resources and triggers"
|
||||
size="xs"
|
||||
bgClass="border-0"
|
||||
collapsible
|
||||
>
|
||||
Resources are imported as empty stubs — set their values after import; one whose path is
|
||||
already in the workspace is left exactly as it is and reported as already there, so a value
|
||||
you have since filled in is never overwritten. Trigger kinds are recreated disabled, except
|
||||
GCP and Azure triggers, which manage cloud subscriptions at creation and must be re-created
|
||||
manually after filling their resource. Kafka, NATS, SQS, GCP and Azure triggers all require
|
||||
Enterprise. Triggers that reference a resource depend on stubs imported empty, so fill in the
|
||||
resource value before re-enabling the trigger.
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex items-center justify-between gap-2">
|
||||
<div
|
||||
class="mt-2 flex items-center justify-between gap-2 {fillHeight
|
||||
? 'sticky bottom-0 mt-auto bg-surface pb-1 pt-3'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Back is disabled mid-run, and gone once the import has landed: at that point
|
||||
the plan has already happened and re-answering it would say nothing. -->
|
||||
{#if !execution?.done}
|
||||
|
||||
@@ -48,12 +48,31 @@
|
||||
* own slug and `installProject` retargets them, so reading the raw paths here would
|
||||
* look for stubs that are not where they landed. */
|
||||
folder?: string
|
||||
onSkip: () => void
|
||||
onFinish: () => void
|
||||
/** Left with `outstanding` rows still unfilled, which the caller may want to count. */
|
||||
onSkip: (outstanding: number) => void
|
||||
/** Off where the surface already names the step, e.g. a dialog whose title is it. */
|
||||
showHeading?: boolean
|
||||
/** Fill the height given, actions pinned to the bottom. See ImportProjectStep. */
|
||||
fillHeight?: boolean
|
||||
/**
|
||||
* Finished. `checked` is false where the export could not be read: the step then has no
|
||||
* idea what is outstanding, so it offers Finish rather than blocking — and a caller
|
||||
* counting outcomes must not read that as a step that came out clean.
|
||||
*/
|
||||
onFinish: (checked: boolean) => void
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
let { workspace, slug, folder, onSkip, onFinish, onBack }: Props = $props()
|
||||
let {
|
||||
workspace,
|
||||
slug,
|
||||
folder,
|
||||
onSkip,
|
||||
onFinish,
|
||||
onBack,
|
||||
showHeading = true,
|
||||
fillHeight = false
|
||||
}: Props = $props()
|
||||
|
||||
type Row = {
|
||||
name: string
|
||||
@@ -710,7 +729,7 @@
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
onSkip()
|
||||
onSkip(outstanding)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -736,9 +755,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-4 {fillHeight ? 'flex-1' : ''}">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-emphasis">Finish setting up</h2>
|
||||
{#if showHeading}
|
||||
<h2 class="text-sm font-semibold text-emphasis">Finish setting up</h2>
|
||||
{/if}
|
||||
<!-- Reads as what the user gets out of it, not as what the import failed to do:
|
||||
the step is skippable, so it has to say why finishing is worth their time. -->
|
||||
<p class="mt-0.5 text-xs text-secondary">
|
||||
@@ -780,7 +801,7 @@
|
||||
{#if row.status === 'done'}
|
||||
<Check size={20} class="text-emerald-600" />
|
||||
{:else if row.status === 'running'}
|
||||
<Loader2 size={20} class="animate-spin text-blue-500" />
|
||||
<Loader2 size={20} class="animate-spin text-accent" />
|
||||
{:else if row.status === 'failed'}
|
||||
<X size={20} class="text-red-500" />
|
||||
{:else if row.status === 'unknown'}
|
||||
@@ -982,38 +1003,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Three different things to say, and which one depends on what is left. A missing
|
||||
credential degrades the project; a missing data table ends it, because every app
|
||||
queries tables that do not exist. Only the credential case is offered as
|
||||
skippable — saying "you can skip this" above a missing data table would be
|
||||
telling the user something that is not true. -->
|
||||
<!-- Two things to say, and which one depends on what is left. A missing credential
|
||||
degrades the project; a missing data table ends it, because every app queries tables
|
||||
that do not exist — but that case is not stated here: Skip already asks to confirm
|
||||
it, in the words the user is about to act on. Nor is it offered as skippable, which
|
||||
would be telling the user something that is not true. -->
|
||||
{#if outstanding === 0}
|
||||
<Alert type="success" title="You're all set" size="xs">
|
||||
Everything this project needs is configured. Finish, and it is ready to run.
|
||||
</Alert>
|
||||
{:else if pendingTables.length > 0}
|
||||
<Alert
|
||||
type="warning"
|
||||
title={missingTables.length > 0
|
||||
? 'The project will not run without this'
|
||||
: 'This could not be checked'}
|
||||
size="xs"
|
||||
>
|
||||
{#if missingTables.length > 0}
|
||||
The tables {missingTables.length === 1
|
||||
? 'this data table holds'
|
||||
: 'these data tables hold'}
|
||||
do not exist, and the project's apps and flows read them. Every one of those fails as soon
|
||||
as it opens.
|
||||
{/if}
|
||||
{#if uncheckedTables.length > 0}
|
||||
{#if missingTables.length > 0}<br /><br />{/if}
|
||||
{uncheckedTables.length === 1 ? 'One data table is' : 'Some data tables are'} set up, but
|
||||
{uncheckedTables.length === 1 ? 'its' : 'their'} schema could not be read, so whether the project's
|
||||
tables are there is unknown. Check again once the database is reachable.
|
||||
{/if}
|
||||
</Alert>
|
||||
{:else}
|
||||
{:else if pendingTables.length === 0}
|
||||
<Alert type="info" title="You can skip this" size="xs" collapsible>
|
||||
The project's apps and flows will fail wherever they read a credential that is still
|
||||
missing. Everything else it imported works either way, and you can fill these in from the
|
||||
@@ -1022,7 +1021,11 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<div
|
||||
class="mt-2 flex items-center justify-between {fillHeight
|
||||
? 'sticky bottom-0 mt-auto bg-surface pb-1 pt-3'
|
||||
: ''}"
|
||||
>
|
||||
{#if onBack}
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -1053,7 +1056,7 @@
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={working || loading || (outstanding > 0 && !loadError)}
|
||||
onClick={onFinish}
|
||||
onClick={() => onFinish(!loadError)}
|
||||
>
|
||||
Finish setup →
|
||||
</Button>
|
||||
|
||||
@@ -25,15 +25,33 @@
|
||||
* resume and offer to run the whole bundle again.
|
||||
*/
|
||||
lowestStep?: number
|
||||
/**
|
||||
* The steps before the optional setup one. The wizard route asks all three; a surface
|
||||
* that opens with the destination already settled passes only the ones it runs.
|
||||
*/
|
||||
labels?: string[]
|
||||
/** What to call the optional setup step, where the surface knows it more precisely. */
|
||||
setupLabel?: string
|
||||
/**
|
||||
* Where a click on an earlier step goes. Without it the step is rewritten in the URL,
|
||||
* which is how the wizard route holds its position. The guards above it — nothing to
|
||||
* go back to, an import in flight — apply either way.
|
||||
*/
|
||||
onNavigate?: (step: number) => void
|
||||
}
|
||||
|
||||
let { step, hasSetup = false, lowestStep = 1 }: Props = $props()
|
||||
let {
|
||||
step,
|
||||
hasSetup = false,
|
||||
lowestStep = 1,
|
||||
labels = IMPORT_WIZARD_LABELS,
|
||||
setupLabel = IMPORT_WIZARD_SETUP_LABEL,
|
||||
onNavigate
|
||||
}: Props = $props()
|
||||
|
||||
// Most projects ship no data table migrations, so the wizard is three steps and
|
||||
// says so. A fourth appears only once there is something to configure.
|
||||
const tabs = $derived(
|
||||
hasSetup ? [...IMPORT_WIZARD_LABELS, IMPORT_WIZARD_SETUP_LABEL] : IMPORT_WIZARD_LABELS
|
||||
)
|
||||
const tabs = $derived(hasSetup ? [...labels, setupLabel] : labels)
|
||||
|
||||
// `maxReachedIndex` is the current step, so Stepper renders everything past it as
|
||||
// unreachable and only the steps behind it as clickable — the wizard has no way to
|
||||
@@ -51,6 +69,10 @@
|
||||
sendUserToast('Wait for the import to finish before going back.', true)
|
||||
return
|
||||
}
|
||||
if (onNavigate) {
|
||||
onNavigate(index + 1)
|
||||
return
|
||||
}
|
||||
// Every step shares one route, so going back is a `step` rewrite that leaves
|
||||
// the rest of the wizard's state in the URL alone.
|
||||
const params = new URLSearchParams($page.url.search)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { classes as alertClasses, icons as alertIcons } from '$lib/components/common/alert/model'
|
||||
import { ExternalLink, X } from 'lucide-svelte'
|
||||
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
|
||||
import {
|
||||
fetchInstanceBanner,
|
||||
isInstanceBannerVisible,
|
||||
type ResolvedInstanceBanner
|
||||
} from './instanceBanner'
|
||||
import { instanceSettingsSaved } from './instanceSettings'
|
||||
|
||||
// Mounted only on the managed cloud (see the render site in the logged layout), so the
|
||||
// poll costs nothing anywhere else. An announcement is only worth broadcasting while it
|
||||
// is current, hence polling rather than waiting for the next full page load: a session
|
||||
// left open all day is exactly the one that needs to hear about the maintenance window.
|
||||
const POLL_MS = 60_000
|
||||
|
||||
let banner = $state<ResolvedInstanceBanner | undefined>(undefined)
|
||||
|
||||
// Per-viewer, per-announcement. Holds the fingerprint of the dismissed banner, so
|
||||
// a new announcement shows up again for everyone who dismissed the previous one.
|
||||
const dismissed = useLocalStorageValue<string>('instance_banner_dismissed', '', 'string')
|
||||
|
||||
// The poll, the tab-focus refresh and the post-save refresh all call `load()` and can
|
||||
// overlap. Responses are not ordered, so without this a slow earlier fetch lands last
|
||||
// and puts a retracted announcement back on screen until the next successful poll.
|
||||
let latestLoad = 0
|
||||
|
||||
async function load() {
|
||||
const generation = ++latestLoad
|
||||
try {
|
||||
const next = await fetchInstanceBanner()
|
||||
if (generation === latestLoad) banner = next
|
||||
} catch (e) {
|
||||
// Keep whatever is on screen: a transient failure must not silently retract
|
||||
// an announcement that is still in force.
|
||||
console.warn('Could not fetch the instance banner', e)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Re-runs on save so a superadmin who just published an announcement sees it
|
||||
// immediately rather than on the next poll or page load.
|
||||
$instanceSettingsSaved
|
||||
load()
|
||||
const interval = setInterval(() => {
|
||||
if (!document.hidden) load()
|
||||
}, POLL_MS)
|
||||
const onVisible = () => {
|
||||
if (!document.hidden) load()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
})
|
||||
|
||||
let shown = $derived(isInstanceBannerVisible(banner, dismissed.val))
|
||||
let palette = $derived(alertClasses[banner?.severity ?? 'info'])
|
||||
let Icon = $derived(alertIcons[banner?.severity ?? 'info'])
|
||||
</script>
|
||||
|
||||
{#if shown && banner}
|
||||
<!-- Sits in the content column above the page, in flow rather than overlaid, so it
|
||||
pushes the app down instead of covering the top of whatever page is open. -->
|
||||
<div
|
||||
class="shrink-0 px-4 py-1.5 flex items-center justify-center gap-x-3 gap-y-1 flex-wrap {palette.bgClass}"
|
||||
role="status"
|
||||
>
|
||||
<Icon size={16} class="shrink-0 {palette.iconClass}" />
|
||||
<span class="text-xs font-medium {palette.titleClass}">{banner.message}</span>
|
||||
{#if banner.link}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
href={banner.link}
|
||||
target="_blank"
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
>
|
||||
{banner.linkLabel}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if banner.dismissible}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
title="Dismiss"
|
||||
aria-label="Dismiss announcement"
|
||||
onclick={() => (dismissed.val = banner?.fingerprint ?? '')}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -26,6 +26,7 @@
|
||||
import GhesAppSettings from './instanceSettings/GhesAppSettings.svelte'
|
||||
import WebhookBaseUrlSetting from './instanceSettings/WebhookBaseUrlSetting.svelte'
|
||||
import WsConnectivityTest from './instanceSettings/WsConnectivityTest.svelte'
|
||||
import InstanceBannerSetting from './instanceSettings/InstanceBannerSetting.svelte'
|
||||
import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte'
|
||||
import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte'
|
||||
import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte'
|
||||
@@ -861,6 +862,8 @@
|
||||
<WebhookBaseUrlSetting {values} disabled={loading || !$enterpriseLicense} />
|
||||
{:else if setting.fieldType == 'ws_connectivity'}
|
||||
<WsConnectivityTest {values} />
|
||||
{:else if setting.fieldType == 'instance_banner'}
|
||||
<InstanceBannerSetting {values} disabled={loading} />
|
||||
{/if}
|
||||
{#if hasError}
|
||||
<span class="text-red-600 dark:text-red-400 text-xs">
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { sleep } from '$lib/utils'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { setLicense } from '$lib/enterpriseUtils'
|
||||
@@ -100,7 +101,8 @@
|
||||
otel: {},
|
||||
indexer_settings: {},
|
||||
critical_error_channels: [],
|
||||
github_enterprise_app: {}
|
||||
github_enterprise_app: {},
|
||||
instance_banner: {}
|
||||
}
|
||||
|
||||
function applyFormDefaults(vals: Record<string, any>): void {
|
||||
@@ -524,6 +526,10 @@
|
||||
for (const category of settingsKeys) {
|
||||
const categorySettings = getSettingsForCategory(category)
|
||||
result[category] = categorySettings.some((s) => {
|
||||
// A field the build never renders must not be able to block Save: off-cloud its
|
||||
// value is unreachable, so an invalid one (from config sync, say) would leave the
|
||||
// category permanently unsaveable with nothing on screen to fix.
|
||||
if (s.cloudonly && !isCloudHosted()) return false
|
||||
if (s.isValid && !s.isValid(currentValues?.[s.key])) return true
|
||||
if (s.validate) {
|
||||
const errors = s.validate(currentValues?.[s.key])
|
||||
@@ -1061,8 +1067,7 @@
|
||||
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
|
||||
<li
|
||||
>user usage (author count, operator count, the distinct guests of the last 30 days,
|
||||
the seats they add past the free allowance, and the workspaces that allow
|
||||
guests)</li
|
||||
the seats they add past the free allowance, and the workspaces that allow guests)</li
|
||||
>
|
||||
<li>superadmin email addresses</li>
|
||||
<li>development instance status</li>
|
||||
@@ -1078,9 +1083,11 @@
|
||||
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
|
||||
membership, the plan tier and quota shown when the execution meter is opened, whether
|
||||
app sandbox isolation is turned on, whether a step's workspace script is edited from
|
||||
the flow editor, how data tables and their migrations are set up and used, and whether
|
||||
data tables are put under roles and whether callers name a role or take the default,
|
||||
last 30 days)</li
|
||||
the flow editor, how data tables and their migrations are set up and used, how often
|
||||
an empty workspace home is seen, how often the home page’s create menu and hub-project
|
||||
picker are opened and from which entry point, the name of any public hub project
|
||||
imported from the home page and how far that import got, and whether data tables are
|
||||
put under roles and whether callers name a role or take the default, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
@@ -1130,8 +1137,7 @@
|
||||
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
|
||||
<li
|
||||
>user usage (author count, operator count, the distinct guests of the last 30 days,
|
||||
the seats they add past the free allowance, and the workspaces that allow
|
||||
guests)</li
|
||||
the seats they add past the free allowance, and the workspaces that allow guests)</li
|
||||
>
|
||||
<li>development instance status</li>
|
||||
<li
|
||||
@@ -1141,9 +1147,11 @@
|
||||
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
|
||||
membership, the plan tier and quota shown when the execution meter is opened, whether
|
||||
app sandbox isolation is turned on, whether a step's workspace script is edited from
|
||||
the flow editor, how data tables and their migrations are set up and used, and whether
|
||||
data tables are put under roles and whether callers name a role or take the default,
|
||||
last 30 days)</li
|
||||
the flow editor, how data tables and their migrations are set up and used, how often
|
||||
an empty workspace home is seen, how often the home page’s create menu and hub-project
|
||||
picker are opened and from which entry point, the name of any public hub project
|
||||
imported from the home page and how far that import got, and whether data tables are
|
||||
put under roles and whether callers name a role or take the default, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
|
||||
@@ -21,9 +21,18 @@
|
||||
args: any
|
||||
argLabel?: string | undefined
|
||||
workspace?: string | undefined
|
||||
/** Drop the header's expand-into-a-drawer button, for a caller that already offers a
|
||||
* way to open the run in full. */
|
||||
disableExpand?: boolean
|
||||
}
|
||||
|
||||
let { id = undefined, args, argLabel = undefined, workspace = undefined }: Props = $props()
|
||||
let {
|
||||
id = undefined,
|
||||
args,
|
||||
argLabel = undefined,
|
||||
workspace = undefined,
|
||||
disableExpand = false
|
||||
}: Props = $props()
|
||||
|
||||
// Internal flag injected by "test this step" runs to suppress the asset
|
||||
// dispatcher. Not a real input: shown as a badge instead of a table row,
|
||||
@@ -125,18 +134,20 @@ ${Object.entries(displayArgs)
|
||||
<Cell head last>Value</Cell>
|
||||
</tr>
|
||||
{#snippet headerAction()}
|
||||
<div class="center-center -m-1">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
jsonStr = JSON.stringify(args, null, 4)
|
||||
jsonViewer?.openDrawer()
|
||||
}}
|
||||
iconOnly
|
||||
startIcon={{ icon: Expand }}
|
||||
></Button>
|
||||
</div>
|
||||
{#if !disableExpand}
|
||||
<div class="center-center -m-1">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
jsonStr = JSON.stringify(args, null, 4)
|
||||
jsonViewer?.openDrawer()
|
||||
}}
|
||||
iconOnly
|
||||
startIcon={{ icon: Expand }}
|
||||
></Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Head>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { VariableService } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { generateRandomString } from '$lib/utils'
|
||||
import { ephemeralSecretPrefix, mintEphemeralSecret } from './secretArgUtils'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Button } from './common'
|
||||
import Password from './Password.svelte'
|
||||
@@ -36,27 +36,16 @@
|
||||
|
||||
let isGenerating = false
|
||||
|
||||
let userPrefix = $derived(
|
||||
'u/' + ($userStore?.username ?? $userStore?.email)?.split('@')[0] + '/secret_arg/'
|
||||
)
|
||||
let username = $derived(($userStore?.username ?? $userStore?.email)?.split('@')[0] ?? '')
|
||||
let userPrefix = $derived(ephemeralSecretPrefix(username))
|
||||
async function generateValue() {
|
||||
if (isGenerating || argReplaced) return
|
||||
isGenerating = true
|
||||
const mintWs = ws!
|
||||
const boundBefore = value
|
||||
try {
|
||||
let npath = userPrefix + generateRandomString(12)
|
||||
let npath = await mintEphemeralSecret(mintWs, username, password)
|
||||
let nvalue = '$var:' + npath
|
||||
await VariableService.createVariable({
|
||||
workspace: mintWs,
|
||||
requestBody: {
|
||||
value: password,
|
||||
is_secret: true,
|
||||
path: npath,
|
||||
description: 'Ephemeral secret variable',
|
||||
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString()
|
||||
}
|
||||
})
|
||||
// The arg can be replaced the same way while the create is in flight. Nothing ever
|
||||
// referenced the variable just minted, so delete it; it expires on its own if that fails.
|
||||
if (value !== boundBefore) {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { processSecretArgs } from './secretArgUtils'
|
||||
import { enforceDisabledDefaults, resetKeysToast } from './job_args'
|
||||
import PowerShellCommonParams from './PowerShellCommonParams.svelte'
|
||||
|
||||
let reloadArgs = $state(0)
|
||||
@@ -60,11 +61,12 @@
|
||||
|
||||
export async function run(overrideScheduledForStr?: string | undefined | null) {
|
||||
let processedArgs: Record<string, any>
|
||||
const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema)
|
||||
if (resetKeys.length > 0) {
|
||||
sendUserToast(resetKeysToast(resetKeys))
|
||||
}
|
||||
try {
|
||||
processedArgs = await processSecretArgs(
|
||||
enforceDisabledDefaults(args ?? {}, true),
|
||||
runnable?.schema
|
||||
)
|
||||
processedArgs = await processSecretArgs(withDefaults, runnable?.schema)
|
||||
} catch (e) {
|
||||
sendUserToast('Failed to process sensitive args: ' + e, true)
|
||||
return
|
||||
@@ -178,30 +180,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function enforceDisabledDefaults(
|
||||
args: Record<string, any>,
|
||||
notify: boolean = false
|
||||
): Record<string, any> {
|
||||
const schema = runnable?.schema
|
||||
if (!schema?.properties) return args
|
||||
const result = { ...args }
|
||||
const resetKeys: string[] = []
|
||||
for (const [key, prop] of Object.entries(schema.properties) as [string, any][]) {
|
||||
if (prop?.disabled && 'default' in prop) {
|
||||
if (notify && result[key] !== prop.default) {
|
||||
resetKeys.push(key)
|
||||
}
|
||||
result[key] = prop.default
|
||||
}
|
||||
}
|
||||
if (resetKeys.length > 0) {
|
||||
sendUserToast(
|
||||
`Disabled field${resetKeys.length > 1 ? 's' : ''} ${resetKeys.map((k) => `'${k}'`).join(', ')} reset to default value${resetKeys.length > 1 ? 's' : ''}`
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Rewrite the open JSON editor from the current args. Only for args replaced from outside
|
||||
* the editor: entering the JSON view already starts from whatever `args` holds. */
|
||||
export function syncJsonEditor() {
|
||||
@@ -322,7 +300,7 @@
|
||||
bind:this={jsonEditor}
|
||||
on:select={(e) => {
|
||||
if (e.detail) {
|
||||
args = enforceDisabledDefaults(e.detail)
|
||||
args = enforceDisabledDefaults(e.detail, runnable?.schema).args
|
||||
}
|
||||
}}
|
||||
initialCode={argsToJsonPayload(runnable.schema, args)}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { skipAllTodos } from '$lib/tutorialUtils'
|
||||
import TroubleshootFlowTutorial from './tutorials/TroubleshootFlowTutorial.svelte'
|
||||
import { getTutorialIndex } from '$lib/tutorials/config'
|
||||
|
||||
let troubleshootFlowTutorial: TroubleshootFlowTutorial | undefined = $state(undefined)
|
||||
|
||||
export function runTutorialById(id: string) {
|
||||
if (id === 'troubleshoot-flow') {
|
||||
troubleshootFlowTutorial?.runTutorial()
|
||||
}
|
||||
}
|
||||
|
||||
function skipAll() {
|
||||
skipAllTodos()
|
||||
}
|
||||
</script>
|
||||
|
||||
<TroubleshootFlowTutorial
|
||||
bind:this={troubleshootFlowTutorial}
|
||||
index={getTutorialIndex('troubleshoot-flow')}
|
||||
on:error
|
||||
on:skipAll={skipAll}
|
||||
on:reload
|
||||
/>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TutorialRouter from './tutorials/TutorialRouter.svelte'
|
||||
import WorkspaceOnboardingTutorial from './tutorials/workspace/WorkspaceOnboardingTutorial.svelte'
|
||||
import WorkspaceOnboardingOperatorTutorial from './tutorials/workspace/WorkspaceOnboardingOperatorTutorial.svelte'
|
||||
|
||||
let tutorialRouter: TutorialRouter | undefined = $state(undefined)
|
||||
|
||||
export function runTutorialById(id: string) {
|
||||
tutorialRouter?.runTutorialById(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<TutorialRouter
|
||||
bind:this={tutorialRouter}
|
||||
tutorials={[
|
||||
{
|
||||
id: 'workspace-onboarding',
|
||||
component: WorkspaceOnboardingTutorial
|
||||
},
|
||||
{
|
||||
id: 'workspace-onboarding-operator',
|
||||
component: WorkspaceOnboardingOperatorTutorial
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -473,15 +473,6 @@
|
||||
|
||||
let appEditorHeader: AppEditorHeader | undefined = $state(undefined)
|
||||
|
||||
export function triggerTutorial() {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const tutorial = urlParams.get('tutorial')
|
||||
|
||||
if (tutorial) {
|
||||
appEditorHeader?.runTutorialById(tutorial)
|
||||
}
|
||||
}
|
||||
|
||||
let box: HTMLElement | undefined = $state(undefined)
|
||||
function parseScroll() {
|
||||
$yTop = box?.scrollTop ?? 0
|
||||
|
||||
@@ -7,25 +7,13 @@
|
||||
import { redo, undo } from '$lib/history.svelte'
|
||||
import { discardDraftAfterDeploy } from '$lib/userDraftToast'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
tutorialsToDo,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { isMac, type Item, userPathPrefix } from '$lib/utils'
|
||||
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
|
||||
import { getTutorialIndex } from '$lib/tutorials/config'
|
||||
import { random_adj } from '$lib/components/random_positive_adjetive'
|
||||
import {
|
||||
AlignHorizontalSpaceAround,
|
||||
BellOff,
|
||||
BookOpen,
|
||||
Bug,
|
||||
CheckCheck,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
DiffIcon,
|
||||
Expand,
|
||||
FileJson,
|
||||
@@ -33,7 +21,6 @@
|
||||
FormInput,
|
||||
History,
|
||||
Laptop2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Smartphone,
|
||||
FileClock,
|
||||
@@ -61,7 +48,6 @@
|
||||
import Awareness from '$lib/components/Awareness.svelte'
|
||||
import { secondaryMenuLeftStore, secondaryMenuRightStore } from './settingsPanel/secondaryMenu'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import AppEditorTutorial from './AppEditorTutorial.svelte'
|
||||
import AppReportsDrawer from './AppReportsDrawer.svelte'
|
||||
import DebugPanel from './contextPanel/DebugPanel.svelte'
|
||||
|
||||
@@ -679,49 +665,9 @@
|
||||
action: () => {
|
||||
appExport?.open(toStatic($app, $staticExporter, $summary).app)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Tutorials',
|
||||
icon: BookOpen,
|
||||
separatorTop: true,
|
||||
submenuItems: [
|
||||
{
|
||||
displayName: 'Background runnables',
|
||||
action: () => appEditorTutorial?.runTutorialById('backgroundrunnables'),
|
||||
icon: $tutorialsToDo.includes(getTutorialIndex('backgroundrunnables'))
|
||||
? Circle
|
||||
: CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(getTutorialIndex('backgroundrunnables'))
|
||||
? undefined
|
||||
: 'green'
|
||||
},
|
||||
{
|
||||
displayName: 'Connection',
|
||||
action: () => appEditorTutorial?.runTutorialById('connection'),
|
||||
icon: $tutorialsToDo.includes(getTutorialIndex('connection')) ? Circle : CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(getTutorialIndex('connection')) ? undefined : 'green'
|
||||
},
|
||||
{
|
||||
displayName: 'Reset tutorials',
|
||||
action: () => resetAllTodos(),
|
||||
icon: RefreshCw,
|
||||
separatorTop: true
|
||||
},
|
||||
{
|
||||
displayName: 'Skip tutorials',
|
||||
action: () => skipAllTodos(),
|
||||
icon: CheckCheck
|
||||
}
|
||||
]
|
||||
}
|
||||
]) as Item[]
|
||||
|
||||
let appEditorTutorial: AppEditorTutorial | undefined = $state(undefined)
|
||||
|
||||
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
|
||||
appEditorTutorial?.runTutorialById(id, options)
|
||||
}
|
||||
|
||||
let appReportingDrawerOpen = $state(false)
|
||||
|
||||
export function openTroubleshootPanel() {
|
||||
@@ -1090,15 +1036,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row gap-2 justify-end items-center overflow-visible shrink-0">
|
||||
<div class="relative">
|
||||
<Dropdown items={moreItems} />
|
||||
{#if $tutorialsToDo.includes(getTutorialIndex('backgroundrunnables')) || $tutorialsToDo.includes(getTutorialIndex('connection'))}
|
||||
<span
|
||||
class="absolute top-0.5 right-0.5 block w-2 h-2 rounded-full bg-surface-accent-primary pointer-events-none"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
<AppEditorTutorial bind:this={appEditorTutorial} />
|
||||
<Dropdown items={moreItems} />
|
||||
|
||||
<div class="{compactTopbar ? 'hidden' : 'hidden md:inline'} relative overflow-visible shrink-0">
|
||||
{#if hasErrors}
|
||||
|
||||
@@ -115,6 +115,10 @@
|
||||
// letting the publisher believe the app is open.
|
||||
let guestAccessEnabled: boolean | undefined = $state(undefined)
|
||||
let guestUsage: GuestUsage | undefined = $state(undefined)
|
||||
// Whether the deployment can have guests at all; off, the mode is not on offer. The
|
||||
// backend decides; the hostname stands in until it has answered, the shared cloud
|
||||
// being the only deployment where guests are unavailable.
|
||||
let guestsAvailable = $derived.by(() => guestUsage?.available ?? !isCloudHosted())
|
||||
|
||||
$effect(() => {
|
||||
const ws = opWs
|
||||
@@ -458,7 +462,7 @@
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{/if}
|
||||
{#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest'}
|
||||
{#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest' && guestsAvailable}
|
||||
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
|
||||
Opening this app to guests is restricted to workspace admins and bypass users by a workspace
|
||||
protection rule
|
||||
@@ -481,8 +485,10 @@
|
||||
<ToggleButton
|
||||
label="Guests"
|
||||
value="guest"
|
||||
disabled={!canSetGuest && policy.execution_mode != 'guest'}
|
||||
tooltip="Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat up to the instance's allowance."
|
||||
disabled={policy.execution_mode != 'guest' && (!guestsAvailable || !canSetGuest)}
|
||||
tooltip={!guestsAvailable
|
||||
? 'Not available on Windmill Cloud. Guests require a self-hosted instance or a dedicated Windmill Cloud deployment.'
|
||||
: "Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat up to the instance's allowance."}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
@@ -499,7 +505,10 @@
|
||||
{#if policy.execution_mode == 'anonymous'}
|
||||
Anyone holding the secret URL below can open this app without signing in.
|
||||
{:else if policy.execution_mode == 'guest'}
|
||||
{#if guestUsage && !guestUsage.instance_enabled}
|
||||
{#if !guestsAvailable}
|
||||
Guests are not available on Windmill Cloud, so this app still admits members only. They
|
||||
require a self-hosted instance or a dedicated Windmill Cloud deployment.
|
||||
{:else if guestUsage && !guestUsage.instance_enabled}
|
||||
A superadmin has turned guests off for this instance, so this app still admits members
|
||||
only.
|
||||
{:else if guestAccessEnabled === undefined}
|
||||
@@ -554,7 +563,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase}
|
||||
{#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable}
|
||||
<div class="mt-4 border-t pt-3 flex flex-col gap-2">
|
||||
<div class="text-xs font-semibold text-emphasis">
|
||||
Embed for your own authenticated users (guest JWT)
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AppTutorials from '../../AppTutorials.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
|
||||
let appTutorials: AppTutorials | undefined = $state(undefined)
|
||||
let targetTutorial: string | undefined = $state(undefined)
|
||||
|
||||
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
|
||||
appTutorials?.runTutorialById(id, options)
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppTutorials
|
||||
bind:this={appTutorials}
|
||||
on:reload
|
||||
on:error={(event: CustomEvent<{ detail: string }>) => {
|
||||
targetTutorial = event.detail.detail
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
open={targetTutorial !== undefined}
|
||||
title="Tutorial error"
|
||||
confirmationText="Open new tab"
|
||||
on:canceled={() => {
|
||||
targetTutorial = undefined
|
||||
}}
|
||||
on:confirmed={async () => {
|
||||
window.open(`/apps/add?tutorial=${targetTutorial}`, '_blank')
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span> This tutorial can only be run on a new app.</span>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
@@ -11,7 +11,6 @@
|
||||
left
|
||||
} from './componentCallbacks.svelte'
|
||||
import type { AppEditorContext, AppViewerContext } from '../../types'
|
||||
import { isCurrentlyInTutorial } from '$lib/stores'
|
||||
|
||||
const { history, movingcomponents, jobsDrawerOpen, runnableJobEditorPanel } =
|
||||
getContext<AppEditorContext>('AppEditorContext') as AppEditorContext
|
||||
@@ -34,8 +33,7 @@
|
||||
if (
|
||||
(typeof classes === 'string' && classes.includes('inputarea')) ||
|
||||
['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName!) ||
|
||||
$runnableJobEditorPanel.focused ||
|
||||
isCurrentlyInTutorial.val
|
||||
$runnableJobEditorPanel.focused
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,10 +14,9 @@
|
||||
import { defaultCode } from '../component'
|
||||
import WorkspaceScriptList from '../settingsPanel/mainInput/WorkspaceScriptList.svelte'
|
||||
import RunnableSelector from '../settingsPanel/mainInput/RunnableSelector.svelte'
|
||||
import { defaultScripts, isCurrentlyInTutorial } from '$lib/stores'
|
||||
import { defaultScripts } from '$lib/stores'
|
||||
import DefaultScripts from '$lib/components/DefaultScripts.svelte'
|
||||
import type { Preview } from '$lib/gen'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { InlineScript } from '../../sharedTypes'
|
||||
|
||||
interface Props {
|
||||
@@ -122,13 +121,7 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex flex-col px-4 gap-2 text-sm',
|
||||
isCurrentlyInTutorial.val ? 'h-full overflow-y-clip' : ''
|
||||
)}
|
||||
id="app-editor-empty-runnable"
|
||||
>
|
||||
<div class="flex flex-col px-4 gap-2 text-sm" id="app-editor-empty-runnable">
|
||||
<div class="mt-2 flex justify-between gap-4" id="app-editor-runnable-header">
|
||||
<div class="font-bold items-baseline truncate">Choose a language</div>
|
||||
<div class="flex gap-2">
|
||||
|
||||
-11
@@ -7,10 +7,6 @@
|
||||
import { getAllScriptNames } from '../../utils'
|
||||
import PanelSection from '../settingsPanel/common/PanelSection.svelte'
|
||||
import { getAppScripts } from './utils'
|
||||
import AppTutorials from '$lib/components/AppTutorials.svelte'
|
||||
import { tutorialsToDo } from '$lib/stores'
|
||||
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
|
||||
import { tutorialInProgress } from '$lib/tutorialUtils'
|
||||
import DocLink from '../settingsPanel/DocLink.svelte'
|
||||
import HideButton from '../settingsPanel/HideButton.svelte'
|
||||
import { BG_PREFIX } from '../appUtilsCore'
|
||||
@@ -37,10 +33,6 @@
|
||||
}
|
||||
|
||||
function createBackgroundScript() {
|
||||
if ($tutorialsToDo.includes(5) && !$ignoredTutorials?.includes(5) && !tutorialInProgress()) {
|
||||
appTutorials?.runTutorialById('backgroundrunnables', { skipStepsCount: 2 })
|
||||
}
|
||||
|
||||
for (const [index, script] of $app.hiddenInlineScripts.entries()) {
|
||||
if (script.hidden) {
|
||||
delete script.hidden
|
||||
@@ -75,7 +67,6 @@
|
||||
selectScript(`${BG_PREFIX}${$app.hiddenInlineScripts.length - 1}`)
|
||||
}
|
||||
|
||||
let appTutorials: AppTutorials | undefined = $state(undefined)
|
||||
const dispatch = createEventDispatcher()
|
||||
let runnables = $derived(getAppScripts($app.grid, $app.subgrids))
|
||||
// When selected component changes, update selectedScriptComponentId
|
||||
@@ -248,5 +239,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<AppTutorials bind:this={appTutorials} on:reload />
|
||||
|
||||
@@ -4,7 +4,18 @@
|
||||
/** One level of a paginated dialog. Order is the order given: the page on screen sits at rest
|
||||
* and every other waits off the side it is listed on, so a deeper page arrives from the right
|
||||
* and the way back arrives from the left without anyone naming a direction. */
|
||||
export type ModalPage = { key: string; content: Snippet }
|
||||
export type ModalPage = {
|
||||
key: string
|
||||
content: Snippet
|
||||
/**
|
||||
* Drawn in place of `content` for a page that has not been opened yet, so the first
|
||||
* navigation to it has something to slide in — without one, the box arrives empty and
|
||||
* fills a frame later, which reads as the animation being broken rather than as
|
||||
* loading. A skeleton is enough: it is on screen for the length of the transition.
|
||||
* Unnecessary under `warm`, which builds every page up front.
|
||||
*/
|
||||
placeholder?: Snippet
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -162,7 +173,8 @@
|
||||
|
||||
<div bind:this={box} class="relative overflow-hidden {c}">
|
||||
{#each pages as page, i (page.key)}
|
||||
{#if visited.includes(page.key) || warmed}
|
||||
{@const built = visited.includes(page.key) || warmed}
|
||||
{#if built || page.placeholder}
|
||||
<!-- Pages are laid over each other rather than laid out, so the dialog cannot change
|
||||
height as one replaces another. That needs a definite height from the caller.
|
||||
`inert` and not just opacity: an off-screen page keeps its DOM, so without it the
|
||||
@@ -177,7 +189,11 @@
|
||||
style="transform: translateX({(i - index) * TRAVEL_PERCENT}%)"
|
||||
inert={i !== index}
|
||||
>
|
||||
{@render page.content()}
|
||||
{#if built}
|
||||
{@render page.content()}
|
||||
{:else}
|
||||
{@render page.placeholder?.()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -52,11 +52,6 @@
|
||||
use:clickOutside={{
|
||||
eventToListenName: 'pointerdown',
|
||||
stopPropagation: true,
|
||||
exclude: async () => {
|
||||
const tutorial = document.querySelector('#driver-popover-content') as HTMLElement | null
|
||||
if (tutorial) return [tutorial]
|
||||
return []
|
||||
},
|
||||
onClickOutside: () => (open = false)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
}}
|
||||
{disabled}
|
||||
{id}
|
||||
data-tab-selected={isSelected ? 'true' : undefined}
|
||||
>
|
||||
<div
|
||||
class={twMerge(
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
* Use this when you want to prevent navigation before checking for unsaved changes.
|
||||
*/
|
||||
deferSelectedUpdate?: boolean
|
||||
/**
|
||||
* Draw the selection as one bar that slides between tabs, rather than a border each tab
|
||||
* turns on. Opt-in, so every existing strip keeps the border it has: a strip whose tabs
|
||||
* appear as their content does needs the move to be visible, and a border cannot travel.
|
||||
* Tabs keep their own bottom border unless the caller turns it off.
|
||||
*/
|
||||
slidingIndicator?: boolean
|
||||
/** Colour of that bar. */
|
||||
indicatorClass?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -37,7 +46,9 @@
|
||||
values = undefined,
|
||||
children,
|
||||
content,
|
||||
deferSelectedUpdate = false
|
||||
deferSelectedUpdate = false,
|
||||
slidingIndicator = false,
|
||||
indicatorClass = 'bg-border-normal'
|
||||
}: Props = $props()
|
||||
|
||||
// Single source of truth for tab state
|
||||
@@ -61,6 +72,53 @@
|
||||
selectedStore.set(selected)
|
||||
})
|
||||
|
||||
// Measured off the selected Tab rather than tracked in state: a Tab decides on its own
|
||||
// whether it is selected (prefix and otherValues matching), and its width is whatever its
|
||||
// label renders to. Zero width means nothing is selected yet, and the bar stays hidden.
|
||||
let row: HTMLDivElement | undefined = $state()
|
||||
let bar = $state({ x: 0, w: 0 })
|
||||
// Where the bar rests while nothing is selected, so it fades out in place rather than
|
||||
// sliding to the left edge. A plain local, not state: measureBar runs inside the effect
|
||||
// below, and reading there the state it writes would make that effect its own dependency.
|
||||
let lastX = 0
|
||||
|
||||
function measureBar() {
|
||||
const el = row?.querySelector<HTMLElement>('[data-tab-selected="true"]')
|
||||
if (el) lastX = el.offsetLeft
|
||||
bar = { x: lastX, w: el ? el.offsetWidth : 0 }
|
||||
}
|
||||
|
||||
// Placing the bar for the first paint. Every later move comes from the observers below: a
|
||||
// Tab marks itself selected in its own update, which has not run when an effect here does,
|
||||
// so measuring from this side alone lands the bar on the tab that was selected before.
|
||||
$effect(() => {
|
||||
if (!slidingIndicator) return
|
||||
void row
|
||||
measureBar()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!slidingIndicator || !row) return
|
||||
const ro = new ResizeObserver(measureBar)
|
||||
ro.observe(row)
|
||||
// The mark moving is the selection changing, and a tab added or removed changes what the
|
||||
// bar has to sit on. Text counts too: a label rewritten in place — a count arriving,
|
||||
// Result becoming Error — resizes the tab under the bar without touching the tree, and
|
||||
// Svelte writes it straight to the node, so childList never sees it.
|
||||
const mo = new MutationObserver(measureBar)
|
||||
mo.observe(row, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['data-tab-selected']
|
||||
})
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
mo.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
let hashValues = $derived(values ? values.map((x) => '#' + x) : undefined)
|
||||
|
||||
function hashChange() {
|
||||
@@ -77,10 +135,26 @@
|
||||
<svelte:window onhashchange={hashChange} />
|
||||
{#if !hideTabs}
|
||||
<ScrollableX class={wrapperClass}>
|
||||
<!-- `scrollbar-hidden` is inert on this non-scrolling row (ScrollableX owns the
|
||||
scroll), but TroubleshootFlowTutorial targets it as a selector hook — keep it. -->
|
||||
<div class={twMerge('border-b flex flex-row whitespace-nowrap scrollbar-hidden', c)} {style}>
|
||||
<div
|
||||
bind:this={row}
|
||||
class={twMerge(
|
||||
'border-b flex flex-row whitespace-nowrap',
|
||||
slidingIndicator ? 'relative' : '',
|
||||
c
|
||||
)}
|
||||
{style}
|
||||
>
|
||||
{@render children?.({ selected })}
|
||||
{#if slidingIndicator}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class={twMerge(
|
||||
'pointer-events-none absolute -bottom-px h-0.5 rounded-t-sm transition-[transform,width,opacity] duration-200 ease-out motion-reduce:transition-none',
|
||||
indicatorClass
|
||||
)}
|
||||
style={`left:0; width:${bar.w}px; transform:translateX(${bar.x}px); opacity:${bar.w ? 1 : 0}`}
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
</ScrollableX>
|
||||
{/if}
|
||||
|
||||
@@ -227,7 +227,15 @@
|
||||
const active = document.activeElement
|
||||
const focusOnChat =
|
||||
!active || active === document.body || (panelEl?.contains(active) ?? false)
|
||||
if (!focusOnChat) return
|
||||
// An Escape while a run form is open must not discard what the user typed, so the action
|
||||
// row alone stops the turn — wherever it is mounted, since the preview panel holds the
|
||||
// form outside `panelEl`. Matched by call: two chats can be loading at once, and one's
|
||||
// row must not answer for the other.
|
||||
if (aiChatManager.hasPendingRunForm) {
|
||||
const row = active?.closest('[data-run-form-actions]')
|
||||
const toolCallId = row?.getAttribute('data-run-form-actions')
|
||||
if (!toolCallId || !aiChatManager.isRunFormPending(toolCallId)) return
|
||||
} else if (!focusOnChat) return
|
||||
e.preventDefault()
|
||||
// Immediate form: other chat panels' identical listeners must not
|
||||
// also cancel on body focus, nor a drawer/modal close on this press.
|
||||
@@ -559,7 +567,7 @@
|
||||
|
||||
const yoloBypassedTools = $derived.by(() => {
|
||||
return aiChatManager.tools
|
||||
.filter((tool) => tool.requiresConfirmation === true)
|
||||
.filter((tool) => tool.requiresConfirmation === true || tool.bypassedByAutoAccept === true)
|
||||
.map((tool) => ({
|
||||
name: tool.def.function.name,
|
||||
// confirmationMessage may be a function of the call args, which we don't
|
||||
|
||||
@@ -23,11 +23,14 @@ import {
|
||||
type ToolCallbacks,
|
||||
type ToolDisplayMessage,
|
||||
type UserQuestionDisplay,
|
||||
type RunFormDisplay,
|
||||
type RunFormDraft,
|
||||
type ChatJob,
|
||||
type ChatJobInit,
|
||||
type ChatJobStatus,
|
||||
completedJobToolStatus,
|
||||
backgroundJobCompletionNote,
|
||||
createJobUpdateReader,
|
||||
deriveChatJobStatus,
|
||||
pendingToolImagesMessage,
|
||||
trimJob
|
||||
@@ -57,6 +60,7 @@ import {
|
||||
buildSummaryMessageContent
|
||||
} from './compactionPrompt'
|
||||
import { dfs } from '$lib/components/flows/previousResults'
|
||||
import { redactFileArgs, redactSecretArgs } from '$lib/components/job_args'
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
|
||||
import { createLongHash } from '$lib/editorLangUtils'
|
||||
import type { AIProvider, UserDraftItemKind } from '$lib/gen'
|
||||
@@ -189,6 +193,22 @@ const MAX_CONSECUTIVE_COMPACTION_FAILURES = 3
|
||||
// (panel teardown, save-and-clear) pass their own reason, so the queued-message
|
||||
// flush can tell "the user wants to move on" from "the turn was torn down".
|
||||
const USER_CANCEL_REASON = 'user_cancelled'
|
||||
// Applied wherever a run form stops rendering. Only the form reads the deployed schema,
|
||||
// so past that point it is a copy of the script's declarations — password and file
|
||||
// defaults with them — persisted for the life of the chat.
|
||||
const settledRunForm = (runForm: RunFormDisplay): RunFormDisplay =>
|
||||
runForm.submitted || runForm.canceled
|
||||
? { ...runForm, schema: undefined, code: undefined, lang: undefined }
|
||||
: runForm
|
||||
|
||||
/** A run form the chat is holding open, keyed by tool call id. */
|
||||
type PendingRunForm = {
|
||||
/** Absent once the loop is no longer waiting: a card restored from history still mounts
|
||||
* its form and still holds edits, but nothing is left to receive them. */
|
||||
resolve?: (args: Record<string, any> | undefined) => void
|
||||
draft: RunFormDraft
|
||||
submitting: boolean
|
||||
}
|
||||
// Built-in `/compact` session command — summarizes the conversation locally
|
||||
// instead of sending a turn to the model. Matched on the whole input so a
|
||||
// regular message that merely mentions "/compact" mid-sentence is unaffected.
|
||||
@@ -495,10 +515,32 @@ export class AIChatManager {
|
||||
// Consecutive getJob failures per background job, so a vanished/404 job can be
|
||||
// drained instead of polled forever. Ephemeral, keyed by jobId.
|
||||
#jobPollFailures = new Map<string, number>()
|
||||
// Incremental log/result-stream readers, keyed by jobId. A job that detaches out of
|
||||
// the inline wait keeps streaming into its card through these; each holds its own
|
||||
// offsets, so one created after a reload refetches from the start.
|
||||
#jobUpdateReaders = new Map<string, ReturnType<typeof createJobUpdateReader>>()
|
||||
/** Opens a run in the sessions preview pane. Set by the session runtime;
|
||||
* undefined in the global side-panel chat, where the tray falls back to opening
|
||||
* the run in a new browser tab. */
|
||||
openRunInPreview?: (a: { jobId: string; workspace: string; label: string }) => void
|
||||
/** Opens a pending run form in the sessions preview pane, on the same tool call the
|
||||
* chat card holds. Unset outside a session: a chat-bound form has nowhere else to go,
|
||||
* so the card hides the control rather than offering a tab that cannot run. */
|
||||
openRunForm?: (a: { toolCallId: string; label: string }) => void
|
||||
closeRunForm?: (toolCallId: string) => void
|
||||
/** Hands that tab from the form to the run it just started, in place: the tab keeps its
|
||||
* position in the strip and stays active if it was. */
|
||||
showRunInPlaceOfForm?: (a: {
|
||||
toolCallId: string
|
||||
jobId: string
|
||||
workspace: string
|
||||
label: string
|
||||
}) => void
|
||||
/** Whether the panel holds this call's pending form. Answered off the session's tab list,
|
||||
* so it stays true while the user is on another tab, and per call rather than "the open
|
||||
* one". Read from a `$derived` — the reader subscribes to the tab list through the call.
|
||||
* The card hides its form on it, which is what keeps exactly one mounted per call. */
|
||||
isRunFormInPreview?: (toolCallId: string) => boolean
|
||||
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
|
||||
closeArtifact?: (artifactId: string) => void
|
||||
#loading = $state<boolean>(false)
|
||||
@@ -682,6 +724,15 @@ export class AIChatManager {
|
||||
{ resolve: (value: boolean) => void; toolName?: string }
|
||||
>()
|
||||
private userQuestionCallbacks = new Map<string, (choices: string[] | undefined) => void>()
|
||||
/**
|
||||
* One run form's whole life while it waits, so ending it is one delete and cannot end half
|
||||
* of it. Held here rather than in the form, which unmounts and remounts as it moves between
|
||||
* the chat card and the preview pane: both are views of one entry.
|
||||
*
|
||||
* Entries are replaced rather than mutated, so a `$derived` reading `submitting` fires;
|
||||
* `draft` keeps its identity across a replacement, which is what the form is bound to.
|
||||
*/
|
||||
#runForms = new SvelteMap<string, PendingRunForm>()
|
||||
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
disabledModes: Partial<Record<AIMode, boolean>> = $state({})
|
||||
@@ -806,19 +857,23 @@ export class AIChatManager {
|
||||
// turn-end save.
|
||||
#maskPersistQueue: Promise<void> = Promise.resolve()
|
||||
#persistModifiedItems(): Promise<void> {
|
||||
this.#maskPersistQueue = this.#maskPersistQueue.then(() =>
|
||||
this.historyManager
|
||||
.saveChat(
|
||||
this.displayMessages,
|
||||
this.messages,
|
||||
this.contextUsage,
|
||||
this.modifiedItems ? [...this.modifiedItems] : undefined
|
||||
)
|
||||
// Swallow (and log) a failed write so it can't wedge the queue as a
|
||||
// rejected link — the next persist snapshots the full current set, so
|
||||
// a lost write self-heals on the next mutation or turn-end save.
|
||||
.catch((e) => console.error('Failed to persist modified-items mask', e))
|
||||
)
|
||||
this.#maskPersistQueue = this.#maskPersistQueue.then(() => {
|
||||
const { display, jobs } = this.#interruptedSnapshot()
|
||||
return (
|
||||
this.historyManager
|
||||
.saveChat(
|
||||
display,
|
||||
this.messages,
|
||||
this.contextUsage,
|
||||
this.modifiedItems ? [...this.modifiedItems] : undefined,
|
||||
jobs
|
||||
)
|
||||
// Swallow (and log) a failed write so it can't wedge the queue as a
|
||||
// rejected link — the next persist snapshots the full current set, so
|
||||
// a lost write self-heals on the next mutation or turn-end save.
|
||||
.catch((e) => console.error('Failed to persist modified-items mask', e))
|
||||
)
|
||||
})
|
||||
return this.#maskPersistQueue
|
||||
}
|
||||
|
||||
@@ -849,6 +904,16 @@ export class AIChatManager {
|
||||
...this.backgroundJobs,
|
||||
{ ...init, createdAt: Date.now(), status: 'queued', detached: false, reported: false }
|
||||
]
|
||||
// The panel was holding this call's form and the call now has a job: the tab follows
|
||||
// the call rather than being left on a form that has already run.
|
||||
if (this.isRunFormInPreview?.(init.toolCallId)) {
|
||||
this.showRunInPlaceOfForm?.({
|
||||
toolCallId: init.toolCallId,
|
||||
jobId: init.jobId,
|
||||
workspace: init.workspace,
|
||||
label: init.label
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge a partial update into a tracked job by id. */
|
||||
@@ -962,10 +1027,34 @@ export class AIChatManager {
|
||||
let anyTerminal = false
|
||||
for (const job of pending) {
|
||||
try {
|
||||
// Its own output first, so a run that detached out of the inline wait keeps
|
||||
// filling its card. `getJob` alone would freeze a streamed result until the
|
||||
// job landed — the partial is only on the updates endpoint.
|
||||
let reader = this.#jobUpdateReaders.get(job.jobId)
|
||||
if (!reader) {
|
||||
reader = createJobUpdateReader(job.jobId, job.workspace)
|
||||
this.#jobUpdateReaders.set(job.jobId, reader)
|
||||
}
|
||||
const update = await reader.poll()
|
||||
if (gen !== this.#jobPollGeneration) return
|
||||
// Only what this reader has collected: the patch is spread over the card, so
|
||||
// naming a field it has nothing for erases output already on it.
|
||||
if (update?.logs || update?.resultStream) {
|
||||
this.applyToolStatus(job.toolCallId, {
|
||||
...(update.logs ? { logs: update.logs } : {}),
|
||||
...(update.resultStream ? { resultStream: update.resultStream } : {})
|
||||
})
|
||||
}
|
||||
|
||||
// Only when the reader has not already carried them, or when the run may be
|
||||
// over — the tail written between the last poll and the end is on the job
|
||||
// alone. Otherwise these are logs the tray strips and the card already has,
|
||||
// fetched a second time every tick, for every detached job in the chat.
|
||||
const wantLogs = !update || update.completed
|
||||
const fetched = await JobService.getJob({
|
||||
workspace: job.workspace,
|
||||
id: job.jobId,
|
||||
noLogs: false,
|
||||
noLogs: !wantLogs,
|
||||
noCode: true
|
||||
})
|
||||
// The user switched conversations while this getJob was in flight; its
|
||||
@@ -975,7 +1064,19 @@ export class AIChatManager {
|
||||
this.#jobPollFailures.delete(job.jobId)
|
||||
if (fetched.type === 'CompletedJob') {
|
||||
anyTerminal = true
|
||||
this.#onBackgroundJobComplete(job, fetched as CompletedJob)
|
||||
this.#jobUpdateReaders.delete(job.jobId)
|
||||
// The updates can call a landed job unfinished, and the model reads these
|
||||
// logs, so a completion seen without them is fetched again.
|
||||
const completed = wantLogs
|
||||
? (fetched as CompletedJob)
|
||||
: ((await JobService.getJob({
|
||||
workspace: job.workspace,
|
||||
id: job.jobId,
|
||||
noLogs: false,
|
||||
noCode: true
|
||||
})) as CompletedJob)
|
||||
if (gen !== this.#jobPollGeneration) return
|
||||
this.#onBackgroundJobComplete(job, completed)
|
||||
} else {
|
||||
// Store the derived status and the trimmed Job together so the tray
|
||||
// badge (JobStatusIcon) and the scalar status can never drift.
|
||||
@@ -996,6 +1097,7 @@ export class AIChatManager {
|
||||
this.#jobPollFailures.set(job.jobId, failures)
|
||||
if (httpStatus === 404 || failures >= 5) {
|
||||
this.#jobPollFailures.delete(job.jobId)
|
||||
this.#jobUpdateReaders.delete(job.jobId)
|
||||
// Vanished (404) or unreachable after repeated polls. Mark it failed WITH
|
||||
// a snapshot + tool-card patch (mirroring #onBackgroundJobComplete) so
|
||||
// neither the tray badge nor the launching tool card stays frozen on
|
||||
@@ -1013,7 +1115,8 @@ export class AIChatManager {
|
||||
this.updateJob(job.jobId, { status: 'failure', reported: true, job: trimJob(gone) })
|
||||
this.applyToolStatus(job.toolCallId, {
|
||||
content: 'Background job could not be retrieved (it may have been removed)',
|
||||
error: `Job ${job.jobId} was unreachable`
|
||||
error: `Job ${job.jobId} was unreachable`,
|
||||
isLoading: false
|
||||
})
|
||||
anyTerminal = true
|
||||
} else {
|
||||
@@ -1055,8 +1158,14 @@ export class AIChatManager {
|
||||
status === 'canceled' || !job.resultFormat
|
||||
? undefined
|
||||
: formatChatJobCompletion(completed, job.resultFormat)
|
||||
// Fill the tool card that launched it (we run outside a turn here).
|
||||
this.applyToolStatus(job.toolCallId, formatted?.card ?? completedJobToolStatus(completed))
|
||||
// Fill the tool card that launched it (we run outside a turn here). isLoading is
|
||||
// normally already false — processToolCall clears it when the launching tool
|
||||
// returns — but a card restored from a mid-turn checkpoint never saw that return,
|
||||
// so only this patch can stop it spinning.
|
||||
this.applyToolStatus(job.toolCallId, {
|
||||
...(formatted?.card ?? completedJobToolStatus(completed)),
|
||||
isLoading: false
|
||||
})
|
||||
// A user-canceled job needs no model note or auto-resume: the user stopped it
|
||||
// deliberately, so announcing it (as "FAILED", since a canceled job isn't a
|
||||
// success) or burning a turn on it would be noise.
|
||||
@@ -1130,17 +1239,12 @@ export class AIChatManager {
|
||||
// (saveChat keeps the prior mask when it is undefined).
|
||||
#jobPersistQueue: Promise<void> = Promise.resolve()
|
||||
#persistBackgroundJobs(): Promise<void> {
|
||||
this.#jobPersistQueue = this.#jobPersistQueue.then(() =>
|
||||
this.historyManager
|
||||
.saveChat(
|
||||
this.displayMessages,
|
||||
this.messages,
|
||||
this.contextUsage,
|
||||
undefined,
|
||||
$state.snapshot(this.backgroundJobs)
|
||||
)
|
||||
this.#jobPersistQueue = this.#jobPersistQueue.then(() => {
|
||||
const { display, jobs } = this.#interruptedSnapshot()
|
||||
return this.historyManager
|
||||
.saveChat(display, this.messages, this.contextUsage, undefined, jobs)
|
||||
.catch((e) => console.error('Failed to persist background jobs', e))
|
||||
)
|
||||
})
|
||||
return this.#jobPersistQueue
|
||||
}
|
||||
|
||||
@@ -1152,6 +1256,7 @@ export class AIChatManager {
|
||||
this.#jobPollGeneration++
|
||||
clearTimeout(this.#autoResumeRetry)
|
||||
this.#autoResumeRetry = undefined
|
||||
this.#jobUpdateReaders.clear()
|
||||
this.backgroundJobs = []
|
||||
this.pendingJobNotes = []
|
||||
}
|
||||
@@ -1787,6 +1892,166 @@ export class AIChatManager {
|
||||
return true
|
||||
}
|
||||
|
||||
requestRunArgs = (
|
||||
toolId: string,
|
||||
form: RunFormDisplay,
|
||||
opts?: { autoAccepted?: boolean }
|
||||
): Promise<Record<string, any> | undefined> => {
|
||||
// The tool reads the schema before it asks, so a stop during that read drains the
|
||||
// callbacks and settles the card before this runs. Installing one then would park
|
||||
// the turn on a form the settled card no longer renders, leaving nothing able to
|
||||
// resolve it. The controller is per-turn, so a later turn still opens.
|
||||
if (this.abortController?.signal.aborted) {
|
||||
// Settle the form the tool attached after the stop. Its card is about to stop
|
||||
// loading without ever having rendered, and settledToolDisplay only reaches a
|
||||
// loading one — so this is the last point the schema, with the script's own
|
||||
// password and file defaults, can be dropped. No card copy: the stop path writes
|
||||
// what the row says.
|
||||
this.#settleRunForm(toolId, undefined)
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
// Ahead of the wait, not of the stop above: the caller settled this form before
|
||||
// attaching it, so its card renders no fields and nothing here could ever resolve.
|
||||
if (opts?.autoAccepted) {
|
||||
return Promise.resolve(form.args)
|
||||
}
|
||||
// Seeded from the caller's copy, before the card renders: the file arguments on
|
||||
// `displayMessages` are redacted, so a draft built from those would open the form on
|
||||
// the marker rather than on the bytes the model proposed.
|
||||
const entry = this.#runFormEntry(toolId, form)
|
||||
return new Promise((resolve) => {
|
||||
this.#runForms.set(toolId, { ...entry, resolve })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry a run form edits through, created on first mount and shared by every later one.
|
||||
*
|
||||
* Deep snapshots, never the message's own values: those are `$state` proxies off
|
||||
* `displayMessages`, and SchemaForm edits args and schema in place (it reorders the
|
||||
* schema on mount), so anything shallower writes each keystroke — a nested password
|
||||
* included — into the persisted transcript.
|
||||
*/
|
||||
#runFormEntry = (toolId: string, runForm: RunFormDisplay): PendingRunForm => {
|
||||
const existing = this.#runForms.get(toolId)
|
||||
if (existing) return existing
|
||||
const draft = $state({
|
||||
args: ($state.snapshot(runForm.args) ?? {}) as Record<string, any>,
|
||||
schema: ($state.snapshot(runForm.schema) ?? {}) as Record<string, any>
|
||||
})
|
||||
const entry: PendingRunForm = { draft, submitting: false }
|
||||
this.#runForms.set(toolId, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
runFormDraft = (toolId: string, runForm: RunFormDisplay): RunFormDraft =>
|
||||
this.#runFormEntry(toolId, runForm).draft
|
||||
|
||||
markRunFormStarted = (toolId: string) => this.#patchRunForm(toolId, { started: true })
|
||||
|
||||
// A form restored from history has an entry once it mounts, but no resolve: the loop
|
||||
// that opened it is gone.
|
||||
isRunFormPending = (toolId: string): boolean => !!this.#runForms.get(toolId)?.resolve
|
||||
|
||||
isRunFormSubmitting = (toolId: string): boolean => this.#runForms.get(toolId)?.submitting ?? false
|
||||
|
||||
/** False when a submit is already in flight for this call, so the caller can drop a
|
||||
* second one rather than mint a second set of ephemeral secret variables for it. */
|
||||
beginRunFormSubmit = (toolId: string): boolean => {
|
||||
const entry = this.#runForms.get(toolId)
|
||||
if (!entry || entry.submitting) return false
|
||||
this.#runForms.set(toolId, { ...entry, submitting: true })
|
||||
return true
|
||||
}
|
||||
|
||||
endRunFormSubmit = (toolId: string) => {
|
||||
const entry = this.#runForms.get(toolId)
|
||||
if (entry?.submitting) this.#runForms.set(toolId, { ...entry, submitting: false })
|
||||
}
|
||||
|
||||
/** Whether any form of this chat is still waiting on the user. Asked instead of looking
|
||||
* the form up in the panel's DOM: when the preview holds it, the card is collapsed and
|
||||
* the only mounted copy is outside the panel — where a DOM query would miss it and let
|
||||
* Escape discard what has been typed. */
|
||||
get hasPendingRunForm(): boolean {
|
||||
for (const entry of this.#runForms.values()) if (entry.resolve) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** False when the form is no longer pending, so the caller can say so instead of
|
||||
* leaving its submit button spinning on a run that will never start. */
|
||||
handleRunFormSubmit = (toolId: string, args: Record<string, any>): boolean => {
|
||||
if (!this.isRunFormPending(toolId)) return false
|
||||
this.#settleRunForm(toolId, args)
|
||||
return true
|
||||
}
|
||||
|
||||
handleRunFormCancel = (toolId: string) => {
|
||||
// The card's own copy is settled here rather than only in the tool's fn, which a form
|
||||
// restored from history no longer has: Cancel is that card's one way out, and while it
|
||||
// stays active the whole session reads as needs-confirmation (getSessionChatStatus asks
|
||||
// pendingUserAction before loading). Clearing isLoading is part of settling — canceled
|
||||
// alone unmounts the form but leaves the card shimmering.
|
||||
this.#settleRunForm(toolId, undefined, (runForm) => ({
|
||||
isLoading: false,
|
||||
error: 'Cancelled by user',
|
||||
content: `Run of "${runForm.path}" cancelled by user`
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The one way a run form stops waiting on the user: `submitted` is the arguments to run
|
||||
* with, `undefined` a cancellation.
|
||||
*
|
||||
* `card` is for a settler that also owns what the row reads — pressing Cancel does, a
|
||||
* stopped turn leaves it to settledToolDisplay.
|
||||
*/
|
||||
#settleRunForm = (
|
||||
toolId: string,
|
||||
submitted: Record<string, any> | undefined,
|
||||
card?: (runForm: RunFormDisplay) => Partial<ToolDisplayMessage>
|
||||
) => {
|
||||
const entry = this.#runForms.get(toolId)
|
||||
// Its draft holds whatever was typed into the form, a minted password included.
|
||||
this.#runForms.delete(toolId)
|
||||
// Cancelled, so no run follows it into that tab (a submitted one is handed over by
|
||||
// registerJob instead) — take the tab with it rather than leaving a dead form open.
|
||||
if (submitted === undefined) this.closeRunForm?.(toolId)
|
||||
const cancelledArgs =
|
||||
submitted === undefined && entry ? this.#settledFormArgs(entry) : undefined
|
||||
this.#patchRunForm(
|
||||
toolId,
|
||||
submitted ? { submitted: true } : { canceled: true },
|
||||
cancelledArgs ? (runForm) => ({ ...card?.(runForm), parameters: cancelledArgs }) : card
|
||||
)
|
||||
entry?.resolve?.(submitted)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a form that never ran leaves on its card. A run writes its own arguments there once
|
||||
* it has them and a cancellation never reaches that write, so without this the card keeps
|
||||
* the proposal it was published on — naming a secret the field had already replaced with a
|
||||
* reference. A reference stands, anything still literal does not.
|
||||
*/
|
||||
#settledFormArgs = (entry: PendingRunForm): Record<string, any> =>
|
||||
redactFileArgs(redactSecretArgs(entry.draft.args, entry.draft.schema), entry.draft.schema)
|
||||
|
||||
#patchRunForm = (
|
||||
toolId: string,
|
||||
patch: Partial<RunFormDisplay>,
|
||||
card?: (runForm: RunFormDisplay) => Partial<ToolDisplayMessage>
|
||||
) => {
|
||||
this.displayMessages = this.displayMessages.map((message) =>
|
||||
message.role === 'tool' && message.tool_call_id === toolId && message.runForm
|
||||
? {
|
||||
...message,
|
||||
...card?.(message.runForm),
|
||||
runForm: settledRunForm({ ...message.runForm, ...patch })
|
||||
}
|
||||
: message
|
||||
)
|
||||
}
|
||||
|
||||
setAiChatInput(aiChatInput: AIChatInput | null) {
|
||||
this.aiChatInput = aiChatInput
|
||||
}
|
||||
@@ -3311,7 +3576,7 @@ export class AIChatManager {
|
||||
)
|
||||
if (messages.length === this.messages.length) return
|
||||
checkpointedShape = shape
|
||||
const display = this.settledToolDisplay(this.displayMessages, 'Interrupted')
|
||||
const { display, jobs } = this.#interruptedSnapshot()
|
||||
// onMessageEnd is what gives streamed text its bubble, and it clears
|
||||
// currentReply doing so — text still there has none, and without one the
|
||||
// reply returns as context the reader cannot see.
|
||||
@@ -3331,7 +3596,8 @@ export class AIChatManager {
|
||||
// partial turn — enough to skip the compaction its next send needs.
|
||||
// Omitting drops the field, which is the "readers estimate" fallback.
|
||||
undefined,
|
||||
this.modifiedItems ? [...this.modifiedItems] : undefined
|
||||
this.modifiedItems ? [...this.modifiedItems] : undefined,
|
||||
jobs
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Failed to checkpoint chat mid-turn', e)
|
||||
@@ -3701,6 +3967,8 @@ export class AIChatManager {
|
||||
isPlanModeActive: () => this.planModeActive,
|
||||
onToolBlockedByPlanMode: this.planMode.noteBlockedTool,
|
||||
requestUserQuestion: this.requestUserQuestion,
|
||||
requestRunArgs: this.requestRunArgs,
|
||||
markRunFormStarted: this.markRunFormStarted,
|
||||
onItemModified: (kind, path) => this.recordModifiedItem(kind, path),
|
||||
onItemDeployed: (kind, from, to) => void this.renameModifiedItem(kind, from, to),
|
||||
onItemDiscarded: (kind, path) => void this.removeModifiedItem(kind, path),
|
||||
@@ -3957,6 +4225,19 @@ export class AIChatManager {
|
||||
resolveQuestion(undefined)
|
||||
}
|
||||
this.userQuestionCallbacks.clear()
|
||||
for (const [toolId, entry] of this.#runForms) {
|
||||
entry.resolve?.(undefined)
|
||||
// Stopping the turn is the form's other way out, and the draft dies with this loop:
|
||||
// settledToolDisplay settles the card below without ever seeing what was typed.
|
||||
this.#patchRunForm(toolId, {}, () => ({ parameters: this.#settledFormArgs(entry) }))
|
||||
// The form settles with the turn, so a preview tab holding it goes too rather
|
||||
// than being left on a form that can no longer run.
|
||||
this.closeRunForm?.(toolId)
|
||||
}
|
||||
// Not through #settleRunForm: settledToolDisplay settles every card of the stopped
|
||||
// turn at once, and it alone can tell a run that reached the server from one that
|
||||
// never did.
|
||||
this.#runForms.clear()
|
||||
const cancelReason = reason ?? USER_CANCEL_REASON
|
||||
console.log('cancelling request:', {
|
||||
reason: cancelReason,
|
||||
@@ -4193,6 +4474,15 @@ export class AIChatManager {
|
||||
if (this.isJobNonTerminal(j.status)) j.detached = true
|
||||
}
|
||||
if (this.backgroundJobs.length > 0) this.backgroundJobs = [...this.backgroundJobs]
|
||||
// Reloading resolves no card on its own. Settle every one the poller above
|
||||
// will not reach, whoever wrote it — a record from a build that stored cards
|
||||
// without their jobs would otherwise restore one that spins forever.
|
||||
const pollable = this.#pollableToolCalls()
|
||||
this.displayMessages = this.settledToolDisplay(
|
||||
this.displayMessages,
|
||||
'Interrupted',
|
||||
(message) => !pollable.has(message.tool_call_id)
|
||||
)
|
||||
this.#ensureJobPoller()
|
||||
// Message-attached files live in the transcript, not in the store's
|
||||
// persistence — rebuild their rows so the loaded chat's references are
|
||||
@@ -4522,10 +4812,25 @@ export class AIChatManager {
|
||||
// through here first.
|
||||
private settledToolDisplay = (
|
||||
messages: DisplayMessage[],
|
||||
messageText: string
|
||||
messageText: string,
|
||||
shouldSettle: (message: ToolDisplayMessage) => boolean = () => true
|
||||
): DisplayMessage[] =>
|
||||
messages.map((message) => {
|
||||
if (message.role === 'tool' && (message.isLoading || message.isQueued)) {
|
||||
if (
|
||||
message.role === 'tool' &&
|
||||
(message.isLoading || message.isQueued) &&
|
||||
shouldSettle(message)
|
||||
) {
|
||||
// Stopping the turn does not stop the job, and between Run and the job's id
|
||||
// there is no way to know whether the server queued one: nothing threads the
|
||||
// abort into that request, so it lands either way. That window says so
|
||||
// rather than picking a side — "canceled" hides a script that ran, "started"
|
||||
// invents one that did not.
|
||||
const runState = message.runForm?.started
|
||||
? 'started'
|
||||
: message.runForm?.submitted
|
||||
? 'starting'
|
||||
: 'idle'
|
||||
return {
|
||||
...message,
|
||||
isLoading: false,
|
||||
@@ -4535,14 +4840,25 @@ export class AIChatManager {
|
||||
// and a card that hides its result as still-streaming.
|
||||
needsConfirmation: false,
|
||||
isStreamingArguments: false,
|
||||
// A question's card disappears once canceled, so keep the question
|
||||
// itself readable in the collapsed header.
|
||||
// An interactive card disappears once canceled, so keep what it was
|
||||
// asking readable in the collapsed header.
|
||||
content: message.userQuestion
|
||||
? `Asked: ${message.userQuestion.question} — ${messageText}`
|
||||
: messageText,
|
||||
error: messageText,
|
||||
: message.runForm
|
||||
? runState === 'started'
|
||||
? `Run ${message.runForm.path} — started, stopped tracking before it finished`
|
||||
: runState === 'starting'
|
||||
? `Run ${message.runForm.path} — ${messageText} while starting, check the runs page for a job`
|
||||
: `Run ${message.runForm.path} — ${messageText}`
|
||||
: messageText,
|
||||
// A run that reached the server keeps whatever the job reported: it is not
|
||||
// this turn's error, and the jobs tray is still following it.
|
||||
...(runState === 'idle' ? { error: messageText } : {}),
|
||||
userQuestion: message.userQuestion
|
||||
? { ...message.userQuestion, canceled: true }
|
||||
: undefined,
|
||||
runForm: message.runForm
|
||||
? settledRunForm({ ...message.runForm, canceled: runState === 'idle' })
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -4552,6 +4868,36 @@ export class AIChatManager {
|
||||
cancelLoadingTools = (messageText: 'Canceled' | 'Error' = 'Canceled') => {
|
||||
this.displayMessages = this.settledToolDisplay(this.displayMessages, messageText)
|
||||
}
|
||||
|
||||
/** What the transcript would be if the turn stopped here — for the writes that fire
|
||||
* mid-turn without ending it. Loading is a property of this page: reloading resolves no
|
||||
* card, so one stored still pending comes back asking for input nothing can deliver.
|
||||
* Settles the stored copy only; the live turn keeps its cards.
|
||||
*
|
||||
* Except a card the poller will resolve after a reload: settling that one stores an
|
||||
* "Interrupted" error the patch a completed job merges in carries nothing to clear.
|
||||
* Which cards those are is loadPastChat's question, asked the same way — and the poller
|
||||
* only knows the jobs stored in the same record, so both go into the same saveChat. */
|
||||
#interruptedSnapshot = (): { display: DisplayMessage[]; jobs: ChatJob[] } => {
|
||||
const polled = this.#pollableToolCalls()
|
||||
return {
|
||||
display: this.settledToolDisplay(
|
||||
this.displayMessages,
|
||||
'Interrupted',
|
||||
(message) => !polled.has(message.tool_call_id)
|
||||
),
|
||||
jobs: $state.snapshot(this.backgroundJobs) as ChatJob[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Tool calls a restored transcript can still resolve. loadPastChat re-attaches the
|
||||
* poller to every non-terminal job and nothing else runs after a reload, so this is
|
||||
* the whole set — asked identically when storing a card and when restoring one, or
|
||||
* the two drift and a card is kept by one and stranded by the other. */
|
||||
#pollableToolCalls = (): Set<string> =>
|
||||
new Set(
|
||||
this.backgroundJobs.filter((j) => this.isJobNonTerminal(j.status)).map((j) => j.toolCallId)
|
||||
)
|
||||
}
|
||||
|
||||
export const aiChatManager = new AIChatManager()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { writable } from 'svelte/store'
|
||||
import type { FlowAIChatHelpers } from './flow/core'
|
||||
import type { PipelineAIChatHelpers } from './pipeline/core'
|
||||
import type { CurrentEditor } from '$lib/components/flows/types'
|
||||
@@ -36,6 +37,7 @@ const mocks = vi.hoisted(() => ({
|
||||
runChatLoop: vi.fn(),
|
||||
listResource: vi.fn(),
|
||||
getJob: vi.fn(),
|
||||
getJobUpdates: vi.fn(),
|
||||
whoami: vi.fn(),
|
||||
workspace: 'test_workspace' as string | undefined,
|
||||
// The workspace being browsed, which a session chat's own workspace need not be.
|
||||
@@ -60,7 +62,8 @@ vi.mock('$lib/gen', () => ({
|
||||
whoami: mocks.whoami
|
||||
},
|
||||
JobService: {
|
||||
getJob: mocks.getJob
|
||||
getJob: mocks.getJob,
|
||||
getJobUpdates: mocks.getJobUpdates
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -121,6 +124,9 @@ vi.mock('$lib/toast', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('$lib/aiStore', () => ({
|
||||
// `sendRequest` reads it before anything else, so a test that goes through a real turn
|
||||
// rather than driving the manager directly needs it present and enabled.
|
||||
copilotInfo: writable({ enabled: true, workspaceDisabled: false, aiModels: [] }),
|
||||
getCurrentModel: mocks.getCurrentModel,
|
||||
tryGetCurrentModel: mocks.tryGetCurrentModel,
|
||||
getCombinedCustomPrompt: () => '',
|
||||
@@ -172,6 +178,10 @@ beforeEach(() => {
|
||||
mocks.getOpenaiClient.mockReturnValue({})
|
||||
mocks.getAnthropicClient.mockReturnValue({})
|
||||
mocks.listResource.mockResolvedValue([])
|
||||
// Re-seeded here rather than in the factory: clearAllMocks keeps implementations, so a
|
||||
// test that makes the updates endpoint fail would otherwise leave it failing for the rest
|
||||
// of the file. Neutral by default — completion is getJob's answer.
|
||||
mocks.getJobUpdates.mockResolvedValue({ completed: false, running: true })
|
||||
mocks.workspace = 'test_workspace'
|
||||
mocks.runChatLoop.mockResolvedValue({
|
||||
addedMessages: [],
|
||||
@@ -226,6 +236,241 @@ describe('AIChatManager unmounted-chat guard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager run form', () => {
|
||||
// A transcript can be persisted mid-turn (a background job's status write) and
|
||||
// restored into a fresh manager, which has none of the turn's callbacks. Cancel is
|
||||
// then the card's only exit, and until it settles pendingUserAction keeps the whole
|
||||
// session reading as needs-confirmation.
|
||||
// A save that fires mid-turn (jobs tray, review dock) stores a transcript nothing
|
||||
// will resume. Storing a card still pending brings back a form whose Run resolves
|
||||
// no callback.
|
||||
it('stores loading cards settled when a mid-turn save fires', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.displayMessages = [
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_r',
|
||||
content: 'Waiting for you to confirm the arguments of "f/a/b"',
|
||||
isLoading: true,
|
||||
runForm: { path: 'f/a/b', schema: {}, args: {} }
|
||||
}
|
||||
]
|
||||
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
|
||||
|
||||
manager.markJobsReviewed([])
|
||||
manager.dismissJob('nope')
|
||||
await Promise.resolve()
|
||||
|
||||
const { isActiveRunForm } = await import('./shared')
|
||||
const stored = saveChat.mock.calls.at(-1)?.[0]?.[0]
|
||||
expect(stored?.runForm?.canceled).toBe(true)
|
||||
// What every mid-turn save has to hold: no stored card renders a live form. A
|
||||
// save path added without settling would restore a Run that resolves nothing.
|
||||
expect(isActiveRunForm(stored!)).toBe(false)
|
||||
// The live card is untouched — the turn is still parked on it.
|
||||
expect(manager.displayMessages[0].isLoading).toBe(true)
|
||||
})
|
||||
|
||||
// Only the rendered form reads the schema, and a settled card renders none. Kept, it
|
||||
// would sit in history for the life of the chat with the script's own password and
|
||||
// file defaults inside it.
|
||||
it('drops the schema from a card that has stopped showing a form', () => {
|
||||
const manager = new AIChatManager()
|
||||
const runForm = { path: 'f/a/b', schema: { properties: { tok: { password: true } } }, args: {} }
|
||||
manager.displayMessages = [
|
||||
{ role: 'tool', tool_call_id: 'call_r', content: '', isLoading: true, runForm }
|
||||
]
|
||||
|
||||
manager.handleRunFormCancel('call_r')
|
||||
|
||||
expect(manager.displayMessages[0].runForm?.schema).toBeUndefined()
|
||||
expect(manager.displayMessages[0].runForm?.canceled).toBe(true)
|
||||
})
|
||||
|
||||
// A run writes what it ran onto the card; a cancelled one never gets there, so without
|
||||
// this its Inputs tab still names the proposal the card was published on — a secret the
|
||||
// mounted field had already replaced with a reference.
|
||||
it('settles a cancelled card on what the form held, not on the proposal', async () => {
|
||||
const manager = new AIChatManager()
|
||||
const schema = {
|
||||
properties: { token: { password: true }, spare: { password: true }, note: {} }
|
||||
}
|
||||
const runForm = {
|
||||
path: 'f/a/b',
|
||||
schema,
|
||||
args: { token: 'hunter2', spare: 'untouched', note: 'hello' }
|
||||
}
|
||||
manager.displayMessages = [
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_r',
|
||||
content: '',
|
||||
isLoading: true,
|
||||
parameters: { ...runForm.args },
|
||||
runForm
|
||||
}
|
||||
]
|
||||
|
||||
void manager.requestRunArgs('call_r', runForm)
|
||||
const draft = manager.runFormDraft('call_r', runForm)
|
||||
draft.args.token = '$var:u/admin/secret_arg/AbC'
|
||||
draft.args.note = 'goodbye'
|
||||
|
||||
manager.handleRunFormCancel('call_r')
|
||||
|
||||
expect(manager.displayMessages[0].parameters).toEqual({
|
||||
token: '$var:u/admin/secret_arg/AbC',
|
||||
// Never minted, so still the secret itself.
|
||||
spare: '<hidden>',
|
||||
note: 'goodbye'
|
||||
})
|
||||
})
|
||||
|
||||
// Stopping the turn is the form's other way out, and it settles cards through
|
||||
// settledToolDisplay rather than through #settleRunForm.
|
||||
it('settles a stopped form on what it held too', () => {
|
||||
const manager = new AIChatManager()
|
||||
const schema = { properties: { token: { password: true }, note: {} } }
|
||||
const runForm = { path: 'f/a/b', schema, args: { token: 'hunter2', note: 'hello' } }
|
||||
manager.displayMessages = [
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_r',
|
||||
content: '',
|
||||
isLoading: true,
|
||||
parameters: { ...runForm.args },
|
||||
runForm
|
||||
}
|
||||
]
|
||||
|
||||
void manager.requestRunArgs('call_r', runForm)
|
||||
const draft = manager.runFormDraft('call_r', runForm)
|
||||
draft.args.token = '$var:u/admin/secret_arg/AbC'
|
||||
draft.args.note = 'goodbye'
|
||||
|
||||
manager.cancel()
|
||||
|
||||
expect(manager.displayMessages[0].parameters).toEqual({
|
||||
token: '$var:u/admin/secret_arg/AbC',
|
||||
note: 'goodbye'
|
||||
})
|
||||
})
|
||||
|
||||
// The tool reads the deployed schema before it asks for arguments. A stop during that
|
||||
// read drains the callbacks and settles the card, so a waiter installed afterwards was
|
||||
// one no rendered form could resolve: the turn stayed loading until a second stop.
|
||||
it('installs no run-form waiter once the turn is stopped', async () => {
|
||||
const manager = new AIChatManager()
|
||||
// The turn the tool is running under; cancel aborts it.
|
||||
manager.abortController = new AbortController()
|
||||
manager.displayMessages = [
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_late',
|
||||
content: 'Executing...',
|
||||
isLoading: true
|
||||
}
|
||||
]
|
||||
|
||||
manager.cancel()
|
||||
|
||||
await expect(
|
||||
manager.requestRunArgs('call_late', { path: 'f/a/b', schema: {}, args: {} })
|
||||
).resolves.toBeUndefined()
|
||||
expect(manager.isRunFormPending('call_late')).toBe(false)
|
||||
})
|
||||
|
||||
// The stop lands while the tool is still reading the deployed schema, so the form is
|
||||
// attached after the card was settled. Nothing settles it a second time — the card
|
||||
// stops loading without the form ever rendering — so the schema would otherwise stay
|
||||
// in the transcript with the script's own password default inside it.
|
||||
it('drops the schema from a form attached after the turn was stopped', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.abortController = new AbortController()
|
||||
manager.displayMessages = [
|
||||
{ role: 'tool', tool_call_id: 'call_x', content: 'Executing...', isLoading: true }
|
||||
]
|
||||
manager.cancel()
|
||||
|
||||
const runForm = {
|
||||
path: 'f/a/b',
|
||||
schema: { properties: { tok: { password: true, default: 'hunter2' } } },
|
||||
args: {}
|
||||
}
|
||||
manager.applyToolStatus('call_x', { content: 'Waiting for you...', runForm, isLoading: true })
|
||||
|
||||
await expect(manager.requestRunArgs('call_x', runForm)).resolves.toBeUndefined()
|
||||
expect(manager.displayMessages[0].runForm?.schema).toBeUndefined()
|
||||
expect(JSON.stringify(manager.displayMessages[0])).not.toContain('hunter2')
|
||||
})
|
||||
|
||||
// Stop ends the turn, not the job: the deployed script is already running with all
|
||||
// its side effects, so the transcript must not record it as cancelled.
|
||||
it('does not mark a started run cancelled when the turn is stopped', () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.displayMessages = [
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_s',
|
||||
content: 'Running "f/a/b"...',
|
||||
isLoading: true,
|
||||
runForm: { path: 'f/a/b', schema: {}, args: {}, submitted: true, started: true }
|
||||
}
|
||||
]
|
||||
|
||||
manager.cancelLoadingTools()
|
||||
|
||||
const settled = manager.displayMessages[0]
|
||||
expect(settled.runForm?.canceled).toBe(false)
|
||||
expect(settled.error).toBe(undefined)
|
||||
expect(settled.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
// Run flips `submitted` a round trip before the job id arrives, and nothing threads
|
||||
// the stop into that request — so the card claims neither outcome for that window.
|
||||
it('claims neither outcome for a run stopped while its job was starting', () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.displayMessages = [
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_s',
|
||||
content: 'Running "f/a/b"...',
|
||||
isLoading: true,
|
||||
runForm: { path: 'f/a/b', schema: {}, args: {}, submitted: true }
|
||||
}
|
||||
]
|
||||
|
||||
manager.cancelLoadingTools()
|
||||
|
||||
const settled = manager.displayMessages[0]
|
||||
expect(settled.runForm?.canceled).toBe(false)
|
||||
expect(settled.error).toBe(undefined)
|
||||
expect(settled.content).toBe(
|
||||
'Run f/a/b — Canceled while starting, check the runs page for a job'
|
||||
)
|
||||
})
|
||||
|
||||
// Only a form the user never submitted was cancelled outright.
|
||||
it('marks an unsubmitted run cancelled when the turn is stopped', () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.displayMessages = [
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_u',
|
||||
content: 'Waiting for you to confirm the arguments of "f/a/b"',
|
||||
isLoading: true,
|
||||
runForm: { path: 'f/a/b', schema: {}, args: {} }
|
||||
}
|
||||
]
|
||||
|
||||
manager.cancelLoadingTools()
|
||||
|
||||
const settled = manager.displayMessages[0]
|
||||
expect(settled.runForm?.canceled).toBe(true)
|
||||
expect(settled.content).toBe('Run f/a/b — Canceled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager.sendOrQueue', () => {
|
||||
// The programmatic senders (an editor's "AI Fix", an arriving hand-off) have no
|
||||
// composer to enforce the composer's rule for them: a second loop on one manager
|
||||
@@ -2147,6 +2392,56 @@ describe('AIChatManager queued messages', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// A checkpoint that leaves a card loading is betting the poller resolves it after
|
||||
// the reload, and the poller only knows the jobs stored in the same record —
|
||||
// registering one does not write it.
|
||||
it('stores the job behind a card the checkpoint leaves loading', async () => {
|
||||
const leavePage = stubHidingPage()
|
||||
const manager = createManager()
|
||||
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
|
||||
|
||||
mocks.runChatLoop.mockImplementationOnce(async (config: any) => {
|
||||
config.addedMessages.push({
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{ id: 't1', type: 'function' as const, function: { name: 'run_script', arguments: '{}' } }
|
||||
]
|
||||
})
|
||||
// Inside the inline wait: the job is registered and still running, so no
|
||||
// persist path has run for it yet.
|
||||
manager.registerJob({
|
||||
jobId: 'job-1',
|
||||
toolCallId: 't1',
|
||||
kind: 'script',
|
||||
label: 'f/a/b',
|
||||
workspace: 'ws'
|
||||
})
|
||||
config.callbacks.setToolStatus('t1', { content: 'Running...', isLoading: true })
|
||||
leavePage.forEach((fn) => fn())
|
||||
// The wait ends normally, so the only save that stored this card loading is
|
||||
// the checkpoint that landed inside it.
|
||||
config.callbacks.setToolStatus('t1', { content: 'Ran', isLoading: false })
|
||||
manager.updateJob('job-1', { status: 'success' })
|
||||
config.addedMessages.push({ role: 'tool' as const, tool_call_id: 't1', content: 'ran' })
|
||||
return {
|
||||
addedMessages: config.addedMessages,
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
hitMaxIterations: false
|
||||
}
|
||||
})
|
||||
|
||||
await manager.sendRequest({ instructions: 'run it' })
|
||||
|
||||
const checkpoint = saveChat.mock.calls.find(([display]) =>
|
||||
(display as DisplayMessage[]).some(
|
||||
(m) => m.role === 'tool' && m.tool_call_id === 't1' && m.isLoading
|
||||
)
|
||||
)
|
||||
expect(checkpoint).toBeDefined()
|
||||
expect(checkpoint?.[4]).toEqual([expect.objectContaining({ jobId: 'job-1' })])
|
||||
})
|
||||
|
||||
it('stops checkpointing once the turn commits, so the transcript is never doubled', async () => {
|
||||
const leavePage = stubHidingPage()
|
||||
const manager = createManager()
|
||||
@@ -2648,6 +2943,33 @@ describe('AIChatManager queued messages', () => {
|
||||
expect(manager.modifiedItems?.size).toBe(0)
|
||||
})
|
||||
|
||||
// Reloading resolves no card on its own. Only the poller can, and only for the jobs
|
||||
// that came back with the transcript — so a stored card without one must arrive
|
||||
// settled, whichever build wrote it.
|
||||
it('settles a restored loading card that no job came back to resolve', async () => {
|
||||
const manager = createManager(createInputMock())
|
||||
mocks.getJob.mockResolvedValue({ type: 'QueuedJob', id: 'job-1' })
|
||||
vi.spyOn(manager.historyManager, 'loadPastChat').mockReturnValue({
|
||||
id: 'reloaded',
|
||||
title: 'Reloaded',
|
||||
displayMessages: [
|
||||
{ role: 'tool', tool_call_id: 'orphan', content: 'Running...', isLoading: true },
|
||||
{ role: 'tool', tool_call_id: 'polled', content: 'Running...', isLoading: true }
|
||||
],
|
||||
actualMessages: [],
|
||||
lastModified: 0
|
||||
} as unknown as ReturnType<typeof manager.historyManager.loadPastChat>)
|
||||
vi.spyOn(manager.historyManager, 'getBackgroundJobs').mockReturnValue([
|
||||
{ jobId: 'job-1', toolCallId: 'polled', status: 'running' }
|
||||
] as any)
|
||||
|
||||
await manager.loadPastChat('reloaded')
|
||||
|
||||
const card = (id: string) => manager.displayMessages.find((m) => m.tool_call_id === id) as any
|
||||
expect(card('orphan')).toMatchObject({ isLoading: false, error: 'Interrupted' })
|
||||
expect(card('polled').isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it('seeds a session chat mask from its stored modified-items', async () => {
|
||||
const manager = createManager(createInputMock())
|
||||
manager.isSessionChat = true
|
||||
@@ -3727,6 +4049,36 @@ describe('AIChatManager background job completion', () => {
|
||||
resultFormat: { kind: 'datatable' as const, datatableName: 'main' }
|
||||
}
|
||||
|
||||
// Live, processToolCall clears isLoading when the launching tool returns. A card
|
||||
// restored from a mid-turn checkpoint never sees that return, so completing its job
|
||||
// is the only thing left that can stop it spinning.
|
||||
it('stops a restored card spinning when the poller completes its job', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.registerJob(datatableJob)
|
||||
manager.displayMessages = [
|
||||
{ role: 'tool', tool_call_id: 'tc-1', content: 'Running...', isLoading: true } as any
|
||||
]
|
||||
mocks.getJob.mockResolvedValue(completed({ result: [{ n: 1 }] }))
|
||||
|
||||
await completeDetachedJob(manager)
|
||||
|
||||
expect((manager.displayMessages[0] as any).isLoading).toBe(false)
|
||||
})
|
||||
|
||||
// Streaming rides on a second endpoint; landing the job must not. A poll that always
|
||||
// fails would otherwise spend the failure budget and drain a job that finished, leaving
|
||||
// the card on "unreachable".
|
||||
it('completes a job whose updates endpoint keeps failing', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.registerJob(datatableJob)
|
||||
mocks.getJobUpdates.mockRejectedValue(new Error('updates unavailable'))
|
||||
mocks.getJob.mockResolvedValue(completed({ result: [{ n: 1 }] }))
|
||||
|
||||
await completeDetachedJob(manager)
|
||||
|
||||
expect(manager.backgroundJobs[0]?.status).toBe('success')
|
||||
})
|
||||
|
||||
it('reconstructs the datatable result contract from the persisted resultFormat', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.registerJob(datatableJob)
|
||||
@@ -3740,12 +4092,48 @@ describe('AIChatManager background job completion', () => {
|
||||
// the SQL contract (row count + shaped rows) rather than generic job output.
|
||||
expect(applyToolStatus).toHaveBeenCalledWith('tc-1', {
|
||||
content: 'Query returned 2 row(s)',
|
||||
result: JSON.stringify([{ n: 1 }, { n: 2 }], null, 2)
|
||||
result: JSON.stringify([{ n: 1 }, { n: 2 }], null, 2),
|
||||
isLoading: false
|
||||
})
|
||||
expect(manager.pendingJobNotes).toHaveLength(1)
|
||||
expect(manager.pendingJobNotes[0]).toContain('"rowCount": 2')
|
||||
})
|
||||
|
||||
// Detaching persists while the card is still loading. Storing it as interrupted would
|
||||
// stick, because the patch a completed job merges in carries no error to clear.
|
||||
it("stores a detached job's card unsettled, so a later success is not left an error", async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.registerJob(datatableJob)
|
||||
manager.applyToolStatus('tc-1', { content: 'running in background', isLoading: true })
|
||||
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
|
||||
|
||||
manager.markJobDetached('job-1')
|
||||
await vi.waitFor(() => expect(saveChat).toHaveBeenCalled())
|
||||
|
||||
const stored = (saveChat.mock.calls.at(-1)?.[0] as any[]).find((m) => m.tool_call_id === 'tc-1')
|
||||
expect(stored.error).toBeUndefined()
|
||||
expect(stored.content).toBe('running in background')
|
||||
})
|
||||
|
||||
// A job still waiting inline is detached by the restore and polled like any other, so
|
||||
// its card is one the poller resolves too — storing it as interrupted sticks, for the
|
||||
// same reason an already-detached one would.
|
||||
it("stores an inline job's card unsettled, so a later success is not left an error", async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.registerJob(datatableJob)
|
||||
manager.registerJob({ ...datatableJob, jobId: 'job-2', toolCallId: 'tc-2' })
|
||||
manager.applyToolStatus('tc-1', { content: 'running', isLoading: true })
|
||||
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
|
||||
|
||||
// The other job reaching a terminal status is what fires the save; job-1 is still
|
||||
// inside its inline wait when it lands.
|
||||
manager.updateJob('job-2', { status: 'success' })
|
||||
await vi.waitFor(() => expect(saveChat).toHaveBeenCalled())
|
||||
|
||||
const stored = (saveChat.mock.calls.at(-1)?.[0] as any[]).find((m) => m.tool_call_id === 'tc-1')
|
||||
expect(stored.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips reconstruction and emits no note for a canceled detached job', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.registerJob(datatableJob)
|
||||
@@ -3757,9 +4145,13 @@ describe('AIChatManager background job completion', () => {
|
||||
// A user cancel isn't a result to shape or a completion to announce.
|
||||
expect(manager.pendingJobNotes).toHaveLength(0)
|
||||
expect(manager.backgroundJobs[0]?.status).toBe('canceled')
|
||||
// The raw result, not the shaping this job's resultFormat would have applied — and no
|
||||
// `error`, which is what keeps the card off the failure styling.
|
||||
expect(applyToolStatus).toHaveBeenCalledWith('tc-1', {
|
||||
content: 'Background job canceled',
|
||||
logs: expect.anything()
|
||||
result: expect.stringContaining('"n": 1'),
|
||||
logs: expect.anything(),
|
||||
isLoading: false
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
viewport bottom) so the mount scrollIntoView leaves the Submit button uncovered. -->
|
||||
<div
|
||||
bind:this={cardNode}
|
||||
class="scroll-mb-8 rounded-md border border-border-light bg-surface p-3"
|
||||
class="scroll-mb-8 rounded-md border border-border-light bg-surface-tertiary p-3 shadow-sm"
|
||||
data-chat-keyboard-scope="ask-user-question"
|
||||
use:focusActiveOnBackgroundClick
|
||||
>
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
/** Set before the label and left unemphasised, so `labelClass` lifts the subject alone:
|
||||
* the part of the heading that is grammar stays quiet. A step lighter than the row's own
|
||||
* label weight, because a caller emphasising the label is also setting a proportional
|
||||
* typeface, whose medium reads heavier than the mono one at this size. */
|
||||
labelPrefix?: string
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
// A card with nothing to reveal keeps the header inert (no chevron, no
|
||||
@@ -13,6 +18,9 @@
|
||||
toggleable?: boolean
|
||||
// Sweeps a highlight across the label while the row is in progress.
|
||||
shimmer?: boolean
|
||||
// Ahead of the label, inside the toggle button: a status that reads as part of the
|
||||
// row rather than as another control, leaving the chevron next to the label it opens.
|
||||
headerLeft?: Snippet
|
||||
// Pinned to the right of the header row, outside the toggle button.
|
||||
headerRight?: Snippet
|
||||
// Always-visible content between the header and the expandable body.
|
||||
@@ -26,10 +34,12 @@
|
||||
|
||||
let {
|
||||
label,
|
||||
labelPrefix,
|
||||
expanded,
|
||||
onToggle,
|
||||
toggleable = true,
|
||||
shimmer = false,
|
||||
headerLeft,
|
||||
headerRight,
|
||||
belowHeader,
|
||||
children,
|
||||
@@ -49,7 +59,8 @@
|
||||
highlight && 'text-emphasis'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{#if labelPrefix}<span class="font-normal text-secondary">{labelPrefix}</span
|
||||
> {/if}{label}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
@@ -63,6 +74,7 @@
|
||||
disabled={!toggleable}
|
||||
aria-expanded={toggleable ? expanded : undefined}
|
||||
>
|
||||
{@render headerLeft?.()}
|
||||
{#if shimmer}
|
||||
<span class="shimmer inline-flex items-center min-w-0">
|
||||
{@render labelText(false)}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick, untrack } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { Play, X } from 'lucide-svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import { processSecretArgs } from '$lib/components/secretArgUtils'
|
||||
import { enforceDisabledDefaults, resetKeysToast } from '$lib/components/job_args'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||||
import { scrollFades } from './scrollFades.svelte'
|
||||
import type { RunFormDisplay } from './shared'
|
||||
|
||||
// Never the imported singleton: submitting has to resolve the pending callback of
|
||||
// the manager that opened this form, which in a session is a per-pane one.
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
interface Props {
|
||||
toolCallId: string
|
||||
runForm: RunFormDisplay
|
||||
/** `card` caps its own height inside the chat transcript; `pane` fills the preview
|
||||
* tab it was opened into and lets the fields take the whole height. */
|
||||
layout?: 'card' | 'pane'
|
||||
}
|
||||
|
||||
let { toolCallId, runForm, layout = 'card' }: Props = $props()
|
||||
|
||||
// The chat's workspace, not the globally-active one: a session may be acting on a
|
||||
// fork, and that is where the job runs — so the pickers and the ephemeral secret
|
||||
// variables have to resolve there too.
|
||||
const workspace = $derived(aiChatManager.operatingWorkspace)
|
||||
|
||||
// The manager's draft, not a copy of its own: the chat card and the preview pane are two
|
||||
// views of one form, and moving between them has to keep what was typed. untrack because
|
||||
// the message is replaced on every patch to the card, and re-seeding from a later copy of
|
||||
// it would discard those edits.
|
||||
const draft = untrack(() => aiChatManager.runFormDraft(toolCallId, runForm))
|
||||
|
||||
const properties = $derived(draft.schema?.properties ?? {})
|
||||
const hasArgs = $derived(Object.keys(properties).length > 0)
|
||||
|
||||
let isValid = $state(true)
|
||||
// Off the manager, not this instance: moving the form between the card and the preview
|
||||
// panel unmounts it, and a flag that died with it would re-arm both buttons mid-submit.
|
||||
const submitting = $derived(aiChatManager.isRunFormSubmitting(toolCallId))
|
||||
let cardNode = $state<HTMLDivElement | undefined>()
|
||||
|
||||
// The picker moves while a form sits open, so this is live state, not mount-time. It holds
|
||||
// off the entrypoint a `dynselect-` argument runs, which does not wait for Run. A password
|
||||
// field mints at mount, too early to hold off here, so `runThroughForm` gates that instead.
|
||||
const planMode = $derived(aiChatManager.planModeActive)
|
||||
|
||||
onMount(() => {
|
||||
void tick().then(() => cardNode?.scrollIntoView({ block: 'nearest' }))
|
||||
})
|
||||
|
||||
const fades = scrollFades()
|
||||
const { container: fadeContainer, content: fadeContent, measure: measureFades } = fades
|
||||
// The two hosts stand on different surfaces — the chat card on the tool call's own, the
|
||||
// preview tab on the raised one — and a fade has to end in the colour behind it.
|
||||
const fadeTo = $derived(
|
||||
layout === 'pane'
|
||||
? 'bg-gradient-to-t from-surface-tertiary via-surface-tertiary/60 to-transparent'
|
||||
: 'bg-gradient-to-t from-surface via-surface/60 to-transparent'
|
||||
)
|
||||
|
||||
async function run() {
|
||||
if (!isValid) return
|
||||
// Before processSecretArgs, not after: a card restored from history outlives the
|
||||
// manager that opened it, so submitting would mint an ephemeral secret variable
|
||||
// per click and still run nothing.
|
||||
if (!aiChatManager.isRunFormPending(toolCallId)) {
|
||||
sendUserToast('This run form is no longer active — ask again to run the script.', true)
|
||||
return
|
||||
}
|
||||
// Ahead of processSecretArgs, which writes ephemeral variables to the workspace: the
|
||||
// autonomy picker moves while a form sits pending, and the re-gate on the other side
|
||||
// of the callback runs too late to unmake a write plan mode promised not to do.
|
||||
if (aiChatManager.planModeActive) {
|
||||
sendUserToast(PLAN_MODE_MESSAGES.runFormRefused, true)
|
||||
return
|
||||
}
|
||||
if (!aiChatManager.beginRunFormSubmit(toolCallId)) return
|
||||
// Only the disabled fields, and only because `RunForm` does the same before its own
|
||||
// run: what the card showed is otherwise what runs. Re-filtering it here would delete
|
||||
// the user's own typing between Run and the job — a free-form field the form gave a
|
||||
// JSON editor to holds keys no schema names, and they are still theirs.
|
||||
const { args: enforced, resetKeys } = enforceDisabledDefaults(draft.args ?? {}, draft.schema)
|
||||
if (resetKeys.length > 0) {
|
||||
sendUserToast(resetKeysToast(resetKeys))
|
||||
}
|
||||
let processed: Record<string, any>
|
||||
try {
|
||||
processed = await processSecretArgs(enforced, draft.schema as any, workspace)
|
||||
} catch (e) {
|
||||
aiChatManager.endRunFormSubmit(toolCallId)
|
||||
sendUserToast('Failed to process sensitive args: ' + e, true)
|
||||
return
|
||||
}
|
||||
// The callback can still go away across the processSecretArgs round trip, and by
|
||||
// then the ephemeral variables exist — say so rather than leaving a dead button.
|
||||
if (!aiChatManager.handleRunFormSubmit(toolCallId, processed)) {
|
||||
aiChatManager.endRunFormSubmit(toolCallId)
|
||||
sendUserToast('This run form is no longer active — ask again to run the script.', true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- The card's heading and its chrome belong to RunScriptCard, which renders this form
|
||||
as one phase of the same card. The keyboard-scope marker says a form owns the keys
|
||||
here, so a list's own shortcuts stand down (ItemsList's SKIP_SELECTOR) — it belongs
|
||||
on this phase only, which is why a settled card drops it with the form. -->
|
||||
<!-- font-main because the card hosting this in the chat is a tool row, and those are
|
||||
font-mono throughout: a field label is UI text, and the pane copy of this same form
|
||||
already reads that way. (Not font-sans — this Tailwind config defines main and mono.) -->
|
||||
<div
|
||||
bind:this={cardNode}
|
||||
class={twMerge('flex flex-col font-main', layout === 'pane' ? 'h-full min-h-0' : 'pt-3')}
|
||||
data-chat-keyboard-scope="run-args-form"
|
||||
>
|
||||
<!-- Only the fields scroll, sized by their content rather than filling the host: the actions
|
||||
follow the last field of a short form, and a long one scrolls under them rather than
|
||||
pushing the Run button — and the lines naming what the form dropped — below the fold. -->
|
||||
<div class={twMerge('relative flex flex-col', layout === 'pane' ? 'min-h-0' : '')}>
|
||||
<!-- `pl-9` is the rail the pane header's title stands on, its padding plus the icon and
|
||||
the gap beside it, so the tab reads down one edge. The card has no such rail to meet
|
||||
and reserves the gutter on both sides instead, which keeps its fields centred. -->
|
||||
<div
|
||||
use:fadeContainer
|
||||
onscroll={measureFades}
|
||||
class={twMerge(
|
||||
'overflow-y-auto',
|
||||
layout === 'pane' ? 'min-h-0 pl-9 pr-3' : 'max-h-[min(28rem,50vh)] px-3'
|
||||
)}
|
||||
style={layout === 'pane'
|
||||
? 'scrollbar-gutter: stable;'
|
||||
: 'scrollbar-gutter: stable both-edges;'}
|
||||
>
|
||||
<div use:fadeContent>
|
||||
{#if hasArgs}
|
||||
<!-- Passing `helperScript` is what lets DynamicInput run a job before Run: it
|
||||
executes the `dynselect-` entrypoint on mount, carrying the other args as proposed,
|
||||
and Cancel does not undo it. Withheld under plan mode, which promised no writes.
|
||||
A test run names its draft by code: `deployed` resolves a stale version, or none. -->
|
||||
<SchemaForm
|
||||
bind:schema={draft.schema}
|
||||
helperScript={planMode
|
||||
? undefined
|
||||
: runForm.kind === 'test'
|
||||
? runForm.code && runForm.lang
|
||||
? { source: 'inline', code: runForm.code, lang: runForm.lang }
|
||||
: undefined
|
||||
: { source: 'deployed', path: runForm.path, runnable_kind: 'script' }}
|
||||
disabled={planMode}
|
||||
{workspace}
|
||||
prettifyHeader
|
||||
bind:isValid
|
||||
bind:args={draft.args}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-xs text-secondary">This script takes no arguments.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Only what is still below, and only while there is some: the fade the tool cards draw
|
||||
under their own content, over the scroller rather than inside it. Nothing at the top —
|
||||
having scrolled down is itself the knowledge that there is more up there. -->
|
||||
{#if fades.bottom}
|
||||
<div
|
||||
class={twMerge(
|
||||
'pointer-events-none absolute inset-x-0 bottom-0 h-[min(2.5rem,35%)]',
|
||||
fadeTo
|
||||
)}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- One region with the actions: these lines report on the run the button below launches,
|
||||
and separating them would read as two subjects. No padding of its own over the fields:
|
||||
every one of them ends on the room ArgInput keeps for a validation message, and adding
|
||||
to it would sit the buttons twice as far below the last field as the first label sits
|
||||
from the top. A form with no fields keeps none of that room, so there it comes back. -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex flex-col gap-2 px-3 pb-3',
|
||||
hasArgs ? '' : 'pt-3',
|
||||
layout === 'pane' ? 'pl-9' : ''
|
||||
)}
|
||||
>
|
||||
{#if runForm.clearedKeys?.length}
|
||||
<p class="text-2xs text-secondary">
|
||||
Sent in a shape this field has no reading of, so it opened empty:
|
||||
<span class="font-mono">{runForm.clearedKeys.join(', ')}</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if runForm.resetKeys?.length}
|
||||
<p class="text-2xs text-secondary">
|
||||
Disabled by this script, so it will run with its default:
|
||||
<span class="font-mono">{runForm.resetKeys.join(', ')}</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if runForm.strippedKeys?.length}
|
||||
<p class="text-2xs text-secondary">
|
||||
A file, so it opened empty for you to attach:
|
||||
<span class="font-mono">{runForm.strippedKeys.join(', ')}</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if planMode}
|
||||
<p class="text-2xs text-secondary">{PLAN_MODE_MESSAGES.runFormRefused}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Reject then confirm at the end of the row, as ToolConfirmationFooter puts them: this
|
||||
is a tool call being validated. Both rest while a submit is in flight — the ephemeral
|
||||
variables exist by then, and cancelling would settle the call as declined on a run
|
||||
already starting. Escape stops the turn from here and nowhere else in the form. -->
|
||||
<div class="flex items-center justify-end gap-2" data-run-form-actions={toolCallId}>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: X }}
|
||||
disabled={submitting}
|
||||
onClick={() => aiChatManager.handleRunFormCancel(toolCallId)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Play }}
|
||||
disabled={!isValid || submitting || planMode}
|
||||
onClick={run}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,529 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
import { prefersReducedMotion } from 'svelte/motion'
|
||||
import { Ban, Loader2, TimerOff } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { Button, Tab, Tabs } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
import { msToReadableTime } from '$lib/utils'
|
||||
import JobArgs from '$lib/components/JobArgs.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
|
||||
import RunArgsFormDisplay from './RunArgsFormDisplay.svelte'
|
||||
import ToolContentDisplay from './ToolContentDisplay.svelte'
|
||||
import ToolPreviewCard from './ToolPreviewCard.svelte'
|
||||
import { scrollFades } from './scrollFades.svelte'
|
||||
import { isActiveRunForm, MAX_LOG_LENGTH, type ToolDisplayMessage } from './shared'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
interface Props {
|
||||
message: ToolDisplayMessage
|
||||
}
|
||||
|
||||
let { message }: Props = $props()
|
||||
|
||||
const runForm = $derived(message.runForm!)
|
||||
// The loop is parked on the form and nothing has run yet: the card is the form.
|
||||
const pending = $derived(isActiveRunForm(message))
|
||||
|
||||
const chatJob = $derived(
|
||||
aiChatManager.backgroundJobs.find((j) => j.toolCallId === message.tool_call_id)
|
||||
)
|
||||
// Declining the form, stopping the turn and cancelling the job all land here, and none
|
||||
// of them is a failure: the run stopped because someone said so.
|
||||
const canceled = $derived(
|
||||
Boolean(message.declinedByUser) || Boolean(runForm.canceled) || chatJob?.status === 'canceled'
|
||||
)
|
||||
const failed = $derived(Boolean(message.error) && !canceled)
|
||||
// A cancelled form never reached a job, so it has no logs and no outcome to offer.
|
||||
const ran = $derived(
|
||||
Boolean(runForm.started) || Boolean(message.logs) || message.result !== undefined || !!chatJob
|
||||
)
|
||||
// A run can outlive the turn that started it, so "the tool call returned" is not
|
||||
// "the run finished": a detached job keeps the card in its running state until
|
||||
// the background poller lands an outcome on it or the tray sees the job end.
|
||||
const settled = $derived(
|
||||
!pending &&
|
||||
!message.isLoading &&
|
||||
(message.result !== undefined ||
|
||||
failed ||
|
||||
canceled ||
|
||||
(chatJob !== undefined && ['success', 'failure', 'canceled'].includes(chatJob.status)))
|
||||
)
|
||||
const running = $derived(!pending && !settled)
|
||||
|
||||
const parameters = $derived(
|
||||
message.parameters && typeof message.parameters === 'object' ? message.parameters : {}
|
||||
)
|
||||
const logs = $derived(typeof message.logs === 'string' ? message.logs : '')
|
||||
const logLineCount = $derived(logs.trim() ? logs.trimEnd().split('\n').length : 0)
|
||||
// What the job has streamed of its result so far. Only ever set while it runs: the
|
||||
// terminal patch clears it, so a settled card reads its outcome off `result` alone.
|
||||
const resultStream = $derived(
|
||||
typeof message.resultStream === 'string' ? message.resultStream : ''
|
||||
)
|
||||
const streaming = $derived(running && resultStream.length > 0)
|
||||
|
||||
// The card stores its result as text (see formatResult), so read it back into a
|
||||
// value DisplayResult can render: a markdown, table or image result is what the
|
||||
// pretty view buys. A string that happens to be JSON parses back as JSON, and the
|
||||
// text it was stored as is one toggle away in the raw view.
|
||||
const resultValue = $derived.by(() => {
|
||||
if (message.result === undefined) return undefined
|
||||
if (typeof message.result !== 'string') return message.result
|
||||
try {
|
||||
return JSON.parse(message.result)
|
||||
} catch {
|
||||
return message.result
|
||||
}
|
||||
})
|
||||
|
||||
// The row is the card's whole heading, in the tense the call is in: a run cancelled
|
||||
// before it started never ran, so it is still the thing that was going to be run. A
|
||||
// test says so, since what it ran is the draft rather than what is deployed.
|
||||
const verbs = $derived(
|
||||
runForm.kind === 'test'
|
||||
? { present: 'Testing', past: 'Tested', future: 'Test' }
|
||||
: { present: 'Running', past: 'Ran', future: 'Run' }
|
||||
)
|
||||
// What the script is called on its own page and in the picker, so the row names the thing
|
||||
// that ran rather than where it is filed. Not every script has one, so the path stays the
|
||||
// fallback — and stays on the preview chip either way, since two folders can hold one name.
|
||||
const runnableName = $derived(runForm.summary || runForm.path)
|
||||
const verb = $derived(running ? verbs.present : settled && ran ? verbs.past : verbs.future)
|
||||
|
||||
// Being cancelled is an outcome like any other, and it is the one the card has to say out
|
||||
// loud: nothing came back, so no other tab can carry it.
|
||||
const outcomeTab = $derived(
|
||||
failed ? 'Error' : canceled ? (ran ? 'Cancelled' : 'Not run') : 'Result'
|
||||
)
|
||||
const cancelReason = $derived(
|
||||
ran
|
||||
? 'This run was cancelled while the script was running.'
|
||||
: 'This run was cancelled before the script started.'
|
||||
)
|
||||
// Streaming opens the tab early: the result is already arriving, and one that appeared
|
||||
// only at the end would hide the thing the user is waiting to read.
|
||||
const hasOutcome = $derived(settled || streaming)
|
||||
const tabs = $derived([
|
||||
{ value: 'input', label: 'Inputs' },
|
||||
...(ran ? [{ value: 'logs', label: 'Logs' }] : []),
|
||||
...(hasOutcome ? [{ value: 'outcome', label: outcomeTab }] : [])
|
||||
])
|
||||
|
||||
// Keyed by call id: this instance is reused when the message at its index changes, so a
|
||||
// bare flag would hand one card's view to whichever run lands in the slot next. Unset
|
||||
// until a tab is clicked and never cleared, so the run stops following itself once
|
||||
// the user has steered the card.
|
||||
let userTab = $state<{ id: string; value: string } | undefined>(undefined)
|
||||
let jsonView = $state<{ id: string; on: boolean } | undefined>(undefined)
|
||||
const steered = $derived(userTab?.id === message.tool_call_id ? userTab.value : undefined)
|
||||
const rawView = $derived(jsonView?.id === message.tool_call_id ? jsonView.on : false)
|
||||
|
||||
// However the run landed, that is what the card opens on.
|
||||
const autoTab = $derived(hasOutcome ? 'outcome' : ran ? 'logs' : 'input')
|
||||
const activeTab = $derived(steered && tabs.some((t) => t.value === steered) ? steered : autoTab)
|
||||
|
||||
// Keyed by call id: a bare flag would carry one card's collapse onto the next message
|
||||
// reusing this instance. Open by default, since the run is what was asked for.
|
||||
let toggled = $state<{ id: string; open: boolean } | undefined>(undefined)
|
||||
const expanded = $derived(toggled?.id === message.tool_call_id ? toggled.open : true)
|
||||
|
||||
// The panel mounts the chat's own form on this call, so the card must not mount a second
|
||||
// one: two views binding the one draft would each reorder the schema SchemaForm edits in
|
||||
// place. Only the form is exclusive. A run shows in both, and collapsing the row or
|
||||
// closing the tab is the user's own way out of seeing it twice.
|
||||
const formInPreview = $derived(
|
||||
pending && (aiChatManager.isRunFormInPreview?.(message.tool_call_id) ?? false)
|
||||
)
|
||||
|
||||
// The three moments a run moves the card — a tab arrives, the selection follows it, the body
|
||||
// changes under it — are staggered rather than landing in one frame, which is what makes the
|
||||
// card followable. A tab already on screen when the card mounts never arrived, so a settled
|
||||
// call restored from history renders its strip at rest.
|
||||
let liveStrip = $state(false)
|
||||
onMount(() => (liveStrip = true))
|
||||
|
||||
function growIn(node: HTMLElement, { live }: { live: boolean }) {
|
||||
const width = node.getBoundingClientRect().width
|
||||
return {
|
||||
duration: live && !prefersReducedMotion.current ? 170 : 0,
|
||||
easing: cubicOut,
|
||||
css: (t: number) => `width:${t * width}px; opacity:${t}`
|
||||
}
|
||||
}
|
||||
|
||||
/** The body fades in over the time the bar takes to travel, so the two halves of a tab change
|
||||
* land together rather than one instantly and one over 200ms. No shift with it: the logs open
|
||||
* pinned to the end of their own scroll, which clips one, and a pane that moves on two tabs
|
||||
* out of three reads as a glitch rather than as direction. */
|
||||
function enterPane(_node: HTMLElement) {
|
||||
return {
|
||||
duration: liveStrip && !prefersReducedMotion.current ? 200 : 0,
|
||||
easing: cubicOut,
|
||||
css: (t: number) => `opacity:${t}`
|
||||
}
|
||||
}
|
||||
|
||||
let bodyEl: HTMLDivElement | undefined = $state()
|
||||
|
||||
const fades = scrollFades()
|
||||
// The arguments table brings its own surface, so a fade ending in the card's would seam
|
||||
// against it; logs and a result stand on the body's own ground and take it cleanly.
|
||||
const fadeBody = $derived(!rawView && (activeTab === 'logs' || activeTab === 'outcome'))
|
||||
|
||||
// One scroll region serves every tab, so a switch has to place it: logs open on their
|
||||
// end, which is where a run is read from, and everything else on its start — otherwise
|
||||
// the tab opened after the logs would begin part-way down its own content.
|
||||
$effect(() => {
|
||||
void rawView
|
||||
if (!bodyEl) return
|
||||
bodyEl.scrollTop = activeTab === 'logs' && !rawView ? bodyEl.scrollHeight : 0
|
||||
})
|
||||
|
||||
// And stay on the end while the job writes: a log stream the user has to scroll to
|
||||
// read is not following the run.
|
||||
$effect(() => {
|
||||
void logs
|
||||
if (!bodyEl || rawView || activeTab !== 'logs' || !running) return
|
||||
bodyEl.scrollTop = bodyEl.scrollHeight
|
||||
})
|
||||
|
||||
// Ticks only while this card has a job in flight, so a transcript of settled runs
|
||||
// keeps no timers at all.
|
||||
let now = $state(Date.now())
|
||||
$effect(() => {
|
||||
if (!running || !chatJob) return
|
||||
const timer = setInterval(() => (now = Date.now()), 500)
|
||||
return () => clearInterval(timer)
|
||||
})
|
||||
|
||||
const elapsed = $derived(chatJob ? msToReadableTime(now - chatJob.createdAt, 2) : '')
|
||||
const duration = $derived(
|
||||
chatJob?.durationMs !== undefined ? msToReadableTime(chatJob.durationMs, 2) : ''
|
||||
)
|
||||
|
||||
// The jobs bar's status hues, darkened: those are drawn for a tinted ground and land
|
||||
// brighter than the name on this transparent one. Per-hue steps because the ramp is not
|
||||
// uniform, and ok borrows emerald because green skips that weight.
|
||||
const statusClass = $derived.by(() => {
|
||||
// The card outlives its job and sometimes precedes it, so the states only it knows about
|
||||
// read off its own flags rather than off a status no job is there to report.
|
||||
if (canceled) return 'text-tertiary'
|
||||
if (failed) return 'text-red-800 dark:text-red-300'
|
||||
if (!ran) return 'text-tertiary'
|
||||
switch (chatJob?.status) {
|
||||
case 'running':
|
||||
return 'text-blue-800 dark:text-blue-200'
|
||||
case 'suspended':
|
||||
return 'text-violet-800 dark:text-violet-300'
|
||||
case 'queued':
|
||||
case 'scheduled':
|
||||
return 'text-orange-800 dark:text-orange-300'
|
||||
case 'failure':
|
||||
return 'text-red-800 dark:text-red-300'
|
||||
case 'success':
|
||||
return 'text-green-700 dark:text-emerald-400'
|
||||
default:
|
||||
return settled ? 'text-green-700 dark:text-emerald-400' : 'text-blue-800 dark:text-blue-200'
|
||||
}
|
||||
})
|
||||
// How long it took, which is the one thing the colour cannot say. A run that never started
|
||||
// has no time to give, so its outcome takes the slot — as a word, never "Not run", which
|
||||
// stutters against the "Run <name>" label beside it.
|
||||
const outcome = $derived(failed ? 'Failed' : canceled ? 'Cancelled' : 'Done')
|
||||
const statusTime = $derived(running ? elapsed : duration || outcome)
|
||||
|
||||
// What the preview button opens changes with the card: the form while the call is still
|
||||
// waiting on one, the run once a job exists. Neither, and there is nothing to open, so
|
||||
// the button is not drawn at all — a form has nowhere to go outside a session, and a
|
||||
// call cancelled before Run never became a run.
|
||||
const previewTarget = $derived(
|
||||
pending
|
||||
? aiChatManager.openRunForm
|
||||
? ('form' as const)
|
||||
: undefined
|
||||
: chatJob
|
||||
? ('run' as const)
|
||||
: undefined
|
||||
)
|
||||
const previewTitle = $derived(
|
||||
previewTarget === 'form'
|
||||
? `Open this form in the preview panel: ${runForm.path}`
|
||||
: aiChatManager.openRunInPreview
|
||||
? `Open this run in the preview panel: ${runForm.path}`
|
||||
: `Open this run in a new tab: ${runForm.path}`
|
||||
)
|
||||
|
||||
function openPreview() {
|
||||
const label = runnableName
|
||||
if (previewTarget === 'form') {
|
||||
aiChatManager.openRunForm?.({ toolCallId: message.tool_call_id, label })
|
||||
return
|
||||
}
|
||||
if (!chatJob) return
|
||||
// Outside a session there is no panel, so the run opens where the jobs tray sends it.
|
||||
if (aiChatManager.openRunInPreview) {
|
||||
aiChatManager.openRunInPreview({ jobId: chatJob.jobId, workspace: chatJob.workspace, label })
|
||||
} else {
|
||||
window.open(
|
||||
`${base}/run/${chatJob.jobId}?workspace=${chatJob.workspace}`,
|
||||
'_blank',
|
||||
'noreferrer'
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- One readout rather than a badge beside a number: the colour says how the run went and the
|
||||
text how long it took, which is how the rest of the chat states a status. While it runs
|
||||
that number is still moving. `font-medium` because the row is a button and the base layer
|
||||
sets those semibold, which would leave this the one bold word in the header. -->
|
||||
{#snippet status()}
|
||||
{#if !pending}
|
||||
<span class={twMerge('shrink-0 whitespace-nowrap text-2xs font-medium', statusClass)}>
|
||||
{statusTime}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- The chip every other tool row opens its preview with, pointed at this call: the form
|
||||
on its way in, the run on its way out. Not a toggle — pressing it again focuses the
|
||||
tab it already opened. The row's only control, as on every other tool call. -->
|
||||
{#snippet previewChip()}
|
||||
<ToolPreviewCard
|
||||
card={{ kind: 'script', path: runForm.path }}
|
||||
title={previewTitle}
|
||||
onOpen={openPreview}
|
||||
kindIcon={false}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<!-- scroll-mb clears the chat's sticky "Waiting for your input" chip so the mount
|
||||
scrollIntoView of the form below leaves the Run button uncovered. -->
|
||||
<!-- The runnable's name is the subject of the row and the verb is grammar, so the name carries
|
||||
the weight and the verb stays quiet. Held one step under the card's headings, since this is a
|
||||
row in a transcript rather than a title. font-main because a name is UI text: the row around
|
||||
it is font-mono, right for a path, wrong here. -->
|
||||
<ChatCollapsibleCard
|
||||
label={runnableName}
|
||||
labelPrefix={verb}
|
||||
{expanded}
|
||||
onToggle={() => (toggled = { id: message.tool_call_id, open: !expanded })}
|
||||
headerLeft={status}
|
||||
headerRight={previewTarget ? previewChip : undefined}
|
||||
class="scroll-mb-8"
|
||||
labelClass="font-main font-medium text-primary"
|
||||
contentClass="p-0 overflow-hidden"
|
||||
>
|
||||
{#if formInPreview}
|
||||
<div class="px-3 py-2 text-2xs leading-4 text-hint">
|
||||
These inputs are open in the preview panel.
|
||||
</div>
|
||||
{:else if pending}
|
||||
<RunArgsFormDisplay toolCallId={message.tool_call_id} {runForm} />
|
||||
{:else}
|
||||
<!-- One region holding the strip and the body, fixed so the card is the same size on every
|
||||
tab — a cap would not do it, since the body is a scroll region and a max-height
|
||||
silently beats flex-grow. The raw view takes that height as a floor instead: its
|
||||
blocks scroll on their own, as an ordinary tool call's do, so a scroller around them
|
||||
would be one too many. -->
|
||||
<div class={twMerge('relative flex flex-col', rawView ? 'min-h-[20rem]' : 'h-[20rem]')}>
|
||||
<!-- The tabs go in raw view — they name the parts of the body, and the raw call is not
|
||||
one of them — while the strip stays, since the JSON toggle lives there. Hence its
|
||||
fixed height: a row sized by its contents would step every time the tabs leave, and
|
||||
the tighter Tab padding below is what fits a label inside that height. -->
|
||||
<Tabs
|
||||
selected={activeTab}
|
||||
on:selected={(e) => (userTab = { id: message.tool_call_id, value: e.detail })}
|
||||
class="h-8 px-3 font-main"
|
||||
wrapperClass="shrink-0"
|
||||
slidingIndicator
|
||||
>
|
||||
{#if !rawView}
|
||||
{#each tabs as tab (tab.value)}
|
||||
<!-- The tab widens in first and the bar follows it, because a run adds its tabs as
|
||||
it produces them: landing the selection on a tab in the frame it appears reads
|
||||
as one unexplained jump. `border-b-0` because the bar is the selection now.
|
||||
Size only: Tab's own colour and weight mark the selection, and this class
|
||||
lands after them in its twMerge, so a colour here would silently win. -->
|
||||
<span class="inline-flex overflow-hidden" in:growIn={{ live: liveStrip }}>
|
||||
<Tab
|
||||
value={tab.value}
|
||||
label={tab.label}
|
||||
class="border-b-0 py-0.5 text-2xs leading-4"
|
||||
exact
|
||||
>
|
||||
{#snippet extra()}
|
||||
{#if tab.value === 'logs' && logLineCount > 0}
|
||||
<span class="text-2xs text-hint">{logLineCount}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Tab>
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="ml-auto flex items-center pl-2">
|
||||
<Toggle
|
||||
checked={rawView}
|
||||
on:change={(e) => (jsonView = { id: message.tool_call_id, on: e.detail })}
|
||||
size="2xs"
|
||||
options={{ right: 'JSON', rightTooltip: 'Show this call as raw JSON' }}
|
||||
lightMode
|
||||
/>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<!-- Logs sit on the softer surface, the way program output is shown everywhere else.
|
||||
The raw view keeps the card's own, as an ordinary tool call has it. -->
|
||||
<div
|
||||
bind:this={bodyEl}
|
||||
use:fades.container
|
||||
onscroll={fades.measure}
|
||||
class={twMerge(
|
||||
'min-h-0 flex-1 px-3 py-2',
|
||||
rawView ? '' : 'overflow-auto',
|
||||
!rawView && activeTab === 'logs' ? 'bg-surface-secondary/50' : ''
|
||||
)}
|
||||
>
|
||||
<!-- min-h-full rather than h-full: the states that centre themselves need the height,
|
||||
and a box that always filled it would measure as never scrollable. -->
|
||||
<div use:fades.content class="flex min-h-full flex-col">
|
||||
{#if rawView}
|
||||
<div class="space-y-3">
|
||||
<!-- Each block scrolls on its own, so each fades on its own. -->
|
||||
<ToolContentDisplay title="Parameters" content={message.parameters} showFade />
|
||||
<ToolContentDisplay title="Logs" content={message.logs} tail showFade />
|
||||
<ToolContentDisplay
|
||||
title="Result"
|
||||
content={message.result}
|
||||
error={message.error}
|
||||
showFade
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Keyed on the tab so the body arrives rather than cuts. One region serves every
|
||||
tab, so only the incoming pane moves: overlapping them would ask this scroller
|
||||
to hold two at once. -->
|
||||
{#key activeTab}
|
||||
<div class="flex min-h-full flex-1 flex-col" in:enterPane>
|
||||
{#if activeTab === 'input'}
|
||||
<!-- What the runs page shows a finished job's arguments as: the operator has already
|
||||
read this table. The job id is what lets it fetch arguments too big to have been
|
||||
persisted with the card. -->
|
||||
<JobArgs
|
||||
args={parameters}
|
||||
id={chatJob?.jobId}
|
||||
workspace={chatJob?.workspace}
|
||||
disableExpand
|
||||
/>
|
||||
{:else if activeTab === 'logs'}
|
||||
{#if logs.trim()}
|
||||
{#if logs.length >= MAX_LOG_LENGTH}
|
||||
<p class="mb-1 text-2xs text-tertiary">
|
||||
Tail of the logs, the last {MAX_LOG_LENGTH} characters.
|
||||
</p>
|
||||
{/if}
|
||||
<pre class="whitespace-pre-wrap break-words font-mono text-2xs text-primary"
|
||||
>{logs}</pre
|
||||
>
|
||||
{:else}
|
||||
<p class="text-2xs text-tertiary">No logs yet.</p>
|
||||
{/if}
|
||||
{#if running}
|
||||
<div class="mt-1 flex items-center gap-1.5 text-2xs text-tertiary">
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
streaming
|
||||
</div>
|
||||
{/if}
|
||||
{:else if failed}
|
||||
<pre
|
||||
class="whitespace-pre-wrap break-words font-mono text-2xs text-red-700 dark:text-red-300"
|
||||
>{message.error}</pre
|
||||
>
|
||||
{:else if streaming}
|
||||
<!-- The same renderer as a landed result, handed the partial: it is the one that
|
||||
knows how to show a result arriving in pieces. -->
|
||||
<DisplayResult
|
||||
result={undefined}
|
||||
result_stream={resultStream}
|
||||
jobId={chatJob?.jobId}
|
||||
workspaceId={chatJob?.workspace}
|
||||
disableExpand
|
||||
hideAsJson
|
||||
/>
|
||||
{:else if resultValue !== undefined}
|
||||
<!-- The run page's own renderer, not a second one invented for the chat: it handles
|
||||
markdown, tables, images and deep nesting without the card guessing at the
|
||||
shape, and reaches the job through jobId/workspace for an S3 preview.
|
||||
`disableExpand` drops its toolbar and `hideAsJson` its Pretty/JSON switch,
|
||||
which the row already owns. -->
|
||||
<DisplayResult
|
||||
result={resultValue}
|
||||
jobId={chatJob?.jobId}
|
||||
workspaceId={chatJob?.workspace}
|
||||
disableExpand
|
||||
hideAsJson
|
||||
/>
|
||||
{:else if canceled}
|
||||
<!-- All that is left to render is the fact itself: a form cancelled before Run
|
||||
never reached a job, so there is no result the way a cancelled run has one. -->
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center justify-center gap-1.5 px-4 text-center"
|
||||
>
|
||||
<Ban class="h-4 w-4 text-tertiary" />
|
||||
<p class="text-2xs font-medium leading-4 text-secondary">{cancelReason}</p>
|
||||
{#if !ran}
|
||||
<p class="text-2xs leading-4 text-tertiary">
|
||||
The inputs it would have run with are on the Inputs tab.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-2xs text-tertiary">This run returned no result.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Over the body, not inside it: what is still below fades out, and nothing at the top,
|
||||
as on the form and on the tool cards. Two layers on the logs, whose ground is the
|
||||
card's surface with the softer one at half strength over it — one gradient would end
|
||||
on the wrong colour and leave a band at the very edge. -->
|
||||
{#if fades.bottom && fadeBody}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-[min(2.5rem,35%)] bg-gradient-to-t from-surface via-surface/60 to-transparent"
|
||||
></div>
|
||||
{#if activeTab === 'logs'}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-[min(2.5rem,35%)] bg-gradient-to-t from-surface-secondary/50 via-surface-secondary/30 to-transparent"
|
||||
></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if running && chatJob}
|
||||
<!-- Where the form keeps its own actions, so the button that stops a run and the one
|
||||
that starts it sit in the same corner of the same card. The run page's own cancel
|
||||
button, down to the icon: the operator has already pressed this one. -->
|
||||
<div class="flex justify-end border-t border-border-light px-3 py-2">
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
destructive
|
||||
startIcon={{ icon: TimerOff }}
|
||||
title="Cancel the script"
|
||||
onClick={() => aiChatManager.cancelJob(chatJob.jobId)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</ChatCollapsibleCard>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Loader2, Copy, Check } from 'lucide-svelte'
|
||||
import { TOOL_PRETTIFY_MAP } from './shared'
|
||||
import { scrollFades } from './scrollFades.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -12,6 +13,9 @@
|
||||
streaming?: boolean
|
||||
toolName?: string
|
||||
showFade?: boolean
|
||||
/** Open on the end of the content instead of its start, and stay there as it grows.
|
||||
* For logs, whose last lines are the ones being looked for. */
|
||||
tail?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -23,7 +27,8 @@
|
||||
showWhileLoading = true,
|
||||
streaming = false,
|
||||
toolName,
|
||||
showFade = false
|
||||
showFade = false,
|
||||
tail = false
|
||||
}: Props = $props()
|
||||
let copied = $state(false)
|
||||
|
||||
@@ -75,31 +80,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Only draw the bottom fade when the content actually overflows and the
|
||||
// user hasn't scrolled to the bottom. `showFade` is the parent's intent;
|
||||
// `canScrollDown` is the live measurement on the inner scroll container.
|
||||
let scrollEl: HTMLDivElement | undefined = $state()
|
||||
let canScrollDown = $state(false)
|
||||
function updateCanScrollDown() {
|
||||
if (!scrollEl) {
|
||||
canScrollDown = false
|
||||
return
|
||||
}
|
||||
canScrollDown = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight > 1
|
||||
}
|
||||
// Only draw the bottom fade when the content actually overflows and the user hasn't
|
||||
// scrolled to the bottom. `showFade` is the parent's intent; `fades.bottom` is the live
|
||||
// measurement on the inner scroll container, which catches streaming JSON growing past
|
||||
// max-h-28 as well as the first paint.
|
||||
const fades = scrollFades()
|
||||
const { container: fadeContainer, content: fadeContent, measure: measureFades } = fades
|
||||
|
||||
// Mount-time + content-change measurement via ResizeObserver. The
|
||||
// observer fires on initial observe (catches first paint) and on every
|
||||
// size change of the scroll container or its content (catches streaming
|
||||
// JSON growing past max-h-28). User scrolls fire `onscroll` directly.
|
||||
let scroller = $state<HTMLDivElement | undefined>()
|
||||
$effect(() => {
|
||||
if (!scrollEl) return
|
||||
updateCanScrollDown()
|
||||
const ro = new ResizeObserver(updateCanScrollDown)
|
||||
ro.observe(scrollEl)
|
||||
const inner = scrollEl.firstElementChild
|
||||
if (inner) ro.observe(inner)
|
||||
return () => ro.disconnect()
|
||||
void content
|
||||
if (tail && scroller) scroller.scrollTop = scroller.scrollHeight
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -136,17 +127,18 @@
|
||||
{:else if hasContent}
|
||||
<div class="relative">
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
onscroll={updateCanScrollDown}
|
||||
bind:this={scroller}
|
||||
use:fadeContainer
|
||||
onscroll={measureFades}
|
||||
class="overflow-x-auto max-h-28 overflow-y-auto"
|
||||
>
|
||||
<pre class="text-2xs text-primary whitespace-pre-wrap"
|
||||
<pre use:fadeContent class="text-2xs text-primary whitespace-pre-wrap"
|
||||
>{formatJson($state.snapshot(content))}</pre
|
||||
>
|
||||
</div>
|
||||
{#if showFade && canScrollDown}
|
||||
{#if showFade && fades.bottom}
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 h-16 pointer-events-none bg-gradient-to-t from-surface via-surface/70 via-surface/40 to-transparent"
|
||||
class="absolute bottom-0 left-0 right-0 h-[min(2.5rem,35%)] pointer-events-none bg-gradient-to-t from-surface via-surface/70 to-transparent"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
import ToolMessageActions from './ToolMessageActions.svelte'
|
||||
import ToolPreviewCard from './ToolPreviewCard.svelte'
|
||||
import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte'
|
||||
import RunScriptCard from './RunScriptCard.svelte'
|
||||
import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte'
|
||||
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
|
||||
|
||||
@@ -117,6 +118,10 @@
|
||||
isActiveUserQuestion(message) ? message.userQuestion : undefined
|
||||
)
|
||||
|
||||
// The run card owns this call from the form to whatever settled it, cancelling included:
|
||||
// the card is the call, and a run the user stopped is not a different kind of thing.
|
||||
const isRunCard = $derived(Boolean(message.runForm))
|
||||
|
||||
// The preview chip sits on the header row (to the right of the tool-call text);
|
||||
// shown once the tool settled, never while loading/erroring/awaiting confirmation.
|
||||
const showPreviewChip = $derived(
|
||||
@@ -140,6 +145,8 @@
|
||||
<span class="text-2xs text-tertiary truncate">{message.toolName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if isRunCard}
|
||||
<RunScriptCard {message} />
|
||||
{:else if planState}
|
||||
<!-- Same lean shape as a tool call below: a header row that collapses into the
|
||||
transcript, with everything else in one box under it. -->
|
||||
|
||||
@@ -8,15 +8,26 @@
|
||||
|
||||
interface Props {
|
||||
card: { kind: PreviewCardKind; path: string }
|
||||
/** Opens something other than the item's own preview — the run card opens the call
|
||||
* it owns, which is a form before it is a run. */
|
||||
onOpen?: () => void
|
||||
title?: string
|
||||
/** The kind icon says what the chip opens. A card that already names its own runnable
|
||||
* in the row above has said it, and repeating it there reads as a second subject. */
|
||||
kindIcon?: boolean
|
||||
}
|
||||
|
||||
let { card }: Props = $props()
|
||||
let { card, onOpen, title, kindIcon = true }: Props = $props()
|
||||
|
||||
const kindLabel = $derived(card.kind === 'raw_app' ? 'app' : card.kind)
|
||||
|
||||
let opening = $state(false)
|
||||
async function open() {
|
||||
if (opening) return
|
||||
if (onOpen) {
|
||||
onOpen()
|
||||
return
|
||||
}
|
||||
opening = true
|
||||
try {
|
||||
await runToolDisplayAction(openItemPreviewAction(card.kind, card.path))
|
||||
@@ -30,9 +41,11 @@
|
||||
variant="default"
|
||||
unifiedSize="2xs"
|
||||
disabled={opening}
|
||||
title="Open {kindLabel} preview: {card.path}"
|
||||
title={title ?? `Open ${kindLabel} preview: ${card.path}`}
|
||||
onClick={open}
|
||||
startIcon={{ icon: RowIcon as unknown as IconType, props: { kind: card.kind, size: 12 } }}
|
||||
startIcon={kindIcon
|
||||
? { icon: RowIcon as unknown as IconType, props: { kind: card.kind, size: 12 } }
|
||||
: undefined}
|
||||
endIcon={{ icon: PanelRight }}
|
||||
wrapperClasses="shrink-0"
|
||||
>
|
||||
|
||||
@@ -88,6 +88,19 @@ const CATALOG = [
|
||||
required: ['workspace', 'hash']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'runScriptByPath',
|
||||
description: 'Run script by path',
|
||||
instructions: 'Trigger a run of a deployed script',
|
||||
path: '/w/{workspace}/jobs/run/p/{path}',
|
||||
method: 'POST',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
|
||||
required: ['workspace', 'path']
|
||||
},
|
||||
body_schema: { type: 'object', additionalProperties: true }
|
||||
},
|
||||
{
|
||||
name: 'runFlowByPath',
|
||||
description: 'Run flow by path',
|
||||
@@ -178,6 +191,19 @@ describe('call_api_get', () => {
|
||||
expect(search.matches.map((m: any) => m.name)).not.toContain('deleteScriptByHash')
|
||||
})
|
||||
|
||||
// Left reachable, this endpoint is the way around the argument form: it runs the
|
||||
// deployed script on the model's arguments, unstripped and unshown.
|
||||
it('refuses a deployed script run, pointing at run_script', async () => {
|
||||
const called = await run('call_api_endpoint', { name: 'runScriptByPath' })
|
||||
expect(called.error).toContain('run_script')
|
||||
expect(called.success).toBe(false)
|
||||
|
||||
// And it is gone from search, so the model is redirected before it ever calls.
|
||||
const search = await run('search_api_endpoints', { query: 'run deployed script' })
|
||||
expect(search.matches.map((m: any) => m.name)).not.toContain('runScriptByPath')
|
||||
expect(search.covered_by_dedicated_tools?.join(' ')).toContain('run_script')
|
||||
})
|
||||
|
||||
it('refuses draft-blind item reads and lists, pointing at the draft-aware tools', async () => {
|
||||
for (const name of ['getScriptByPath', 'getResource', 'getSchedule']) {
|
||||
const result = await run('call_api_get', { name })
|
||||
|
||||
@@ -36,6 +36,7 @@ const COVERED_ENDPOINTS: Record<string, string> = {
|
||||
listFlows: 'list_workspace_items (it includes your drafts)',
|
||||
listResource: 'list_workspace_items (it includes your drafts)',
|
||||
listSchedules: 'list_workspace_items (it includes your drafts)',
|
||||
runScriptByPath: 'run_script (it shows the user an argument form to confirm)',
|
||||
deleteScriptByPath: 'delete_workspace_item',
|
||||
deleteScriptByHash: 'delete_workspace_item',
|
||||
deleteFlowByPath: 'delete_workspace_item',
|
||||
|
||||
@@ -82,12 +82,21 @@ vi.mock('$lib/gen', async () => {
|
||||
runScriptPreview: vi.fn(async () => 'job-script-preview'),
|
||||
runFlowPreview: vi.fn(async () => 'job-flow-preview'),
|
||||
runFlowByPath: vi.fn(async () => 'job-flow-by-path'),
|
||||
runScriptByPath: vi.fn(async () => 'job-script-by-path'),
|
||||
getJob: vi.fn(async () => ({
|
||||
type: 'CompletedJob',
|
||||
success: true,
|
||||
result: { ok: true },
|
||||
logs: 'test logs'
|
||||
})),
|
||||
// What every job wait polls first; unmocked it reaches the real client and the
|
||||
// wait never returns. Answers completed, so one tick settles the job.
|
||||
getJobUpdates: vi.fn(async () => ({
|
||||
completed: true,
|
||||
running: false,
|
||||
new_logs: 'test logs',
|
||||
log_offset: 'test logs'.length
|
||||
})),
|
||||
getJobLogs: vi.fn(async () => 'job log line 1\njob log line 2'),
|
||||
listJobs: vi.fn(async () => [
|
||||
{
|
||||
@@ -294,6 +303,12 @@ vi.mock('$lib/gen', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Minting reaches the API and is covered in secretArgUtils.test.ts; what matters here is that a
|
||||
// run the posture answers goes through it and starts on what came back.
|
||||
vi.mock('$lib/components/secretArgUtils', () => ({
|
||||
processSecretArgs: vi.fn(async (args: Record<string, any>) => args)
|
||||
}))
|
||||
|
||||
vi.mock('./rawAppBundlerBridge', () => ({
|
||||
bundleRawAppDraft: vi.fn(async () => ({
|
||||
js: 'bundled js',
|
||||
@@ -349,6 +364,7 @@ import {
|
||||
VariableService
|
||||
} from '$lib/gen'
|
||||
import { superadmin, userStore, usersWorkspaceStore } from '$lib/stores'
|
||||
import { processSecretArgs } from '$lib/components/secretArgUtils'
|
||||
import { clearWorkspaceRoleCache } from '$lib/user'
|
||||
import { get } from 'svelte/store'
|
||||
import type { Tool, ToolCallbacks } from '../shared'
|
||||
@@ -368,7 +384,10 @@ function getBackendDraft<V = any>(kind: string, path: string, _opts?: unknown):
|
||||
|
||||
const toolCallbacks: ToolCallbacks = {
|
||||
setToolStatus: vi.fn(),
|
||||
removeToolStatus: vi.fn()
|
||||
removeToolStatus: vi.fn(),
|
||||
// Every host that can run a script mounts the form, so the default answers it with what it
|
||||
// opened with. A test meaning to exercise a host without one overrides this with undefined.
|
||||
requestRunArgs: async (_toolId, form) => form.args
|
||||
}
|
||||
|
||||
function getGlobalTool(name: string): Tool<{}> {
|
||||
@@ -4318,8 +4337,7 @@ describe('global AI tools', () => {
|
||||
|
||||
const result = await withCompletedTestJob(() =>
|
||||
callGlobalTool('test_run_script', {
|
||||
path: 'f/scripts/draft-test',
|
||||
args: { name: 'Ada' }
|
||||
path: 'f/scripts/draft-test'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -4328,7 +4346,7 @@ describe('global AI tools', () => {
|
||||
requestBody: {
|
||||
path: 'f/scripts/draft-test',
|
||||
content,
|
||||
args: { name: 'Ada' },
|
||||
args: {},
|
||||
language: 'bun'
|
||||
}
|
||||
})
|
||||
@@ -4347,8 +4365,7 @@ describe('global AI tools', () => {
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool('test_run_script', {
|
||||
path: 'f/scripts/deployed-test',
|
||||
args: { name: 'Grace' }
|
||||
path: 'f/scripts/deployed-test'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -4361,12 +4378,378 @@ describe('global AI tools', () => {
|
||||
requestBody: {
|
||||
path: 'f/scripts/deployed-test',
|
||||
content: 'def main(name):\n return name',
|
||||
args: { name: 'Grace' },
|
||||
args: {},
|
||||
language: 'python3'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// A test run meets the same card as a deployed one, so what previews is what the form
|
||||
// submitted — not what the model proposed.
|
||||
it('test_run_script previews the arguments the form submitted', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/formed-test',
|
||||
summary: 'Formed test script',
|
||||
content: 'export async function main(name: string) {}',
|
||||
language: 'bun',
|
||||
schema: { properties: { name: { type: 'string' } } }
|
||||
} as any)
|
||||
|
||||
let opened: Record<string, any> | undefined
|
||||
let kind: string | undefined
|
||||
let helperSource: { code?: string; lang?: string } | undefined
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'test_run_script',
|
||||
{ path: 'f/scripts/formed-test', args: { name: 'Ada' } },
|
||||
{
|
||||
...toolCallbacks,
|
||||
requestRunArgs: async (_toolId, form) => {
|
||||
opened = form.args
|
||||
kind = form.kind
|
||||
helperSource = { code: form.code, lang: form.lang }
|
||||
return { name: 'Grace' }
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(opened).toEqual({ name: 'Ada' })
|
||||
// Drives the card's tense: a test says it tested, not that it ran.
|
||||
expect(kind).toBe('test')
|
||||
// The draft itself, so a dynselect field offers the options this code returns rather
|
||||
// than the deployed version's — which is stale, or absent for a draft never deployed.
|
||||
expect(helperSource).toEqual({
|
||||
code: 'export async function main(name: string) {}',
|
||||
lang: 'bun'
|
||||
})
|
||||
expect(JobService.runScriptPreview).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
path: 'f/scripts/formed-test',
|
||||
content: 'export async function main(name: string) {}',
|
||||
args: { name: 'Grace' },
|
||||
language: 'bun'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// The bypass posture answers a run form as it answers any other confirmation, for a
|
||||
// deployed run as much as a test. The card must never render one first: a form nobody
|
||||
// will fill in is attached already settled, so no field is ever mounted, and the schema
|
||||
// it would have built them from never reaches the transcript.
|
||||
it('mounts no field on either run form under yolo', async () => {
|
||||
const script = {
|
||||
path: 'f/scripts/yolo',
|
||||
content: 'export async function main(name: string) {}',
|
||||
language: 'bun',
|
||||
schema: { properties: { name: { type: 'string' } } }
|
||||
} as any
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce(script)
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce(script)
|
||||
|
||||
const statuses: any[] = []
|
||||
const requestRunArgs = vi.fn(async (_toolId: string, form: any) => form.args)
|
||||
const yolo = {
|
||||
...toolCallbacks,
|
||||
setToolStatus: (_toolId: string, status: any) => statuses.push(status),
|
||||
shouldAutoAcceptToolConfirmations: () => true,
|
||||
requestRunArgs
|
||||
}
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool('test_run_script', { path: 'f/scripts/yolo', args: { name: 'Ada' } }, yolo)
|
||||
)
|
||||
|
||||
const testForm = statuses.find((s) => s.runForm)?.runForm
|
||||
expect(testForm.submitted).toBe(true)
|
||||
// Nothing is left to render it, and a card carrying one persists it forever.
|
||||
expect(testForm.schema).toBeUndefined()
|
||||
// Told the form is already answered, or the loop parks on a card with no fields.
|
||||
expect(requestRunArgs.mock.calls[0][2]).toEqual({ autoAccepted: true })
|
||||
expect(JobService.runScriptPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestBody: expect.objectContaining({ args: { name: 'Ada' } }) })
|
||||
)
|
||||
|
||||
statuses.length = 0
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool('run_script', { path: 'f/scripts/yolo', args: { name: 'Ada' } }, yolo)
|
||||
)
|
||||
|
||||
const deployedForm = statuses.find((s) => s.runForm)?.runForm
|
||||
expect(deployedForm.submitted).toBe(true)
|
||||
expect(deployedForm.schema).toBeUndefined()
|
||||
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestBody: { name: 'Ada' } })
|
||||
)
|
||||
})
|
||||
|
||||
// The posture is the user's standing answer to whether to ask, so a run it answers starts on
|
||||
// the model's arguments as sent — no default filled in, no required field second-guessed.
|
||||
// Predicting what a mounted field would hold starts runs the form itself would refuse; the
|
||||
// schema's own defaults are the worker's job, from the code's signature.
|
||||
it('sends the model arguments as proposed when the posture answers', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/defaulted',
|
||||
schema: {
|
||||
properties: { name: { type: 'string' }, retries: { type: 'number', default: 3 } },
|
||||
required: ['name', 'retries']
|
||||
}
|
||||
} as any)
|
||||
const statuses: any[] = []
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/defaulted', args: { name: 'Ada' } },
|
||||
{
|
||||
...toolCallbacks,
|
||||
setToolStatus: (_toolId: string, status: any) => statuses.push(status),
|
||||
shouldAutoAcceptToolConfirmations: () => true,
|
||||
requestRunArgs: async (_toolId: string, form: any) => form.args
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(statuses.find((x) => x.runForm)?.runForm.submitted).toBe(true)
|
||||
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestBody: { name: 'Ada' } })
|
||||
)
|
||||
})
|
||||
|
||||
// A disabled field is declared as not the caller's to set, and the posture answering the
|
||||
// form does not make the model one of the callers it is kept from.
|
||||
it('holds a disabled default against the model when the posture answers', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/locked',
|
||||
schema: {
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
mode: { type: 'string', disabled: true, default: 'safe' }
|
||||
}
|
||||
}
|
||||
} as any)
|
||||
|
||||
const result = await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/locked', args: { name: 'Ada', mode: 'destructive' } },
|
||||
{
|
||||
...toolCallbacks,
|
||||
shouldAutoAcceptToolConfirmations: () => true,
|
||||
requestRunArgs: async (_toolId: string, form: any) => form.args
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestBody: { name: 'Ada', mode: 'safe' } })
|
||||
)
|
||||
// Or the next call proposes the same override again.
|
||||
expect(result).toContain('disables mode')
|
||||
})
|
||||
|
||||
// With no form there is no PasswordArgInput to turn a proposed secret into a reference, and
|
||||
// a job's arguments are readable by everyone who can see its run. What starts the job must
|
||||
// be what came back from the minting, never the proposal.
|
||||
it('mints a proposed secret into a reference before starting a run the posture answers', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/secret',
|
||||
schema: { properties: { token: { type: 'string', password: true } }, required: ['token'] }
|
||||
} as any)
|
||||
vi.mocked(processSecretArgs).mockImplementationOnce(async () => ({
|
||||
token: '$var:u/ada/secret_arg/minted'
|
||||
}))
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/secret', args: { token: 'hunter2' } },
|
||||
{
|
||||
...toolCallbacks,
|
||||
shouldAutoAcceptToolConfirmations: () => true,
|
||||
requestRunArgs: async (_toolId: string, form: any) => form.args
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(processSecretArgs).toHaveBeenCalledWith(
|
||||
{ token: 'hunter2' },
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestBody: { token: '$var:u/ada/secret_arg/minted' } })
|
||||
)
|
||||
})
|
||||
|
||||
// The bypass is the user's standing answer, not a licence for the host to skip asking:
|
||||
// a chat with nowhere to put a form still refuses the run under any other posture.
|
||||
it('run_script refuses a host with no form unless the posture answers for it', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({
|
||||
path: 'f/scripts/noform',
|
||||
schema: { properties: { name: { type: 'string' } } }
|
||||
} as any)
|
||||
|
||||
const refused = await callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/noform', args: { name: 'Ada' } },
|
||||
{ ...toolCallbacks, requestRunArgs: undefined }
|
||||
)
|
||||
|
||||
expect(JobService.runScriptByPath).not.toHaveBeenCalled()
|
||||
expect(refused).toContain('cannot show a run form')
|
||||
})
|
||||
|
||||
// The posture answers wherever it is set, form or no form: what it answers is consent, and a
|
||||
// host without one has nothing left to ask. A secret still becomes a reference first, which
|
||||
// is the only thing the missing form would have done.
|
||||
it('run_script runs on a formless host under yolo', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({
|
||||
path: 'f/scripts/noform-secret',
|
||||
schema: {
|
||||
properties: { token: { type: 'string', password: true } },
|
||||
required: ['token']
|
||||
}
|
||||
} as any)
|
||||
vi.mocked(processSecretArgs).mockImplementationOnce(async () => ({
|
||||
token: '$var:u/ada/secret_arg/minted'
|
||||
}))
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/noform-secret', args: { token: 'hunter2' } },
|
||||
{
|
||||
...toolCallbacks,
|
||||
requestRunArgs: undefined,
|
||||
shouldAutoAcceptToolConfirmations: () => true
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestBody: { token: '$var:u/ada/secret_arg/minted' } })
|
||||
)
|
||||
})
|
||||
|
||||
// The transcript is re-cloned into IndexedDB on every save, and a form takes as much text
|
||||
// as the user pastes. What the card stores is bounded; what the job runs is not.
|
||||
it('run_script stores a marker for oversized arguments but runs them in full', async () => {
|
||||
const huge = 'x'.repeat(120_000)
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({
|
||||
path: 'f/scripts/big',
|
||||
content: 'export async function main(blob: string) {}',
|
||||
language: 'bun',
|
||||
schema: { properties: { blob: { type: 'string' } } }
|
||||
} as any)
|
||||
|
||||
const statuses: any[] = []
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/big', args: { blob: 'small' } },
|
||||
{
|
||||
...toolCallbacks,
|
||||
setToolStatus: (_toolId: string, status: any) => statuses.push(status),
|
||||
// The user pastes into the field the model left small: the model's own
|
||||
// proposal is bounded by what it can emit, this is not.
|
||||
requestRunArgs: async () => ({ blob: huge })
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestBody: { blob: huge } })
|
||||
)
|
||||
const persisted = statuses.filter((s) => s.parameters !== undefined).at(-1)?.parameters
|
||||
expect(persisted).toEqual({ reason: 'WINDMILL_TOO_BIG' })
|
||||
expect(JSON.stringify(statuses)).not.toContain(huge)
|
||||
})
|
||||
|
||||
// A schema with no fields still opens a form: an empty one is still the Run button, and
|
||||
// that button is the whole confirmation this tool has. Skipping it because there is
|
||||
// nothing to fill in starts the script with no confirmation at all.
|
||||
it('test_run_script opens a form and starts no job when the schema declares no field', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({
|
||||
path: 'f/scripts/noargs-test',
|
||||
content: 'export async function main() {}',
|
||||
language: 'bun',
|
||||
schema: { properties: {} }
|
||||
} as any)
|
||||
|
||||
const cancelled = await callGlobalTool(
|
||||
'test_run_script',
|
||||
{ path: 'f/scripts/noargs-test', args: { force_delete: true } },
|
||||
{ ...toolCallbacks, requestRunArgs: async () => undefined }
|
||||
)
|
||||
|
||||
expect(JobService.runScriptPreview).not.toHaveBeenCalled()
|
||||
expect(cancelled).toContain('The user cancelled the run form')
|
||||
|
||||
// A schema declaring nothing is a form with no field to hold this, and the card says
|
||||
// as much — so answering it must not send an argument that was never on screen.
|
||||
const answered = await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'test_run_script',
|
||||
{ path: 'f/scripts/noargs-test', args: { force_delete: true } },
|
||||
{ ...toolCallbacks, requestRunArgs: async (_toolId, form) => form.args }
|
||||
)
|
||||
)
|
||||
|
||||
expect(JobService.runScriptPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestBody: expect.objectContaining({ args: {} }) })
|
||||
)
|
||||
expect(answered).toContain('does not declare force_delete')
|
||||
})
|
||||
|
||||
// A host that answers the form without minting, as the eval harness does by returning the
|
||||
// proposal verbatim: the job's arguments are readable by everyone who can see its run.
|
||||
it('mints a secret the host handed back as a literal', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/host-literal',
|
||||
schema: { properties: { token: { type: 'string', password: true } } }
|
||||
} as any)
|
||||
vi.mocked(processSecretArgs).mockImplementationOnce(async () => ({
|
||||
token: '$var:u/ada/secret_arg/minted'
|
||||
}))
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/host-literal', args: { token: 'hunter2' } },
|
||||
{ ...toolCallbacks, requestRunArgs: async (_toolId, form) => form.args }
|
||||
)
|
||||
)
|
||||
|
||||
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestBody: { token: '$var:u/ada/secret_arg/minted' } })
|
||||
)
|
||||
})
|
||||
|
||||
// The bypass has no form to have shown them either, so the rule holds there too.
|
||||
it('drops an undeclared argument when the posture answers too', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/noargs-yolo',
|
||||
schema: { properties: { name: { type: 'string' } } }
|
||||
} as any)
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/noargs-yolo', args: { name: 'Ada', force_delete: true } },
|
||||
{
|
||||
...toolCallbacks,
|
||||
shouldAutoAcceptToolConfirmations: () => true,
|
||||
requestRunArgs: async (_toolId: string, form: any) => form.args
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestBody: { name: 'Ada' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('test_run_flow previews draft flow content by path', async () => {
|
||||
const modules = [{ id: 'start', value: { type: 'identity' } }]
|
||||
await callGlobalTool('write_flow', {
|
||||
@@ -4633,6 +5016,218 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The form IS the consent, so a dismissed one must leave the script unrun.
|
||||
it('run_script starts no job when the user cancels the form', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/deployed',
|
||||
summary: 'Deployed',
|
||||
schema: { properties: { name: { type: 'string' } } }
|
||||
} as any)
|
||||
|
||||
const result = await callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/deployed', args: { name: 'Ada' } },
|
||||
{ ...toolCallbacks, requestRunArgs: async () => undefined }
|
||||
)
|
||||
|
||||
expect(JobService.runScriptByPath).not.toHaveBeenCalled()
|
||||
expect(result).toContain('The user cancelled the run form')
|
||||
expect(result).toContain('Do not call run_script again')
|
||||
})
|
||||
|
||||
// job_args.test.ts owns what each step of the argument pipeline does; this owns that
|
||||
// run_script still runs them. Delete a call from runThroughForm and every one of those
|
||||
// unit tests still passes, so one call has to cross all of them here.
|
||||
it('run_script puts the proposed arguments through the whole pipeline', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/everything',
|
||||
schema: {
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
ratio: { type: 'number' },
|
||||
size: { type: 'number' },
|
||||
token: { type: 'string', password: true },
|
||||
locked: { type: 'string', default: 'fixed', disabled: true },
|
||||
doc: { type: 'string', contentEncoding: 'base64' }
|
||||
}
|
||||
}
|
||||
} as any)
|
||||
|
||||
const bytes = 'QUJD'.repeat(1024)
|
||||
const statuses: any[] = []
|
||||
let shown: Record<string, any> | undefined
|
||||
let cleared: string[] | undefined
|
||||
let reset: string[] | undefined
|
||||
const result = await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'run_script',
|
||||
{
|
||||
path: 'f/scripts/everything',
|
||||
args: {
|
||||
count: '7',
|
||||
ratio: 'abc',
|
||||
size: '$var:u/admin/batch_size',
|
||||
token: 'hunter2',
|
||||
locked: 'tampered',
|
||||
doc: bytes,
|
||||
force_delete: true
|
||||
}
|
||||
},
|
||||
{
|
||||
...toolCallbacks,
|
||||
setToolStatus: (_toolId: string, status: any) => statuses.push(status),
|
||||
requestRunArgs: async (_toolId, form) => {
|
||||
shown = form.args
|
||||
cleared = form.clearedKeys
|
||||
reset = form.resetKeys
|
||||
// What the user does with the form: attaches the file no model can produce,
|
||||
// and names a variable for the secret it was not allowed to fill.
|
||||
return { ...form.args, doc: bytes, token: '$var:u/ada/prod_api_key' }
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Coerced, cleared, left alone, reset, dropped and emptied of bytes — every rule reached
|
||||
// through the tool rather than called directly. The proposed secret is not emptied: it is
|
||||
// already in the model's own tool call in the same stored record, and PasswordArgInput
|
||||
// mints whatever the field opens with before the job sees it.
|
||||
expect(shown).toEqual({
|
||||
count: 7,
|
||||
size: '$var:u/admin/batch_size',
|
||||
token: 'hunter2',
|
||||
locked: 'fixed'
|
||||
})
|
||||
expect(cleared).toEqual(['ratio'])
|
||||
expect(reset).toEqual(['locked'])
|
||||
|
||||
// The bytes belong in the job request and nowhere else: the card is persisted, and a
|
||||
// file small enough to survive truncation would otherwise reach the model whole.
|
||||
expect(JobService.runScriptByPath).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/scripts/everything',
|
||||
requestBody: {
|
||||
count: 7,
|
||||
size: '$var:u/admin/batch_size',
|
||||
locked: 'fixed',
|
||||
doc: bytes,
|
||||
token: '$var:u/ada/prod_api_key'
|
||||
}
|
||||
})
|
||||
expect(result).toContain('does not declare force_delete')
|
||||
expect(result).toContain('<file: 3 KB>')
|
||||
// The bytes are the value; the reference is not, and the run page shows it for this
|
||||
// same job.
|
||||
expect(result).not.toContain(bytes)
|
||||
expect(JSON.stringify(statuses)).not.toContain(bytes)
|
||||
expect(result).toContain('$var:u/ada/prod_api_key')
|
||||
expect(JSON.stringify(statuses)).toContain('$var:u/ada/prod_api_key')
|
||||
// Named, or an emptied field reads as the user having deleted the value and the next
|
||||
// call proposes the same bytes again.
|
||||
for (const named of ['ratio', 'doc', 'locked']) expect(result).toContain(named)
|
||||
})
|
||||
|
||||
// The form is its own confirmation, so it never reaches processToolCall's second gate.
|
||||
// Plan mode can be switched on while it sits open, and the job must not start.
|
||||
it('run_script starts no job when plan mode is entered while the form is open', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/noargs',
|
||||
schema: { properties: {} }
|
||||
} as any)
|
||||
|
||||
let planning = false
|
||||
const result = await callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/noargs', args: {} },
|
||||
{
|
||||
...toolCallbacks,
|
||||
isPlanModeActive: () => planning,
|
||||
requestRunArgs: async (_toolId, form) => {
|
||||
planning = true
|
||||
return form.args
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(JobService.runScriptByPath).not.toHaveBeenCalled()
|
||||
expect(result).toContain('plan mode is active')
|
||||
})
|
||||
|
||||
// A form that reaches the screen mints a proposed secret on mount, which the gate after
|
||||
// the user answers is too late to unmake — so plan mode arriving during the fetch counts.
|
||||
it('run_script opens no form when plan mode is entered during the schema fetch', async () => {
|
||||
let planning = false
|
||||
vi.mocked(ScriptService.getScriptByPath).mockImplementationOnce(async () => {
|
||||
planning = true
|
||||
return {
|
||||
path: 'f/scripts/pw',
|
||||
schema: { properties: { token: { type: 'string', password: true } } }
|
||||
} as any
|
||||
})
|
||||
|
||||
let formOpened = false
|
||||
const result = await callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/pw', args: { token: 'hunter2' } },
|
||||
{
|
||||
...toolCallbacks,
|
||||
isPlanModeActive: () => planning,
|
||||
requestRunArgs: async (_toolId, form) => {
|
||||
formOpened = true
|
||||
return form.args
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(formOpened).toBe(false)
|
||||
expect(JobService.runScriptByPath).not.toHaveBeenCalled()
|
||||
expect(result).toContain('plan mode is active')
|
||||
})
|
||||
|
||||
it('run_script runs the arguments the user submitted, not the ones proposed', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/greet',
|
||||
schema: { properties: { name: { type: 'string' } } }
|
||||
} as any)
|
||||
|
||||
const result = await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/greet', args: { name: 'Ada' } },
|
||||
{ ...toolCallbacks, requestRunArgs: async () => ({ name: 'Grace' }) }
|
||||
)
|
||||
)
|
||||
|
||||
expect(JobService.runScriptByPath).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/scripts/greet',
|
||||
requestBody: { name: 'Grace' }
|
||||
})
|
||||
// The model must not assume its proposal is what ran.
|
||||
expect(result).toContain('Ran with arguments: {"name":"Grace"}')
|
||||
})
|
||||
|
||||
// The arguments are already in the call this result answers, and every way the form's
|
||||
// own differ from the proposed ones has its own clause — so an untouched form has
|
||||
// nothing to name, and naming it anyway pays for the copy on every later iteration.
|
||||
it('run_script names the arguments only when the user changed them', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/greet',
|
||||
schema: { properties: { name: { type: 'string' } } }
|
||||
} as any)
|
||||
|
||||
const result = await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/greet', args: { name: 'Ada' } },
|
||||
{ ...toolCallbacks, requestRunArgs: async (_toolId, form) => form.args }
|
||||
)
|
||||
)
|
||||
|
||||
expect(result).not.toContain('Ran with arguments')
|
||||
expect(result).toContain('unedited')
|
||||
})
|
||||
|
||||
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',
|
||||
@@ -5323,6 +5918,9 @@ describe('session-only preview tools gating', () => {
|
||||
expect(names).not.toContain('list_app_runs')
|
||||
expect(names).not.toContain('search_dom')
|
||||
expect(names).not.toContain('read_dom')
|
||||
// Not withheld: without it the side panel's only route to a deployed run is the raw
|
||||
// endpoint, which confirms an opaque request body instead of the arguments.
|
||||
expect(names).toContain('run_script')
|
||||
// other tools are still present
|
||||
expect(names).toContain('write_script')
|
||||
})
|
||||
@@ -5335,6 +5933,7 @@ describe('session-only preview tools gating', () => {
|
||||
expect(names).toContain('list_app_runs')
|
||||
expect(names).toContain('search_dom')
|
||||
expect(names).toContain('read_dom')
|
||||
expect(names).toContain('run_script')
|
||||
// The session set is the full globalTools minus capability-gated tools:
|
||||
// this environment is not Chromium, so take_screenshot is withheld (DOM
|
||||
// capture is only faithful on Blink). search_dom / read_dom are not gated.
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
WebsocketTriggerService
|
||||
} from '$lib/gen'
|
||||
import { createTwoFilesPatch } from 'diff'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
import { $ScriptLang } from '$lib/gen/schemas.gen'
|
||||
import type {
|
||||
@@ -47,6 +48,16 @@ import {
|
||||
STARTER_RUNNABLE_KEY,
|
||||
type FrameworkKey
|
||||
} from '$lib/components/raw_apps/templates'
|
||||
import {
|
||||
coerceArgsToSchema,
|
||||
dropUndeclaredArgs,
|
||||
enforceDisabledDefaults,
|
||||
redactFileArgs,
|
||||
redactSecretArgs,
|
||||
stripFileArgs
|
||||
} from '$lib/components/job_args'
|
||||
import { processSecretArgs } from '$lib/components/secretArgUtils'
|
||||
import { PLAN_MODE_MESSAGES } from '../planModeMessages'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue'
|
||||
import type { RawAppDomQuery } from '$lib/components/raw_apps/rawAppDom'
|
||||
@@ -120,6 +131,7 @@ import {
|
||||
isHubPath,
|
||||
type CreatedResourceTriggerKind,
|
||||
type PreviewCardKind,
|
||||
type RunFormDisplay,
|
||||
type Tool,
|
||||
type ToolCallbacks,
|
||||
type ToolDisplayAction
|
||||
@@ -889,7 +901,19 @@ const testRunScriptSchema = z.object({
|
||||
const testRunScriptToolDef = createToolDef(
|
||||
testRunScriptSchema,
|
||||
'test_run_script',
|
||||
'Execute a preview-style test run of a script by path, preferring draft content when it exists.',
|
||||
'Execute a preview-style test run of a script by path, preferring draft content when it exists. The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.',
|
||||
{ strict: false }
|
||||
)
|
||||
|
||||
const runScriptSchema = z.object({
|
||||
path: z.string().describe('Workspace path of the deployed script to run.'),
|
||||
args: testRunArgsSchema
|
||||
})
|
||||
|
||||
const runScriptToolDef = createToolDef(
|
||||
runScriptSchema,
|
||||
'run_script',
|
||||
'Run a DEPLOYED script for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real"); otherwise use test_run_script.',
|
||||
{ strict: false }
|
||||
)
|
||||
|
||||
@@ -1322,7 +1346,7 @@ ${pipelineBullet}
|
||||
: ' Pass items ("<kind>:<path>" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace'
|
||||
}, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed.
|
||||
- For a Windmill operation no other tool covers (workers, queue state, a run's args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead.
|
||||
- runScriptByPath / runFlowByPath from the API catalog run the DEPLOYED version of an item. Use them only when the user explicitly asks to run the deployed version, and read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs). To test something you are editing or just wrote, always use test_run_script, test_run_flow, or test_run_step — they run the draft.
|
||||
- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For run_script, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema, and fill in every one you can infer. runFlowByPath from the API catalog runs a deployed flow without a form: only for a flow the user asked to run deployed.
|
||||
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive).
|
||||
- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.
|
||||
- Keep context targeted.${
|
||||
@@ -3639,12 +3663,31 @@ export const globalTools: Tool<{}>[] = [
|
||||
const parsed = testRunScriptSchema.parse(ctx.args)
|
||||
return testRunScriptByPath(parsed, ctx)
|
||||
},
|
||||
requiresConfirmation: true,
|
||||
confirmationMessage: (args) => `Run a test of ${pathLeaf(args?.path, 'the script')}`,
|
||||
// No requiresConfirmation: the argument form is the confirmation, and the bypass posture
|
||||
// answers it with what the form opened with — a decision made for the user, so the
|
||||
// posture's own list has to name it. One thing does run before Run: see the note on
|
||||
// the form's SchemaForm.
|
||||
bypassedByAutoAccept: true,
|
||||
confirmationMessage: 'Run a test of a script',
|
||||
streamingLabel: 'Preparing the test form...',
|
||||
queuedLabel: (args) => `Test ${args?.path ?? 'the script'}`,
|
||||
showDetails: true,
|
||||
autoCollapseDetails: false
|
||||
},
|
||||
{
|
||||
def: runScriptToolDef,
|
||||
fn: async (ctx) => {
|
||||
const parsed = runScriptSchema.parse(ctx.args)
|
||||
return runDeployedScript(parsed, ctx)
|
||||
},
|
||||
// No requiresConfirmation, for the reason test_run_script carries.
|
||||
bypassedByAutoAccept: true,
|
||||
confirmationMessage: 'Run a deployed script',
|
||||
streamingLabel: 'Preparing the run form...',
|
||||
queuedLabel: (args) => `Run ${args?.path ?? 'a script'}`,
|
||||
showDetails: true,
|
||||
autoCollapseDetails: false
|
||||
},
|
||||
{
|
||||
def: testRunFlowToolDef,
|
||||
fn: async (ctx) => {
|
||||
@@ -4282,8 +4325,8 @@ export const SESSION_PREVIEW_TOOL_NAMES = new Set([
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* chat, or `globalTools` minus the preview tools for the regular global
|
||||
* side-panel chat.
|
||||
*/
|
||||
export function globalToolsFor({ sessionPreview }: { sessionPreview: boolean }): Tool<{}>[] {
|
||||
const tools = sessionPreview
|
||||
@@ -5116,16 +5159,52 @@ function writeVariableDraft(args: WriteVariableArgs, ctx: WriteDraftCtx): Promis
|
||||
async function loadScriptForEdit(
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<{ content: string; language: ScriptLang; summary?: string }> {
|
||||
): Promise<{
|
||||
content: string
|
||||
language: ScriptLang
|
||||
summary?: string
|
||||
schema?: Record<string, any>
|
||||
}> {
|
||||
const draft = await getGlobalDraft(workspace, 'script', path)
|
||||
if (draft) {
|
||||
if (typeof draft.value !== 'string' || !draft.language) {
|
||||
throw new Error(`Draft script "${path}" is missing content or language.`)
|
||||
}
|
||||
return { content: draft.value, language: draft.language, summary: draft.summary }
|
||||
return {
|
||||
content: draft.value,
|
||||
language: draft.language,
|
||||
summary: draft.summary,
|
||||
schema: draft.schema as Record<string, any> | undefined
|
||||
}
|
||||
}
|
||||
const script = await ScriptService.getScriptByPath({ workspace, path })
|
||||
return { content: script.content, language: script.language, summary: script.summary }
|
||||
return {
|
||||
content: script.content,
|
||||
language: script.language,
|
||||
summary: script.summary,
|
||||
schema: script.schema as Record<string, any> | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** The fields a test form offers, for code that may never have been deployed. A draft the
|
||||
* chat wrote carries the schema it inferred at write time; anything else — a draft written
|
||||
* elsewhere, a deployed script whose schema predates an edit — is inferred here from the
|
||||
* content that is about to run, so the form cannot offer a field the code no longer takes. */
|
||||
async function schemaForTestRun(script: {
|
||||
content: string
|
||||
language: ScriptLang
|
||||
schema?: Record<string, any>
|
||||
}): Promise<Record<string, any>> {
|
||||
// Emptily declared is not declared: a stored `properties: {}` means the schema predates
|
||||
// the arguments the code now takes, so infer rather than offer a form with no fields.
|
||||
if (Object.keys(script.schema?.properties ?? {}).length > 0) return script.schema!
|
||||
const schema = emptySchema()
|
||||
try {
|
||||
await inferArgs(script.language, script.content, schema)
|
||||
} catch (e) {
|
||||
console.error('Failed to infer script schema for the test run form', e)
|
||||
}
|
||||
return schema as unknown as Record<string, any>
|
||||
}
|
||||
|
||||
async function editScript(
|
||||
@@ -5370,30 +5449,324 @@ async function testRunScriptByPath(
|
||||
args: z.infer<typeof testRunScriptSchema>,
|
||||
ctx: WriteDraftCtx
|
||||
): Promise<string> {
|
||||
const { workspace, toolId, toolCallbacks } = ctx
|
||||
const { workspace } = ctx
|
||||
const script = await loadScriptForEdit(args.path, workspace)
|
||||
const testArgs = normalizeTestRunArgs(args.args)
|
||||
const schema = await schemaForTestRun(script)
|
||||
|
||||
return executeTestRun({
|
||||
jobStarter: () =>
|
||||
JobService.runScriptPreview({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: args.path,
|
||||
content: script.content,
|
||||
args: testArgs,
|
||||
language: script.language
|
||||
}
|
||||
}),
|
||||
return runThroughForm(
|
||||
{
|
||||
path: args.path,
|
||||
schema,
|
||||
summary: script.summary,
|
||||
kind: 'test',
|
||||
code: script.content,
|
||||
lang: script.language,
|
||||
// Never "deployed" here: the code about to run is the draft the model is still
|
||||
// writing, and a line telling it to re-read the deployed schema would send it
|
||||
// to the wrong version.
|
||||
schemaNoun: 'script',
|
||||
toolName: 'test_run_script',
|
||||
proposed: args.args,
|
||||
startMessage: `Running test for script "${args.path}"...`,
|
||||
contextName: 'script',
|
||||
// Its own loop: the model is told to test and iterate, so the posture answers the
|
||||
// form with what it opened with rather than parking the loop on a card.
|
||||
autoAcceptable: true,
|
||||
background: args.background,
|
||||
detachAfterMs: waitSecondsToDetachMs(args.wait_seconds),
|
||||
startJob: (submitted) =>
|
||||
JobService.runScriptPreview({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: args.path,
|
||||
content: script.content,
|
||||
args: submitted,
|
||||
language: script.language
|
||||
}
|
||||
})
|
||||
},
|
||||
ctx
|
||||
)
|
||||
}
|
||||
|
||||
/** The "do not call again" half is load-bearing: without it the model re-proposes the
|
||||
* call, which re-opens the form the user just dismissed, and Stop becomes their only
|
||||
* way out. */
|
||||
const runFormCancelled = (toolName: string) =>
|
||||
`The user cancelled the run form. The script did NOT run. Do not call ${toolName} again unless the user asks for it.`
|
||||
|
||||
/** The model only needs to see what the user changed, and nothing bounds an object or
|
||||
* array argument the form let them paste into. */
|
||||
const MAX_SUBMITTED_ARGS_LENGTH = 4000
|
||||
|
||||
/** The card's own copy is bounded separately, and far higher: it is what the details pane
|
||||
* renders, and JobArgs stops rendering the JSON in full at this size regardless. */
|
||||
const MAX_PERSISTED_ARGS_LENGTH = 100_000
|
||||
|
||||
/** One run through an argument form: conform what the model proposed to the schema of the
|
||||
* version about to run, open the form on it, then run whatever came back. Both tools that
|
||||
* run a script are this, differing only in where the schema comes from and how the job
|
||||
* starts — so the user meets one card whichever they asked for. */
|
||||
type FormRunSpec = {
|
||||
path: string
|
||||
schema: Record<string, any>
|
||||
summary?: string
|
||||
kind: 'run' | 'test'
|
||||
/** The code a test run is about to preview, so its form can offer the same dynamic-option
|
||||
* pickers the script editor's test panel does. Omitted for a deployed run, which names a
|
||||
* path instead. */
|
||||
code?: string
|
||||
lang?: ScriptLang
|
||||
/** How the lines the model reads back name the version this ran: telling it to re-read
|
||||
* the "deployed schema" of a draft would send it to the wrong code. */
|
||||
schemaNoun: string
|
||||
toolName: string
|
||||
proposed: Record<string, any> | null | undefined
|
||||
startMessage: string
|
||||
contextName: 'script' | 'flow'
|
||||
/** Whether the bypass posture may answer this form with what it opened with. */
|
||||
autoAcceptable?: boolean
|
||||
background?: boolean
|
||||
detachAfterMs?: number
|
||||
startJob: (submitted: Record<string, any>) => Promise<string>
|
||||
}
|
||||
|
||||
async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<string> {
|
||||
const { workspace, toolId, toolCallbacks } = ctx
|
||||
// Asked of the posture, not of the tool: every run tool is auto-acceptable, so a
|
||||
// host with no form would otherwise run one on the model's arguments alone, in any
|
||||
// posture. What a bypass answers is a decision the user already made; without it there
|
||||
// is no consent to be had here and nothing to fall back on.
|
||||
const postureAnswers = Boolean(
|
||||
spec.autoAcceptable && toolCallbacks.shouldAutoAcceptToolConfirmations?.(spec.toolName)
|
||||
)
|
||||
if (!toolCallbacks.requestRunArgs && !postureAnswers) {
|
||||
return 'This chat cannot show a run form, so a script cannot be run from here.'
|
||||
}
|
||||
|
||||
// processToolCall gates plan mode once, before the schema fetch, and this form is its own
|
||||
// confirmation so it never reaches that gate again. Repeated wherever a write follows: a
|
||||
// mounted field mints on its own, which no later gate can unmake.
|
||||
const blockedByPlanMode = (): string | undefined => {
|
||||
if (!toolCallbacks.isPlanModeActive?.()) return undefined
|
||||
toolCallbacks.onToolBlockedByPlanMode?.()
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: PLAN_MODE_MESSAGES.blockedLabel,
|
||||
isLoading: false,
|
||||
isStreamingArguments: false,
|
||||
error: PLAN_MODE_MESSAGES.blockedResult,
|
||||
blockedByPlanMode: true
|
||||
})
|
||||
return PLAN_MODE_MESSAGES.blockedResult
|
||||
}
|
||||
const blockedBeforeForm = blockedByPlanMode()
|
||||
if (blockedBeforeForm) return blockedBeforeForm
|
||||
|
||||
const schema = spec.schema
|
||||
// Whether to ask is the only question decided here. What a mounted field would hold — a
|
||||
// default, a synthesised empty, whether Run lights up — is the form's own business: any
|
||||
// second derivation of it here can start a run the form itself would refuse.
|
||||
const autoAccepted = postureAnswers
|
||||
const strippedKeys: string[] = []
|
||||
const coerced = autoAccepted
|
||||
? undefined
|
||||
: coerceArgsToSchema(normalizeTestRunArgs(spec.proposed), schema)
|
||||
let proposed: Record<string, any>
|
||||
let resetKeys: string[]
|
||||
let undeclaredKeys: string[]
|
||||
if (coerced) {
|
||||
resetKeys = coerced.resetKeys
|
||||
undeclaredKeys = coerced.undeclaredKeys
|
||||
// Left as the model proposed it, minted by the widget the field mounts: a reference put
|
||||
// here instead would be normalised away by the nested form an object secret renders as.
|
||||
proposed = stripFileArgs(coerced.args, schema as any, strippedKeys)
|
||||
} else {
|
||||
// Both rules hold against every caller, not only the ones a form stands in front of:
|
||||
// an undeclared argument has no field anywhere, and a disabled one is nobody's to set.
|
||||
// Without the rest of the coercion, which answers what a mounted widget would show.
|
||||
const declared = dropUndeclaredArgs(normalizeTestRunArgs(spec.proposed), schema)
|
||||
undeclaredKeys = declared.undeclaredKeys
|
||||
const enforced = enforceDisabledDefaults(declared.args, schema)
|
||||
resetKeys = enforced.resetKeys
|
||||
// In the widget's stead: with no form there is no PasswordArgInput to turn a proposed
|
||||
// secret into a reference, and the job's arguments outlive the run.
|
||||
try {
|
||||
proposed = await processSecretArgs(enforced.args, schema as any, workspace)
|
||||
} catch (e) {
|
||||
const message = `Failed to store the sensitive arguments of "${spec.path}": ${e}`
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: message,
|
||||
isLoading: false,
|
||||
isStreamingArguments: false,
|
||||
error: message
|
||||
})
|
||||
return message
|
||||
}
|
||||
}
|
||||
const form: RunFormDisplay = {
|
||||
path: spec.path,
|
||||
summary: spec.summary || undefined,
|
||||
kind: spec.kind,
|
||||
schema: autoAccepted ? undefined : schema,
|
||||
code: autoAccepted ? undefined : spec.code,
|
||||
lang: autoAccepted ? undefined : spec.lang,
|
||||
submitted: autoAccepted || undefined,
|
||||
args: proposed,
|
||||
clearedKeys: coerced?.clearedKeys.length ? coerced.clearedKeys : undefined,
|
||||
resetKeys: resetKeys.length ? resetKeys : undefined,
|
||||
strippedKeys: strippedKeys.length ? strippedKeys : undefined
|
||||
}
|
||||
|
||||
// Files only: nothing rewrites `runForm.args` after this, so bytes left in it outlive the
|
||||
// size guard that covers `parameters`.
|
||||
const persisted = { ...form, args: redactFileArgs(proposed, schema as any) }
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: autoAccepted
|
||||
? spec.startMessage
|
||||
: `Waiting for you to confirm the arguments of "${spec.path}"`,
|
||||
runForm: persisted,
|
||||
// Not the raw tool-call arguments: the card settles on what the form opened with.
|
||||
// Only settles it — the raw proposal still renders while the call streams in.
|
||||
parameters: persisted.args,
|
||||
isLoading: true
|
||||
})
|
||||
|
||||
// `form`, not `persisted`: a password field mints from what it opens with.
|
||||
const submitted = toolCallbacks.requestRunArgs
|
||||
? await toolCallbacks.requestRunArgs(toolId, form, { autoAccepted })
|
||||
: proposed
|
||||
if (!submitted) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Run of "${spec.path}" cancelled by user`,
|
||||
isLoading: false,
|
||||
isStreamingArguments: false,
|
||||
error: 'Cancelled by user',
|
||||
declinedByUser: true
|
||||
})
|
||||
return runFormCancelled(spec.toolName)
|
||||
}
|
||||
|
||||
const blockedBeforeRun = blockedByPlanMode()
|
||||
if (blockedBeforeRun) return blockedBeforeRun
|
||||
|
||||
// Every job leaves through here, so this is where a sensitive argument becomes a reference:
|
||||
// the form mints as the user types and the bypass mints in its stead, but a host answering
|
||||
// the form its own way — the eval harness does — would hand over a literal. Idempotent, so
|
||||
// the two that already minted pay a walk and no round trip.
|
||||
let toRun: Record<string, any>
|
||||
try {
|
||||
toRun = await processSecretArgs(submitted, schema as any, workspace)
|
||||
} catch (e) {
|
||||
const message = `Failed to store the sensitive arguments of "${spec.path}": ${e}`
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: message,
|
||||
isLoading: false,
|
||||
isStreamingArguments: false,
|
||||
error: message
|
||||
})
|
||||
return message
|
||||
}
|
||||
|
||||
// The card's details pane must show what ran, not what was proposed. Bytes are marked by
|
||||
// size because the card is persisted; everything else stands as the run page shows it for
|
||||
// the same job.
|
||||
const forCard = redactFileArgs(toRun, schema as any)
|
||||
// The transcript is re-cloned into IndexedDB on every save and a form carries whatever was
|
||||
// pasted into it, so past what the pane would render the card reads the arguments off the
|
||||
// job instead. Only once there is a job to read them from: substituting the marker any
|
||||
// earlier would leave a run that never started showing nothing but the marker.
|
||||
const oversized = JSON.stringify(forCard).length > MAX_PERSISTED_ARGS_LENGTH
|
||||
if (!oversized) {
|
||||
toolCallbacks.setToolStatus(toolId, { parameters: forCard })
|
||||
}
|
||||
|
||||
const outcome = await executeTestRun({
|
||||
jobStarter: async () => {
|
||||
const jobId = await spec.startJob(toRun)
|
||||
// The form's own submitted flag flips a round trip earlier, when the user presses
|
||||
// Run; only from here is there a job for a stopped turn to say it left running.
|
||||
toolCallbacks.markRunFormStarted?.(toolId)
|
||||
if (oversized) {
|
||||
toolCallbacks.setToolStatus(toolId, { parameters: { reason: 'WINDMILL_TOO_BIG' } })
|
||||
}
|
||||
return jobId
|
||||
},
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
startMessage: `Running test for script "${args.path}"...`,
|
||||
contextName: 'script',
|
||||
background: args.background,
|
||||
detachAfterMs: waitSecondsToDetachMs(args.wait_seconds),
|
||||
label: args.path
|
||||
startMessage: spec.startMessage,
|
||||
contextName: spec.contextName,
|
||||
actionNoun: spec.kind === 'test' ? 'test' : 'run',
|
||||
background: spec.background,
|
||||
detachAfterMs: spec.detachAfterMs,
|
||||
label: spec.path
|
||||
})
|
||||
|
||||
const schemaNoun = `${spec.schemaNoun} schema`
|
||||
// Only what the form could make no reading of: a wrong-typed value it can read is
|
||||
// converted silently, since the field then shows what the run carries and there is
|
||||
// nothing to report.
|
||||
const clearedKeys = coerced?.clearedKeys ?? []
|
||||
const cleared = clearedKeys.length
|
||||
? `\nThe ${schemaNoun} declares ${clearedKeys.join(', ')}, but you sent ${clearedKeys.length > 1 ? 'them in shapes' : 'it in a shape'} with no reading in the declared ${clearedKeys.length > 1 ? 'types' : 'type'}, so the ${clearedKeys.length > 1 ? 'fields opened' : 'field opened'} empty and the run did not carry ${clearedKeys.length > 1 ? 'them' : 'it'}. Re-read the input schema and match ${clearedKeys.length > 1 ? 'their declared types' : 'its declared type'}.`
|
||||
: ''
|
||||
const reset = resetKeys.length
|
||||
? `\nThe ${schemaNoun} disables ${resetKeys.join(', ')}, so the run used ${resetKeys.length > 1 ? 'their defaults' : 'its default'} rather than the proposed ${resetKeys.length > 1 ? 'values' : 'value'}. Do not propose ${resetKeys.length > 1 ? 'them' : 'it'} again.`
|
||||
: ''
|
||||
// Nothing renders these, so the model is the only one who can be told they went nowhere.
|
||||
const undeclared = undeclaredKeys.length
|
||||
? `\nThe ${schemaNoun} does not declare ${undeclaredKeys.join(', ')}, so ${undeclaredKeys.length > 1 ? 'they were' : 'it was'} not sent — no run form in Windmill offers a field the schema does not name. Re-read the input schema and use the arguments it declares.`
|
||||
: ''
|
||||
// Otherwise an emptied field reads as the user having deleted it, and the next call
|
||||
// proposes the same bytes again.
|
||||
const stripped = strippedKeys.length
|
||||
? `\n${strippedKeys.join(', ')} ${strippedKeys.length > 1 ? 'are file arguments' : 'is a file argument'}, so the form opened ${strippedKeys.length > 1 ? 'them' : 'it'} empty for the user to attach. ${strippedKeys.length > 1 ? 'They are' : 'It is'} theirs to provide, not yours: do not propose ${strippedKeys.length > 1 ? 'them' : 'it'} again.`
|
||||
: ''
|
||||
// Redacted for the model alone: what it proposed is already in its own tool call, but a
|
||||
// secret the user typed into the form would be entering its context here.
|
||||
const redacted = redactFileArgs(redactSecretArgs(toRun, schema as any), schema as any)
|
||||
const submittedJson = JSON.stringify(redacted)
|
||||
const shown =
|
||||
submittedJson.length > MAX_SUBMITTED_ARGS_LENGTH
|
||||
? submittedJson.slice(0, MAX_SUBMITTED_ARGS_LENGTH) + '... (truncated)'
|
||||
: submittedJson
|
||||
// Naming them costs a copy of arguments already in the call above, and cleared/reset/
|
||||
// stripped name every way the form's own differ from the proposed ones — so only what
|
||||
// the user changed is news.
|
||||
const ran = deepEqual(redacted, proposed)
|
||||
? 'Ran with the arguments the form opened with, unedited.'
|
||||
: `Ran with arguments: ${shown}`
|
||||
return `${ran}${cleared}${reset}${stripped}${undeclared}\n${outcome}`
|
||||
}
|
||||
|
||||
async function runDeployedScript(
|
||||
args: z.infer<typeof runScriptSchema>,
|
||||
ctx: WriteDraftCtx
|
||||
): Promise<string> {
|
||||
const { workspace } = ctx
|
||||
// No getDraft: this runs the script as it is live, so the form has to offer the
|
||||
// inputs the live version accepts and not a draft's.
|
||||
const script = await ScriptService.getScriptByPath({ workspace, path: args.path })
|
||||
return runThroughForm(
|
||||
{
|
||||
path: args.path,
|
||||
schema: (script.schema as Record<string, any>) ?? {},
|
||||
summary: script.summary,
|
||||
kind: 'run',
|
||||
schemaNoun: 'deployed',
|
||||
toolName: 'run_script',
|
||||
proposed: args.args,
|
||||
startMessage: `Running "${args.path}"...`,
|
||||
contextName: 'script',
|
||||
// Bypassable like a test run: the posture is the user's standing answer, and a form
|
||||
// it parks on is a card nobody is watching.
|
||||
autoAcceptable: true,
|
||||
startJob: (submitted) =>
|
||||
JobService.runScriptByPath({ workspace, path: args.path, requestBody: submitted })
|
||||
},
|
||||
ctx
|
||||
)
|
||||
}
|
||||
|
||||
async function testRunFlowByPath(
|
||||
|
||||
@@ -12,6 +12,9 @@ export const PLAN_MODE_MESSAGES = {
|
||||
/** Sits beside the autonomy picker while plan mode holds. The picker's tooltip carries
|
||||
* the rest, so this states only the constraint. */
|
||||
modeNote: 'Read-only',
|
||||
/** Refuses a pending run form. Its own string because nothing is settled: the form stays
|
||||
* live, so this names the way out rather than telling the user their run was blocked. */
|
||||
runFormRefused: 'Plan mode is read-only — switch it off to run this script.',
|
||||
// One pair for both artifact tools: the fact and the way forward are the same whether the
|
||||
// model tried to mint the plan or to rewrite it, and the generic refusal above ("put this
|
||||
// change in your plan") reads as nonsense for a call that writes a document.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Live "is there more past this edge" for one scroll region. Measured, not assumed: these
|
||||
* boxes change height under a still scroll offset — a tool result streams in, a dynamic
|
||||
* field fills its options. Put `container` on the scrolling element with
|
||||
* `onscroll={measure}`, and `content` on the element inside it whose height moves.
|
||||
*/
|
||||
export function scrollFades() {
|
||||
let node: HTMLElement | undefined = undefined
|
||||
let top = $state(false)
|
||||
let bottom = $state(false)
|
||||
// Built on first attach, never at call time: this runs during component init, where
|
||||
// ResizeObserver does not exist on the server.
|
||||
let observer: ResizeObserver | undefined = undefined
|
||||
|
||||
function measure() {
|
||||
if (!node) {
|
||||
top = false
|
||||
bottom = false
|
||||
return
|
||||
}
|
||||
top = node.scrollTop > 1
|
||||
bottom = node.scrollHeight - node.scrollTop - node.clientHeight > 1
|
||||
}
|
||||
|
||||
function observe(el: HTMLElement) {
|
||||
observer ??= new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
return {
|
||||
destroy() {
|
||||
observer?.unobserve(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get top() {
|
||||
return top
|
||||
},
|
||||
get bottom() {
|
||||
return bottom
|
||||
},
|
||||
measure,
|
||||
container(el: HTMLElement) {
|
||||
node = el
|
||||
measure()
|
||||
const handle = observe(el)
|
||||
return {
|
||||
destroy() {
|
||||
handle.destroy()
|
||||
node = undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
content: observe
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ vi.mock('$lib/components/flows/flowTree', () => ({
|
||||
vi.mock('$lib/gen', () => ({
|
||||
ScriptService: {},
|
||||
FlowService: {},
|
||||
JobService: { getJob: vi.fn() },
|
||||
JobService: { getJob: vi.fn(), getJobUpdates: vi.fn() },
|
||||
ScheduleService: {
|
||||
previewSchedule: vi.fn(),
|
||||
createSchedule: vi.fn()
|
||||
@@ -945,6 +945,16 @@ describe('processToolCall', () => {
|
||||
{ requestConfirmation: vi.fn().mockResolvedValue(false) }
|
||||
)
|
||||
).toEqual([['ai_chat', 'tool', 'run_script:declined']])
|
||||
// A tool that runs its own consent surface (the run form) declines by settling
|
||||
// the card, then returns normally — that must not read as a successful run.
|
||||
expect(
|
||||
await outcomeKeys({
|
||||
fn: vi.fn(async ({ toolCallbacks, toolId }: any) => {
|
||||
toolCallbacks.setToolStatus(toolId, { declinedByUser: true })
|
||||
return 'cancelled'
|
||||
})
|
||||
})
|
||||
).toEqual([['ai_chat', 'tool', 'run_script:declined']])
|
||||
expect(await outcomeKeys({}, { isPlanModeActive: () => true })).toEqual([
|
||||
['ai_chat', 'tool', 'run_script:blocked_plan_mode']
|
||||
])
|
||||
@@ -1272,6 +1282,28 @@ describe('isActiveUserQuestion', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isActiveRunForm', () => {
|
||||
const runForm = { path: 'f/a/b', schema: {}, args: { name: 'ada' } }
|
||||
function toolMessage(overrides: Partial<ToolDisplayMessage> = {}): ToolDisplayMessage {
|
||||
return {
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_r',
|
||||
content: 'waiting for arguments',
|
||||
isLoading: true,
|
||||
runForm,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Either flag unmounts the card, so the loop must stop waiting on it.
|
||||
it('is false once submitted or cancelled', async () => {
|
||||
const { isActiveRunForm } = await import('./shared')
|
||||
expect(isActiveRunForm(toolMessage())).toBe(true)
|
||||
expect(isActiveRunForm(toolMessage({ runForm: { ...runForm, submitted: true } }))).toBe(false)
|
||||
expect(isActiveRunForm(toolMessage({ runForm: { ...runForm, canceled: true } }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pendingUserAction', () => {
|
||||
const toolMessage = (overrides: Partial<ToolDisplayMessage> = {}): ToolDisplayMessage => ({
|
||||
role: 'tool',
|
||||
@@ -1289,6 +1321,15 @@ describe('pendingUserAction', () => {
|
||||
expect(pendingUserAction([toolMessage({ needsConfirmation: true })])).toBe('confirmation')
|
||||
})
|
||||
|
||||
// A run form asks for arguments rather than a yes/no, but it blocks the loop the
|
||||
// same way, so the chat reports it as a confirmation.
|
||||
it('reports an unsubmitted run form as a confirmation', async () => {
|
||||
const { pendingUserAction } = await import('./shared')
|
||||
expect(
|
||||
pendingUserAction([toolMessage({ runForm: { path: 'f/a/b', schema: {}, args: {} } })])
|
||||
).toBe('confirmation')
|
||||
})
|
||||
|
||||
it('is undefined for a tool the AI is running on its own', async () => {
|
||||
const { pendingUserAction } = await import('./shared')
|
||||
expect(pendingUserAction([toolMessage()])).toBe(undefined)
|
||||
@@ -1365,6 +1406,9 @@ describe('pollJobCompletion detach', () => {
|
||||
const getJob = vi.mocked(JobService.getJob)
|
||||
getJob.mockReset()
|
||||
getJob.mockResolvedValue({ type: 'QueuedJob', running: true } as any)
|
||||
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
|
||||
getJobUpdates.mockReset()
|
||||
getJobUpdates.mockResolvedValue({ running: true, completed: false } as any)
|
||||
const cbs = makeCallbacks()
|
||||
|
||||
// detachAfterMs 2000 → 2 polls at 1s each, then detach.
|
||||
@@ -1390,14 +1434,50 @@ describe('pollJobCompletion detach', () => {
|
||||
const { JobService } = await import('$lib/gen')
|
||||
const getJob = vi.mocked(JobService.getJob)
|
||||
getJob.mockReset()
|
||||
const completed = { type: 'CompletedJob', success: true, result: 42 }
|
||||
const completed = { type: 'CompletedJob', success: true, result: 42, logs: 'ran' }
|
||||
getJob.mockResolvedValue(completed as any)
|
||||
// Still landing on a tick the updates endpoint calls unfinished: the job can
|
||||
// complete between the two calls, and `getJob` is what says so.
|
||||
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
|
||||
getJobUpdates.mockReset()
|
||||
getJobUpdates.mockResolvedValue({ completed: false, running: true } as any)
|
||||
const cbs = makeCallbacks()
|
||||
|
||||
const promise = pollJobCompletion('job1', 'w', 'tool1', cbs as any, { detachAfterMs: 15000 })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(await promise).toBe(completed)
|
||||
const landed = await promise
|
||||
expect(landed).toBe(completed)
|
||||
// Fetched again with its logs rather than settled on the logless tick fetch,
|
||||
// which would reach the model as "No logs available".
|
||||
expect((landed as any).logs).toBe('ran')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// Streaming rides on a second endpoint; landing the job must not. A failing updates
|
||||
// endpoint costs live logs, never the run.
|
||||
it('returns the completed job with its logs when the updates endpoint fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { pollJobCompletion } = await import('./shared')
|
||||
const { JobService } = await import('$lib/gen')
|
||||
const getJob = vi.mocked(JobService.getJob)
|
||||
getJob.mockReset()
|
||||
const completed = { type: 'CompletedJob', success: true, result: 42, logs: 'ran' }
|
||||
getJob.mockResolvedValue(completed as any)
|
||||
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
|
||||
getJobUpdates.mockReset()
|
||||
getJobUpdates.mockRejectedValue(new Error('updates unavailable'))
|
||||
const cbs = makeCallbacks()
|
||||
|
||||
const promise = pollJobCompletion('job1', 'w', 'tool1', cbs as any, { detachAfterMs: 15000 })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
const landed = await promise
|
||||
expect(landed).toBe(completed)
|
||||
expect((landed as any).logs).toBe('ran')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -1411,6 +1491,9 @@ describe('pollJobCompletion detach', () => {
|
||||
const getJob = vi.mocked(JobService.getJob)
|
||||
getJob.mockReset()
|
||||
getJob.mockResolvedValue({ type: 'QueuedJob', running: true } as any)
|
||||
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
|
||||
getJobUpdates.mockReset()
|
||||
getJobUpdates.mockResolvedValue({ running: true, completed: false } as any)
|
||||
const cbs = makeCallbacks()
|
||||
|
||||
const promise = pollJobCompletion('job1', 'w', 'tool1', cbs as any)
|
||||
|
||||
@@ -553,6 +553,53 @@ export function answeredChoices(q: UserQuestionDisplay): string[] | undefined {
|
||||
return q.selectedChoices ?? (q.selectedChoice ? [q.selectedChoice] : undefined)
|
||||
}
|
||||
|
||||
/** Argument form for a deployed-script run, persisted with the transcript — every
|
||||
* field has to stay plain JSON. */
|
||||
export type RunFormDisplay = {
|
||||
path: string
|
||||
summary?: string
|
||||
/** What the run is, in the card's own words: a deployed script run, or a preview of the
|
||||
* draft being written. Only the tense of the row's label turns on it. */
|
||||
kind?: 'run' | 'test'
|
||||
/** Of whatever version is about to run: the deployed script, or the draft a test
|
||||
* previews. Only the rendered form reads it, so it is dropped once one of the flags
|
||||
* below unmounts that form: kept, every settled card would carry a copy of the schema
|
||||
* — password and file defaults included — in history forever. */
|
||||
schema?: Record<string, any>
|
||||
/** The draft a test run previews, for the `dynselect-` helper only — a deployed helper
|
||||
* would answer for the wrong version. Set on a test run alone, and dropped with the
|
||||
* schema once the form unmounts, so no settled card carries a copy of the code. */
|
||||
code?: string
|
||||
lang?: ScriptLang
|
||||
/** Prefill only: the card's `parameters` records what the job started with. */
|
||||
args: Record<string, any>
|
||||
/** Proposed arguments emptied because their declared type had no reading of them.
|
||||
* Named on the card: an empty field is otherwise the caller having sent nothing. */
|
||||
clearedKeys?: string[]
|
||||
/** Proposed arguments a disabled field overrode with its default. Named for the same
|
||||
* reason: the field renders locked, so the value it holds is not the proposed one. */
|
||||
resetKeys?: string[]
|
||||
/** File arguments emptied out of the proposal. Named so an empty field reads as the
|
||||
* caller's value having been removed, not as the field having none. */
|
||||
strippedKeys?: string[]
|
||||
/** Either one unmounts the form, so set exactly one, and only once the loop has
|
||||
* stopped waiting on this card. */
|
||||
submitted?: boolean
|
||||
canceled?: boolean
|
||||
/** The job exists. Distinct from `submitted`, which flips a round trip earlier — in
|
||||
* between, whether the server queued a job is unknown, so a turn stopped there is
|
||||
* recorded as neither started nor canceled. */
|
||||
started?: boolean
|
||||
}
|
||||
|
||||
/** What a run form is being filled with while it waits. Held by the chat manager, not by
|
||||
* the form, so the chat card and the preview pane edit one draft rather than two copies.
|
||||
* The schema rides along because SchemaForm binds and reorders it. */
|
||||
export type RunFormDraft = {
|
||||
args: Record<string, any>
|
||||
schema: Record<string, any>
|
||||
}
|
||||
|
||||
/** One page hit from a provider-side web search (OpenAI sources carry no title). */
|
||||
export type WebSearchSource = {
|
||||
url: string
|
||||
@@ -565,6 +612,9 @@ export type ToolDisplayMessage = {
|
||||
content: string
|
||||
parameters?: any
|
||||
result?: any
|
||||
/** What the job has streamed of its result so far, while it is still running.
|
||||
* Cleared when the job lands: `result` is then the whole of it. */
|
||||
resultStream?: string
|
||||
logs?: string
|
||||
isLoading?: boolean
|
||||
/** Arguments fully streamed but execution not started (see queuedToolStatus). */
|
||||
@@ -578,6 +628,7 @@ export type ToolDisplayMessage = {
|
||||
showFade?: boolean
|
||||
actions?: ToolDisplayAction[]
|
||||
userQuestion?: UserQuestionDisplay
|
||||
runForm?: RunFormDisplay
|
||||
webSearchSources?: WebSearchSource[]
|
||||
/** Data URL of an image the tool produced (e.g. take_screenshot), shown on the card. */
|
||||
imageUrl?: string
|
||||
@@ -653,6 +704,18 @@ export function isActiveUserQuestion(message: DisplayMessage | undefined): boole
|
||||
)
|
||||
}
|
||||
|
||||
export function isActiveRunForm(message: DisplayMessage | undefined): boolean {
|
||||
return Boolean(
|
||||
message &&
|
||||
message.role === 'tool' &&
|
||||
message.runForm &&
|
||||
message.isLoading &&
|
||||
!message.error &&
|
||||
!message.runForm.submitted &&
|
||||
!message.runForm.canceled
|
||||
)
|
||||
}
|
||||
|
||||
// The loop is parked on the user: an unanswered askUserQuestion, or a tool call
|
||||
// staged for confirmation. The manager stays `loading` through both, so anything
|
||||
// rendering progress must ask here first or it reports "the AI is working".
|
||||
@@ -676,6 +739,12 @@ export function pendingUserActionDetail(
|
||||
if (isActiveUserQuestion(message)) {
|
||||
return { action: 'question', toolCallId: message.tool_call_id }
|
||||
}
|
||||
// A run form is a confirmation carrying arguments, not a question: it parks the
|
||||
// turn the same way, but Run or Cancel resolves it and typing never does — so it
|
||||
// must not claim the answer affordance a pending question offers.
|
||||
if (isActiveRunForm(message)) {
|
||||
return { action: 'confirmation', toolCallId: message.tool_call_id }
|
||||
}
|
||||
if (message.needsConfirmation && message.isLoading) {
|
||||
return { action: 'confirmation', toolCallId: message.tool_call_id }
|
||||
}
|
||||
@@ -958,6 +1027,9 @@ export async function processToolCall<T>({
|
||||
}
|
||||
|
||||
let result = ''
|
||||
// A tool that asks for consent itself settles the call as declined or blocked and
|
||||
// returns normally, so without this both telemeter as successful runs.
|
||||
let settledInsideTool: 'declined' | 'blocked_plan_mode' | undefined = undefined
|
||||
try {
|
||||
result = await callTool({
|
||||
tools,
|
||||
@@ -965,10 +1037,17 @@ export async function processToolCall<T>({
|
||||
args,
|
||||
workspace: workspaceId,
|
||||
helpers,
|
||||
toolCallbacks,
|
||||
toolCallbacks: {
|
||||
...toolCallbacks,
|
||||
setToolStatus: (toolId, status) => {
|
||||
if (status?.declinedByUser) settledInsideTool = 'declined'
|
||||
else if (status?.blockedByPlanMode) settledInsideTool = 'blocked_plan_mode'
|
||||
toolCallbacks.setToolStatus(toolId, status)
|
||||
}
|
||||
},
|
||||
toolId: toolCall.id
|
||||
})
|
||||
logToolOutcome('ok')
|
||||
logToolOutcome(settledInsideTool ?? 'ok')
|
||||
toolCallbacks.setToolStatus(toolCall.id, {
|
||||
isLoading: false,
|
||||
isStreamingArguments: false
|
||||
@@ -1069,6 +1148,10 @@ export interface Tool<T> {
|
||||
* is true. */
|
||||
refuseInPlanMode?: (p: { args: any; helpers: T }) => ToolRejection | undefined
|
||||
requiresConfirmation?: boolean
|
||||
/** The tool's own argument form is its confirmation, and the bypass posture answers that
|
||||
* form — so no card is waited on, yet a decision is still being made for the user. The
|
||||
* list of what the posture bypasses is built from both this and `requiresConfirmation`. */
|
||||
bypassedByAutoAccept?: boolean
|
||||
/** Header shown on the confirmation card before the tool runs. Pass a function
|
||||
* to derive it from the parsed arguments (e.g. name the script being tested). */
|
||||
confirmationMessage?: string | ((args: any) => string)
|
||||
@@ -1229,6 +1312,16 @@ export interface ToolCallbacks {
|
||||
toolId: string,
|
||||
question: UserQuestionDisplay
|
||||
) => Promise<string[] | undefined>
|
||||
/** Park the loop on an argument form and resolve with the args the user submitted, or
|
||||
* undefined if they cancelled. Wired only where the form can be rendered. `autoAccepted`
|
||||
* says YOLO already answered it with what it opened with, so there is no card to wait on. */
|
||||
requestRunArgs?: (
|
||||
toolId: string,
|
||||
form: RunFormDisplay,
|
||||
opts?: { autoAccepted?: boolean }
|
||||
) => Promise<Record<string, any> | undefined>
|
||||
/** The submitted form's job is queued. Wired alongside requestRunArgs. */
|
||||
markRunFormStarted?: (toolId: string) => void
|
||||
/** Records a workspace item the tool call created/edited/deleted, by its
|
||||
* canonical (itemKind, storagePath). Session chats wire this to accumulate the
|
||||
* chat's modified-items mask; the global side-panel chat omits it (no-op). */
|
||||
@@ -1488,7 +1581,7 @@ export async function buildSchemaForTool(
|
||||
|
||||
// Constants for result formatting
|
||||
const MAX_RESULT_LENGTH = 12000
|
||||
const MAX_LOG_LENGTH = 4000
|
||||
export const MAX_LOG_LENGTH = 4000
|
||||
export const MAX_RUNNABLE_CONTENT_LENGTH = 20000
|
||||
|
||||
/** How long a test run is awaited inline before it detaches into the background
|
||||
@@ -1517,13 +1610,16 @@ export interface TestRunConfig {
|
||||
detachAfterMs?: number
|
||||
/** Human label for the jobs tray row (path / step id). Defaults to the job id. */
|
||||
label?: string
|
||||
/** Overrides the default "…test started, waiting for completion" status while the
|
||||
/** Overrides the default "…started, waiting for completion" status while the
|
||||
* job runs inline (e.g. an SQL tool shows "SQL running…"). */
|
||||
runningMessage?: string
|
||||
/** Noun for the human-facing status strings ("<X> test completed successfully").
|
||||
* Defaults to `contextName`, which also carries the jobs-tray kind and so cannot
|
||||
* always name what ran: an app's path runnable queues a flow job. */
|
||||
/** The item noun in the human-facing status strings ("Flow test completed
|
||||
* successfully"). Defaults to `contextName`, which also carries the jobs-tray kind
|
||||
* and so cannot always name what ran: an app's path runnable queues a flow job. */
|
||||
completionName?: string
|
||||
/** The action noun in those same strings ("Script run completed successfully").
|
||||
* Defaults to "test", so a tool running the deployed item for real passes "run". */
|
||||
actionNoun?: string
|
||||
/** Custom terminal formatting for the INLINE completion path (callers whose
|
||||
* result isn't a plain test-run summary, e.g. exec_datatable_sql shaping rows).
|
||||
* Returns the string handed to the model plus the tool-card patch. When omitted,
|
||||
@@ -1544,6 +1640,46 @@ export type BackgroundJobFormatter = (job: CompletedJob) => {
|
||||
card: Partial<ToolDisplayMessage>
|
||||
}
|
||||
|
||||
/** Reads a running job's output incrementally through `getJobUpdates`, the only endpoint
|
||||
* carrying `new_result_stream` — `getJob` returns logs but never the partial result. Each
|
||||
* reader keeps its own offsets, so one starting over refetches from zero. Best-effort: a
|
||||
* failed poll answers `undefined` and mutates nothing, so the next resumes from the same
|
||||
* offsets and a run always lands on `getJob` alone. */
|
||||
export function createJobUpdateReader(jobId: string, workspace: string) {
|
||||
let logs = ''
|
||||
let resultStream = ''
|
||||
let logOffset = 0
|
||||
let streamOffset = 0
|
||||
let started = false
|
||||
return {
|
||||
async poll(): Promise<{ completed: boolean; logs: string; resultStream: string } | undefined> {
|
||||
let update: Awaited<ReturnType<typeof JobService.getJobUpdates>>
|
||||
try {
|
||||
update = await JobService.getJobUpdates({
|
||||
workspace,
|
||||
id: jobId,
|
||||
running: started,
|
||||
logOffset,
|
||||
streamOffset
|
||||
})
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
started ||= update.running ?? false
|
||||
// Both kept as a tail: the offsets come from the server, so dropping the head
|
||||
// costs nothing here, and neither is the record of the run — the logs are on the
|
||||
// job, and a streamed partial is replaced by the result the moment it lands.
|
||||
if (update.new_logs) logs = (logs + update.new_logs).slice(-MAX_LOG_LENGTH)
|
||||
if (update.new_result_stream) {
|
||||
resultStream = (resultStream + update.new_result_stream).slice(-MAX_LOG_LENGTH)
|
||||
}
|
||||
if (update.log_offset) logOffset = update.log_offset
|
||||
if (update.stream_offset) streamOffset = update.stream_offset
|
||||
return { completed: update.completed ?? false, logs, resultStream }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Common job polling function.
|
||||
//
|
||||
// Two modes, selected by whether `detachAfterMs` is provided:
|
||||
@@ -1563,24 +1699,54 @@ export async function pollJobCompletion(
|
||||
const maxAttempts = detachEnabled ? Math.ceil((options?.detachAfterMs ?? 0) / 1000) : 60
|
||||
let attempts = 0
|
||||
let job: CompletedJob | null = null
|
||||
const reader = createJobUpdateReader(jobId, workspace)
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
attempts++
|
||||
|
||||
try {
|
||||
const update = await reader.poll()
|
||||
// The tray's snapshot is trimmed of logs (it is persisted), so the card is the
|
||||
// only place a running job's output can land. Cards that hide their logs while
|
||||
// loading are unaffected; the run card follows them line by line.
|
||||
if (update) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
logs: formatLogs(update.logs),
|
||||
resultStream: update.resultStream || undefined
|
||||
})
|
||||
}
|
||||
|
||||
// Ask for the logs when the run may be over — the tail written between the last
|
||||
// poll and the end is only on the job itself — or when there is no reader output
|
||||
// to have collected them.
|
||||
const wantLogs = !update || update.completed
|
||||
const fetchedJob = await JobService.getJob({
|
||||
workspace: workspace,
|
||||
id: jobId,
|
||||
noLogs: false,
|
||||
noLogs: !wantLogs,
|
||||
noCode: true
|
||||
})
|
||||
|
||||
if (fetchedJob.type === 'CompletedJob') {
|
||||
job = fetchedJob
|
||||
// The updates can still call a landed job unfinished, so a completion seen on
|
||||
// a logless fetch is fetched again rather than settled without them: the model
|
||||
// reads these logs, and their absence is indistinguishable from a silent run.
|
||||
job = wantLogs
|
||||
? fetchedJob
|
||||
: ((await JobService.getJob({
|
||||
workspace: workspace,
|
||||
id: jobId,
|
||||
noLogs: false,
|
||||
noCode: true
|
||||
})) as CompletedJob)
|
||||
break
|
||||
}
|
||||
// Keep the tray's status + Job snapshot fresh during the inline wait.
|
||||
// With no reader, this is the only place the card's logs can come from.
|
||||
if (!update) {
|
||||
toolCallbacks.setToolStatus(toolId, { logs: formatLogs(fetchedJob.logs) })
|
||||
}
|
||||
// The badge needs the real Job to tell running from suspended or scheduled, which
|
||||
// the updates do not say.
|
||||
toolCallbacks.onJobStatus?.(jobId, {
|
||||
status: deriveChatJobStatus(fetchedJob),
|
||||
job: trimJob(fetchedJob)
|
||||
@@ -1694,14 +1860,22 @@ function backgroundedSummary(jobId: string, label: string): string {
|
||||
// fills its card the same way one that finished inline does.
|
||||
export function completedJobToolStatus(job: CompletedJob): Partial<ToolDisplayMessage> {
|
||||
// A canceled job isn't a `success`, but it isn't a failure either — the user
|
||||
// stopped it — so don't dress the card as an error.
|
||||
// stopped it — so don't dress the card as an error. It still has the result the run
|
||||
// page shows for a canceled run, which names who stopped it, so keep that.
|
||||
if (job.canceled) {
|
||||
return { content: 'Background job canceled', logs: formatLogs(job.logs) }
|
||||
return {
|
||||
content: 'Background job canceled',
|
||||
result: formatResult(job.result),
|
||||
logs: formatLogs(job.logs),
|
||||
resultStream: undefined
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: `Background job ${job.success ? 'completed successfully' : 'failed'}`,
|
||||
result: formatResult(job.result),
|
||||
logs: formatLogs(job.logs),
|
||||
// The partial is the result now, so nothing streamed is kept beside it.
|
||||
resultStream: undefined,
|
||||
...(job.success ? {} : { error: getErrorMessage(job.result) })
|
||||
}
|
||||
}
|
||||
@@ -1731,12 +1905,15 @@ export function backgroundJobCompletionNote(
|
||||
)
|
||||
}
|
||||
|
||||
// Main execution function for test runs
|
||||
export async function executeTestRun(config: TestRunConfig): Promise<string> {
|
||||
// Detach-into-background is enabled only when the host wired the job hooks
|
||||
// (global/sessions chat). Otherwise this stays a blocking call.
|
||||
const detachEnabled = !!config.toolCallbacks.onJobStarted
|
||||
const label = config.label ?? config.contextName
|
||||
const actionNoun = config.actionNoun ?? 'test'
|
||||
// Stands on its own where the status strings are prefixed by the item, so its
|
||||
// default carries the noun.
|
||||
const failureNoun = config.actionNoun ?? 'test run'
|
||||
try {
|
||||
config.toolCallbacks.setToolStatus(config.toolId, {
|
||||
content: config.startMessage || `Starting ${config.contextName} test...`
|
||||
@@ -1761,7 +1938,8 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
|
||||
})
|
||||
|
||||
config.toolCallbacks.setToolStatus(config.toolId, {
|
||||
content: config.runningMessage ?? `${contextName} test started, waiting for completion...`
|
||||
content:
|
||||
config.runningMessage ?? `${contextName} ${actionNoun} started, waiting for completion...`
|
||||
})
|
||||
|
||||
const outcome = await pollJobCompletion(
|
||||
@@ -1781,7 +1959,7 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
|
||||
if (outcome === 'detached') {
|
||||
config.toolCallbacks.onJobDetached?.(jobId)
|
||||
config.toolCallbacks.setToolStatus(config.toolId, {
|
||||
content: `${contextName} test running in background (job ${jobId})`
|
||||
content: `${contextName} ${actionNoun} running in background (job ${jobId})`
|
||||
})
|
||||
return backgroundedSummary(jobId, label)
|
||||
}
|
||||
@@ -1800,9 +1978,12 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
|
||||
}
|
||||
|
||||
config.toolCallbacks.setToolStatus(config.toolId, {
|
||||
content: `${contextName} test ${job.success ? 'completed successfully' : 'failed'}`,
|
||||
content: `${contextName} ${actionNoun} ${job.success ? 'completed successfully' : 'failed'}`,
|
||||
result: formatResult(job.result),
|
||||
logs: formatLogs(job.logs),
|
||||
// The partial is the result now, so the card reads it off `result` alone and the
|
||||
// transcript stops carrying a second copy of a streamed answer.
|
||||
resultStream: undefined,
|
||||
...(job.success ? {} : { error: getErrorMessage(job.result) })
|
||||
})
|
||||
|
||||
@@ -1823,10 +2004,10 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
|
||||
// the flow it could not find — losing the one diagnostic the run exists for.
|
||||
const errorMessage = formatToolError(error)
|
||||
config.toolCallbacks.setToolStatus(config.toolId, {
|
||||
content: `Test execution failed`,
|
||||
content: `Execution failed`,
|
||||
error: errorMessage
|
||||
})
|
||||
throw new Error(`Failed to execute test run: ${errorMessage}`)
|
||||
throw new Error(`Failed to execute ${failureNoun}: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ export type FlowBuilderWhitelabelCustomUi = {
|
||||
export?: boolean
|
||||
history?: boolean
|
||||
aiBuilder?: boolean
|
||||
tutorials?: boolean
|
||||
diff?: boolean
|
||||
extraDeployOptions?: boolean
|
||||
editableSummary?: boolean
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
interface Props {
|
||||
loading: boolean
|
||||
disableStaticInputs?: boolean
|
||||
disableTutorials?: boolean
|
||||
disableAi?: boolean
|
||||
disableSettings?: boolean
|
||||
disabledFlowInputs?: boolean
|
||||
@@ -99,7 +98,6 @@
|
||||
let {
|
||||
loading,
|
||||
disableStaticInputs = false,
|
||||
disableTutorials = false,
|
||||
disableAi = false,
|
||||
disableSettings = false,
|
||||
disabledFlowInputs = false,
|
||||
@@ -391,7 +389,6 @@
|
||||
bind:this={flowModuleSchemaMap}
|
||||
controlsPosition={compactGraphOverlay ? 'bottom' : 'top'}
|
||||
{disableStaticInputs}
|
||||
{disableTutorials}
|
||||
{disableAi}
|
||||
{disableSettings}
|
||||
{smallErrorHandler}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import FlowTutorials from '../FlowTutorials.svelte'
|
||||
import { BookOpen, CheckCircle, Circle, RefreshCw, CheckCheck } from 'lucide-svelte'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
|
||||
import { tutorialsToDo } from '$lib/stores'
|
||||
import { getTutorialIndex } from '$lib/tutorials/config'
|
||||
|
||||
let flowTutorials: FlowTutorials | undefined = $state(undefined)
|
||||
|
||||
async function getTutorialItems() {
|
||||
return [
|
||||
{
|
||||
displayName: 'Build a flow',
|
||||
action: () => flowTutorials?.runTutorialById('flow-live-tutorial'),
|
||||
index: getTutorialIndex('flow-live-tutorial'),
|
||||
icon: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) ? Circle : CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) ? undefined : 'green'
|
||||
},
|
||||
{
|
||||
displayName: 'Fix a broken flow',
|
||||
action: () => flowTutorials?.runTutorialById('troubleshoot-flow'),
|
||||
index: getTutorialIndex('troubleshoot-flow'),
|
||||
icon: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow')) ? Circle : CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow')) ? undefined : 'green'
|
||||
},
|
||||
{
|
||||
displayName: 'Reset tutorials',
|
||||
action: () => resetAllTodos(),
|
||||
icon: RefreshCw
|
||||
},
|
||||
{
|
||||
displayName: 'Skip tutorials',
|
||||
action: () => skipAllTodos(),
|
||||
icon: CheckCheck
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key $tutorialsToDo}
|
||||
<Dropdown items={getTutorialItems}>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: BookOpen }}
|
||||
/>
|
||||
{/snippet}
|
||||
</Dropdown>
|
||||
{/key}
|
||||
|
||||
<FlowTutorials
|
||||
bind:this={flowTutorials}
|
||||
on:reload
|
||||
on:error
|
||||
on:skipAll
|
||||
/>
|
||||
|
||||
@@ -128,7 +128,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Index 0 is used by the tutorial to identify the first "Add step" -->
|
||||
<InsertModulePopover
|
||||
{disableAi}
|
||||
placement={'bottom'}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
|
||||
import { locateModules, groupByParent } from '../multiSelectUtils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import FlowTutorials from '$lib/components/FlowTutorials.svelte'
|
||||
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
|
||||
import { replaceId } from '../flowStore.svelte'
|
||||
import { setScheduledPollSchedule, type TriggerContext } from '$lib/components/triggers'
|
||||
@@ -55,7 +54,6 @@
|
||||
interface Props {
|
||||
sidebarSize?: number | undefined
|
||||
disableStaticInputs?: boolean
|
||||
disableTutorials?: boolean
|
||||
disableAi?: boolean
|
||||
disableSettings?: boolean
|
||||
newFlow?: boolean
|
||||
@@ -86,7 +84,6 @@
|
||||
let {
|
||||
sidebarSize = $bindable(undefined),
|
||||
disableStaticInputs = false,
|
||||
disableTutorials = false,
|
||||
disableAi = false,
|
||||
disableSettings = false,
|
||||
newFlow = false,
|
||||
@@ -888,7 +885,3 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !disableTutorials}
|
||||
<FlowTutorials on:reload />
|
||||
{/if}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
Loader2,
|
||||
Workflow,
|
||||
Import,
|
||||
Store,
|
||||
PanelLeftClose
|
||||
} from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
@@ -26,6 +27,25 @@
|
||||
import { conditionalMelt, getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import { createDropdownMenu, melt } from '@melt-ui/svelte'
|
||||
import YAML from 'yaml'
|
||||
import type { Snippet } from 'svelte'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
|
||||
interface Props {
|
||||
/** Replaces the default `New` button, e.g. with an inline text link. */
|
||||
trigger?: Snippet
|
||||
/** The node `trigger` renders: what the menu anchors to and what opens it. */
|
||||
triggerElement?: HTMLElement
|
||||
/** Which entry point this menu hangs off, for telemetry. */
|
||||
source?: 'toolbar' | 'empty_state'
|
||||
/**
|
||||
* Opens the hub project picker. The menu only offers the entry; the picker and the
|
||||
* import dialog belong to the host, which is the one place a single import modal can
|
||||
* serve both this menu and the empty state's own link.
|
||||
*/
|
||||
onImportHubProject?: () => void
|
||||
}
|
||||
|
||||
let { trigger, triggerElement, source = 'toolbar', onImportHubProject }: Props = $props()
|
||||
|
||||
type Variant = {
|
||||
label: string
|
||||
@@ -228,8 +248,15 @@
|
||||
}
|
||||
|
||||
let activeKey = $state(allOptions[0]?.key)
|
||||
// every option's import action, surfaced together under the bottom "Import" submenu
|
||||
const importActions: Extra[] = allOptions.flatMap((o) => o.extras ?? [])
|
||||
// every option's import action, surfaced together under the bottom "Import" submenu.
|
||||
// The hub project leads and is separated below: the others each paste one artifact the
|
||||
// user already holds, while this one brings a whole project in from somewhere else.
|
||||
const importActions: Extra[] = $derived([
|
||||
...(onImportHubProject
|
||||
? [{ label: 'Import a hub project', onSelect: onImportHubProject }]
|
||||
: []),
|
||||
...allOptions.flatMap((o) => o.extras ?? [])
|
||||
])
|
||||
|
||||
// melt dropdown menu: arrow-key nav, typeahead, focus management and outside/escape
|
||||
// close all come for free; we only drive the doc panel off the highlighted item.
|
||||
@@ -306,7 +333,7 @@
|
||||
// styling — melt element stores are callable on a node, exactly like `use:melt`.
|
||||
let triggerEl: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined)
|
||||
$effect(() => {
|
||||
const el = triggerEl
|
||||
const el = triggerElement ?? triggerEl
|
||||
if (!el) return
|
||||
const applied = conditionalMelt(el, menuTrigger as any) as {
|
||||
destroy?: () => void
|
||||
@@ -314,6 +341,18 @@
|
||||
return applied?.destroy
|
||||
})
|
||||
|
||||
// Which entry point people actually create from: the toolbar button, or the inline
|
||||
// link in the empty state. Only the open edge counts — melt writes the store on
|
||||
// close and on every re-render of the menu.
|
||||
let wasOpen = false
|
||||
$effect(() => {
|
||||
const isOpen = $open
|
||||
if (isOpen && !wasOpen) {
|
||||
logFeatureUsage('home', 'new_menu_open', { key: source })
|
||||
}
|
||||
wasOpen = isOpen
|
||||
})
|
||||
|
||||
const SHOW_DOC_SETTING = 'home_create_show_doc'
|
||||
let showDoc = $state(getLocalSetting(SHOW_DOC_SETTING) !== 'false')
|
||||
function setShowDoc(value: boolean) {
|
||||
@@ -366,191 +405,204 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
{...$menuTrigger}
|
||||
id="create-new-button"
|
||||
aiId="home-create-new"
|
||||
aiDescription="Create a new script, flow or app"
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
bind:element={triggerEl}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
|
||||
{#if $open && active}
|
||||
<div
|
||||
use:melt={$menu}
|
||||
data-arrow-loop
|
||||
class="z-[6000] flex flex-row rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
|
||||
style={showDoc ? 'width: 780px;' : ''}
|
||||
<!-- `contents` so a custom inline trigger (the empty state's text link) keeps flowing
|
||||
with the sentence around it instead of becoming a block of its own. -->
|
||||
<div class={trigger ? 'contents' : ''}>
|
||||
{#if trigger}
|
||||
{@render trigger()}
|
||||
{:else}
|
||||
<Button
|
||||
{...$menuTrigger}
|
||||
id="create-new-button"
|
||||
aiId="home-create-new"
|
||||
aiDescription="Create a new script, flow or app"
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
bind:element={triggerEl}
|
||||
>
|
||||
{#if showDoc}
|
||||
<!-- explanation of the highlighted editor -->
|
||||
<div class="flex flex-col gap-3 p-5 flex-1 min-w-0">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 {activeAc.tile}"
|
||||
>
|
||||
<active.icon size={26} class={activeAc.iconText} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h3 class="font-semibold text-primary leading-tight">{active.label}</h3>
|
||||
{#if active.badge}
|
||||
<span
|
||||
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {active
|
||||
.badge.class}"
|
||||
>
|
||||
{active.badge.label}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-tertiary">{active.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
New
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-secondary leading-relaxed">{active.description}</p>
|
||||
|
||||
<ul class="flex flex-col gap-1.5 mt-1">
|
||||
{#each active.bullets as bullet (bullet)}
|
||||
<li class="flex flex-row items-center gap-2 text-xs text-secondary">
|
||||
<ChevronRight size={14} class={activeAc.iconText} />
|
||||
{bullet}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
class="mt-auto self-start inline-flex items-center gap-1 pt-2 text-[10px] text-tertiary hover:text-secondary transition-colors"
|
||||
title="Hide descriptions"
|
||||
tabindex={-1}
|
||||
onclick={() => setShowDoc(false)}
|
||||
{#if $open && active}
|
||||
<div
|
||||
use:melt={$menu}
|
||||
data-arrow-loop
|
||||
class="z-[6000] flex flex-row rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
|
||||
style={showDoc ? 'width: 780px;' : ''}
|
||||
>
|
||||
{#if showDoc}
|
||||
<!-- explanation of the highlighted editor -->
|
||||
<div class="flex flex-col gap-3 p-5 flex-1 min-w-0">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 {activeAc.tile}"
|
||||
>
|
||||
<PanelLeftClose size={12} />
|
||||
Hide descriptions
|
||||
<active.icon size={26} class={activeAc.iconText} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h3 class="font-semibold text-primary leading-tight">{active.label}</h3>
|
||||
{#if active.badge}
|
||||
<span
|
||||
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {active
|
||||
.badge.class}"
|
||||
>
|
||||
{active.badge.label}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-tertiary">{active.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-secondary leading-relaxed">{active.description}</p>
|
||||
|
||||
<ul class="flex flex-col gap-1.5 mt-1">
|
||||
{#each active.bullets as bullet (bullet)}
|
||||
<li class="flex flex-row items-center gap-2 text-xs text-secondary">
|
||||
<ChevronRight size={14} class={activeAc.iconText} />
|
||||
{bullet}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
class="mt-auto self-start inline-flex items-center gap-1 pt-2 text-[10px] text-tertiary hover:text-secondary transition-colors"
|
||||
title="Hide descriptions"
|
||||
tabindex={-1}
|
||||
onclick={() => setShowDoc(false)}
|
||||
>
|
||||
<PanelLeftClose size={12} />
|
||||
Hide descriptions
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- option list -->
|
||||
<div class="flex flex-col gap-0.5 p-2 w-[18rem] shrink-0">
|
||||
{#snippet rowBody(option: Option, ac: (typeof accentClasses)[string])}
|
||||
<div class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 {ac.tile}">
|
||||
<option.icon size={14} class={ac.iconText} />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap">
|
||||
{option.label}
|
||||
</span>
|
||||
{#if option.badge}
|
||||
<span
|
||||
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {option
|
||||
.badge.class}"
|
||||
>
|
||||
{option.badge.label}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#each allOptions as option (option.key)}
|
||||
{@const ac = accentClasses[option.accent]}
|
||||
{@const rowClass =
|
||||
'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'}
|
||||
{#if option.variants}
|
||||
<button
|
||||
use:melt={$wacSubTrigger}
|
||||
class={rowClass}
|
||||
onfocusin={() => (activeKey = option.key)}
|
||||
onpointerenter={() => (activeKey = option.key)}
|
||||
>
|
||||
{@render rowBody(option, ac)}
|
||||
<ChevronRight size={14} class="shrink-0 text-tertiary" />
|
||||
</button>
|
||||
{#if $wacSubOpen}
|
||||
<div
|
||||
use:melt={$wacSubMenu}
|
||||
use:hugViewportRight
|
||||
class="z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
|
||||
>
|
||||
{#each option.variants ?? [] as variant (variant.label)}
|
||||
{@const VariantIcon = variant.icon}
|
||||
<button
|
||||
use:melt={$item}
|
||||
class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
|
||||
onclick={() => variant.onSelect()}
|
||||
>
|
||||
<VariantIcon width={14} height={14} />
|
||||
<span class="text-xs font-medium text-primary">{variant.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
use:melt={$item}
|
||||
class={rowClass}
|
||||
onfocusin={() => (activeKey = option.key)}
|
||||
onpointerenter={() => (activeKey = option.key)}
|
||||
onclick={() => option.onSelect()}
|
||||
>
|
||||
{@render rowBody(option, ac)}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- bottom import section: one entry whose submenu imports any artifact -->
|
||||
<div class="mx-1 my-1 border-t border-gray-200 dark:border-gray-700"></div>
|
||||
<button
|
||||
use:melt={$importSubTrigger}
|
||||
class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 bg-gray-100 dark:bg-gray-700"
|
||||
>
|
||||
<Import size={14} class="text-gray-600 dark:text-gray-300" />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap">
|
||||
Import
|
||||
</span>
|
||||
<ChevronRight size={14} class="shrink-0 text-tertiary" />
|
||||
</button>
|
||||
{#if $importSubOpen}
|
||||
<div
|
||||
use:melt={$importSubMenu}
|
||||
use:hugViewportRight
|
||||
class="z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
|
||||
>
|
||||
{#each importActions as action, i (action.label)}
|
||||
<button
|
||||
use:melt={$item}
|
||||
class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
|
||||
onclick={() => action.onSelect()}
|
||||
>
|
||||
{#if onImportHubProject && i === 0}
|
||||
<Store size={14} class="shrink-0 text-tertiary" />
|
||||
{:else}
|
||||
<Import size={14} class="shrink-0 text-tertiary" />
|
||||
{/if}
|
||||
<span class="text-xs font-medium text-primary whitespace-nowrap">
|
||||
{action.label}
|
||||
</span>
|
||||
</button>
|
||||
{#if onImportHubProject && i === 0}
|
||||
<div class="mx-1 my-0.5 border-t border-gray-200 dark:border-gray-700"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- option list -->
|
||||
<div class="flex flex-col gap-0.5 p-2 w-[18rem] shrink-0">
|
||||
{#snippet rowBody(option: Option, ac: (typeof accentClasses)[string])}
|
||||
<div class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 {ac.tile}">
|
||||
<option.icon size={14} class={ac.iconText} />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap">
|
||||
{option.label}
|
||||
</span>
|
||||
{#if option.badge}
|
||||
<span
|
||||
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {option
|
||||
.badge.class}"
|
||||
>
|
||||
{option.badge.label}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#each allOptions as option (option.key)}
|
||||
{@const ac = accentClasses[option.accent]}
|
||||
{@const rowClass =
|
||||
'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'}
|
||||
{#if option.variants}
|
||||
<button
|
||||
use:melt={$wacSubTrigger}
|
||||
class={rowClass}
|
||||
onfocusin={() => (activeKey = option.key)}
|
||||
onpointerenter={() => (activeKey = option.key)}
|
||||
>
|
||||
{@render rowBody(option, ac)}
|
||||
<ChevronRight size={14} class="shrink-0 text-tertiary" />
|
||||
</button>
|
||||
{#if $wacSubOpen}
|
||||
<div
|
||||
use:melt={$wacSubMenu}
|
||||
use:hugViewportRight
|
||||
class="z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
|
||||
>
|
||||
{#each option.variants ?? [] as variant (variant.label)}
|
||||
{@const VariantIcon = variant.icon}
|
||||
<button
|
||||
use:melt={$item}
|
||||
class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
|
||||
onclick={() => variant.onSelect()}
|
||||
>
|
||||
<VariantIcon width={14} height={14} />
|
||||
<span class="text-xs font-medium text-primary">{variant.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
use:melt={$item}
|
||||
class={rowClass}
|
||||
onfocusin={() => (activeKey = option.key)}
|
||||
onpointerenter={() => (activeKey = option.key)}
|
||||
onclick={() => option.onSelect()}
|
||||
>
|
||||
{@render rowBody(option, ac)}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- bottom import section: one entry whose submenu imports any artifact -->
|
||||
<div class="mx-1 my-1 border-t border-gray-200 dark:border-gray-700"></div>
|
||||
{#if !showDoc}
|
||||
<button
|
||||
use:melt={$importSubTrigger}
|
||||
class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
|
||||
class="mt-1 px-2 py-1 text-left text-[10px] text-tertiary/70 hover:text-tertiary hover:underline transition-colors"
|
||||
title="Show descriptions"
|
||||
tabindex={-1}
|
||||
onclick={() => setShowDoc(true)}
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 bg-gray-100 dark:bg-gray-700"
|
||||
>
|
||||
<Import size={14} class="text-gray-600 dark:text-gray-300" />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap">
|
||||
Import
|
||||
</span>
|
||||
<ChevronRight size={14} class="shrink-0 text-tertiary" />
|
||||
Show descriptions
|
||||
</button>
|
||||
{#if $importSubOpen}
|
||||
<div
|
||||
use:melt={$importSubMenu}
|
||||
use:hugViewportRight
|
||||
class="z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
|
||||
>
|
||||
{#each importActions as action (action.label)}
|
||||
<button
|
||||
use:melt={$item}
|
||||
class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
|
||||
onclick={() => action.onSelect()}
|
||||
>
|
||||
<Import size={14} class="shrink-0 text-tertiary" />
|
||||
<span class="text-xs font-medium text-primary whitespace-nowrap">
|
||||
{action.label}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !showDoc}
|
||||
<button
|
||||
class="mt-1 px-2 py-1 text-left text-[10px] text-tertiary/70 hover:text-tertiary hover:underline transition-colors"
|
||||
title="Show descriptions"
|
||||
tabindex={-1}
|
||||
onclick={() => setShowDoc(true)}
|
||||
>
|
||||
Show descriptions
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- shared import drawer (YAML / JSON) for the bottom "Import" submenu actions -->
|
||||
<Drawer bind:this={importDrawer} size="800px">
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import HubTemplatePicker from './HubTemplatePicker.svelte'
|
||||
import type { HubProjectPick } from '$lib/hubProject'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** A project was chosen. The host closes this and opens the import dialog on it. */
|
||||
onPick: (project: HubProjectPick) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
let { open, onPick, onClose }: Props = $props()
|
||||
|
||||
// Bound, not one-way, for the same reason the import dialog binds it: `Modal` dispatches
|
||||
// `confirmed`/`canceled` only, so the X, Escape and the backdrop are visible to the caller
|
||||
// through this value and nowhere else.
|
||||
let modalOpen = $state(false)
|
||||
let wasOpen = false
|
||||
$effect(() => {
|
||||
const shouldBeOpen = open
|
||||
if (shouldBeOpen !== untrack(() => modalOpen)) {
|
||||
modalOpen = shouldBeOpen
|
||||
if (shouldBeOpen) logFeatureUsage('home', 'template_picker_open', { key: 'new_menu' })
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
const isOpen = modalOpen
|
||||
untrack(() => {
|
||||
if (!isOpen && wasOpen) onClose()
|
||||
wasOpen = isOpen
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- A dialog rather than the empty state's popover: this one opens from an item inside an
|
||||
already-open dropdown, and a popover anchored there leaves two melt layers arguing over
|
||||
focus and dismissal. The height is fixed so the list inside has a definite box to page in,
|
||||
the same requirement the popover meets with `fitViewport`. -->
|
||||
<!-- `kind="X"`: picking a card is the action, so a Cancel button under the list would be the
|
||||
only thing in the dialog that looks like one. -->
|
||||
<Modal bind:open={modalOpen} kind="X" title="Import a hub project" class="sm:!max-w-[560px]">
|
||||
<div class="flex h-[min(70vh,520px)] flex-col">
|
||||
<HubTemplatePicker fullWidth {onPick} />
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { ArrowRight, ArrowUpRight, LayoutGrid, Star } from 'lucide-svelte'
|
||||
import InfiniteList from '$lib/components/InfiniteList.svelte'
|
||||
import {
|
||||
hubAppIcon,
|
||||
hubBrowserUrl,
|
||||
hubProjectCatalogue,
|
||||
type HubProjectPick
|
||||
} from '$lib/hubProject'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
interface Props {
|
||||
onPick: (project: HubProjectPick) => void
|
||||
/** Take the width given instead of the popover's own, for a host that sets one. */
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
let { onPick, fullWidth = false }: Props = $props()
|
||||
|
||||
let list: InfiniteList | undefined = $state(undefined)
|
||||
|
||||
// The hub serves its whole catalogue in one response, so paging happens here: the list
|
||||
// asks for a window and gets a slice of what `hubProjectCatalogue` already holds. Should
|
||||
// the hub ever paginate, only this loader changes.
|
||||
// `InfiniteList` shows its `empty` snippet for a list that came back empty and for one that
|
||||
// failed, so the loader records which happened: a reachable hub that has published nothing
|
||||
// is not a hub that could not be reached.
|
||||
let loadFailed = $state(false)
|
||||
|
||||
// Armed once per workspace, and inside `untrack`: `setLoader` loads immediately, and both
|
||||
// it and `loadData` read the list's own reactive state as well as writing it — called
|
||||
// tracked, this effect depends on what the load changes and re-runs itself. An empty
|
||||
// catalogue never settles that cycle, which spins the tab at full CPU.
|
||||
let loadedFor: string | undefined = undefined
|
||||
$effect(() => {
|
||||
const workspace = $workspaceStore
|
||||
if (!list || !workspace || loadedFor === workspace) return
|
||||
loadedFor = workspace
|
||||
untrack(() => {
|
||||
list?.setLoader(async (page: number, perPage: number) => {
|
||||
try {
|
||||
const all = await hubProjectCatalogue(workspace)
|
||||
loadFailed = false
|
||||
return all.slice((page - 1) * perPage, page * perPage)
|
||||
} catch (error) {
|
||||
loadFailed = true
|
||||
throw error
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
let hubUrl = $state('https://hub.windmill.dev')
|
||||
void hubBrowserUrl()
|
||||
.then((u) => (hubUrl = u))
|
||||
.catch(() => {})
|
||||
// `hubBrowserUrl` hands back the instance setting as the admin wrote it, so a scheme-less
|
||||
// one would make `new URL` throw — in render, which takes the popover down with it.
|
||||
let hubHost = $derived.by(() => {
|
||||
try {
|
||||
return new URL(hubUrl).host
|
||||
} catch {
|
||||
return hubUrl
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- The popover gives this box a definite height; the list takes what the header leaves and
|
||||
scrolls inside it, which is also what lets it page. -->
|
||||
<div class="flex min-h-0 flex-col {fullWidth ? 'w-full flex-1' : 'w-[380px]'}">
|
||||
<!-- The hub is named once, as the link to it: a footer row saying the same thing again is
|
||||
a second line spent on somewhere the reader is not going. -->
|
||||
<p class="px-3 pb-2 pt-3 text-[11.5px] leading-snug text-hint">
|
||||
Working projects from
|
||||
<a
|
||||
href="{hubUrl}/projects"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="inline-flex items-baseline gap-0.5 text-secondary hover:text-emphasis hover:underline"
|
||||
>
|
||||
{hubHost}<ArrowUpRight size={11} class="self-center" />
|
||||
</a>
|
||||
— imported as a folder in this workspace.
|
||||
</p>
|
||||
|
||||
<div class="min-h-0 flex-1 border-t border-border-light">
|
||||
<!-- The height comes from the flex chain above: DataTable's own container is `h-full`, so
|
||||
the scroll box inside it is bounded, which is what lets the list page. -->
|
||||
<InfiniteList bind:this={list} noBorder rounded={false}>
|
||||
{#snippet customRow({ item }: { item: HubProjectPick })}
|
||||
{@const Icon = hubAppIcon(item.iconApps[0] ?? '')}
|
||||
<tr>
|
||||
<td class="p-0">
|
||||
<button
|
||||
class="group flex w-full items-start gap-3 border-b border-border-light px-3 py-2.5 text-left hover:bg-surface-hover"
|
||||
onclick={() => onPick(item)}
|
||||
>
|
||||
<div class="flex size-[22px] shrink-0 items-center justify-center">
|
||||
{#if item.logoUrl}
|
||||
<img src={item.logoUrl} alt="" class="max-h-[22px] max-w-[22px] object-contain" />
|
||||
{:else if Icon}
|
||||
<Icon size={20} />
|
||||
{:else}
|
||||
<LayoutGrid size={18} class="text-tertiary" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="truncate text-xs font-semibold text-emphasis">{item.name}</span>
|
||||
{#if item.stars > 0}
|
||||
<span
|
||||
class="flex shrink-0 items-center gap-0.5 text-2xs font-normal text-tertiary"
|
||||
>
|
||||
<Star size={11} />{item.stars}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- The arrow is the only thing that appears on hover: the whole row is the
|
||||
control, so it says where the row goes rather than acting as a button. -->
|
||||
<ArrowRight
|
||||
size={13}
|
||||
class="shrink-0 text-tertiary opacity-0 transition group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<!-- What the project is, in its own words. The item counts it used to carry say
|
||||
nothing about whether this is the project you want; the wizard shows them on
|
||||
the step where they matter, right before the import runs. -->
|
||||
<p
|
||||
class="mt-1 line-clamp-4 text-[11.5px] font-normal leading-relaxed text-secondary"
|
||||
>
|
||||
{item.description || item.summary}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/snippet}
|
||||
|
||||
{#snippet empty()}
|
||||
<p class="px-3 py-6 text-xs text-secondary">
|
||||
{#if loadFailed}
|
||||
Could not reach the hub. You can still browse its projects in a new tab.
|
||||
{:else}
|
||||
This hub has no projects yet.
|
||||
{/if}
|
||||
</p>
|
||||
{/snippet}
|
||||
</InfiniteList>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,363 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { watch } from 'runed'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import ImportWizardSteps from '$lib/components/ImportWizardSteps.svelte'
|
||||
import ImportProjectStep from '$lib/components/ImportProjectStep.svelte'
|
||||
import ImportSetupStep from '$lib/components/ImportSetupStep.svelte'
|
||||
import ImportProjectCard, {
|
||||
type ImportProjectSummary
|
||||
} from '$lib/components/ImportProjectCard.svelte'
|
||||
import { fetchHubProject, hubBrowserUrl, type HubProjectPick } from '$lib/hubProject'
|
||||
import type { ImportExecution } from '$lib/importWizard/execution.svelte'
|
||||
import { useSetupStep } from '$lib/importWizard/setupStep.svelte'
|
||||
import type { ImportPlan } from '$lib/importWizard/plan'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { hubProjectUsageKey, logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
/**
|
||||
* Writes a hub project's items into the active workspace. It checks no permission itself,
|
||||
* so a caller must not offer it to an operator or in a workspace whose direct-deploy
|
||||
* protection has cleared `showEditButtons` — `ItemsList` gates both of its entry points on
|
||||
* exactly that, and the import would otherwise fail item by item against the server's own
|
||||
* checks, after the dialog had promised to run.
|
||||
*/
|
||||
interface Props {
|
||||
/** The project the picker chose. Setting it opens the dialog. */
|
||||
pick: HubProjectPick | undefined
|
||||
onClose: () => void
|
||||
/** The import landed: the caller reloads its list, which replaces the empty state. */
|
||||
onImported?: () => void
|
||||
}
|
||||
|
||||
let { pick, onClose, onImported }: Props = $props()
|
||||
|
||||
let slug = $derived(pick?.slug)
|
||||
|
||||
// Bound, not one-way: the dialog closes itself on the X, Escape and the backdrop, and
|
||||
// `Modal` reports none of those — it dispatches `confirmed`/`canceled` only. Left unbound,
|
||||
// a dismissal hid the dialog while `pick` still held the project, so re-picking the same
|
||||
// one did nothing, and a run in flight kept writing with no UI in front of it.
|
||||
let modalOpen = $state(false)
|
||||
let wasOpen = false
|
||||
$effect(() => {
|
||||
const shouldBeOpen = pick !== undefined
|
||||
if (shouldBeOpen !== untrack(() => modalOpen)) modalOpen = shouldBeOpen
|
||||
})
|
||||
$effect(() => {
|
||||
const isOpen = modalOpen
|
||||
untrack(() => {
|
||||
if (!isOpen && wasOpen) dismiss()
|
||||
wasOpen = isOpen
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Dismissed rather than finished. A run in flight is stopped at the next phase boundary —
|
||||
* nothing can abort a request already sent — and what it has already written stays: a
|
||||
* second run asks the workspace what it holds, so reopening the project carries on from
|
||||
* there rather than importing twice.
|
||||
*/
|
||||
function dismiss() {
|
||||
// Where they left, which is the half of the funnel Finish cannot report: `running`
|
||||
// walked out on an import in progress, `setup` on the credentials it asked for,
|
||||
// `done` closed a landed import instead of pressing Finish, `idle` opened the dialog
|
||||
// and picked nothing up. `done` is its own bucket because it is a normal exit — folded
|
||||
// into `idle` it would read as people bouncing off a dialog they never used.
|
||||
if (!finishing) {
|
||||
const stage: AbandonStage = execution?.running
|
||||
? 'running'
|
||||
: onSetupStep
|
||||
? 'setup'
|
||||
: execution?.done
|
||||
? 'done'
|
||||
: 'idle'
|
||||
logFeatureUsage('home', 'template_abandon', { key: stage })
|
||||
}
|
||||
if (execution?.running) {
|
||||
execution.abandon()
|
||||
// Not on the way out through Finish: `done` survives a retry, so Finish is clickable
|
||||
// while the run is going again. Stopping it is still right — nothing is watching it
|
||||
// any more — but a toast saying the import was stopped contradicts the click.
|
||||
if (!finishing) {
|
||||
sendUserToast('Import stopped. What it already added stays; reopen the project to finish.')
|
||||
}
|
||||
}
|
||||
// A run that started wrote items, whether it finished, was abandoned midway or failed
|
||||
// partway — so the list behind this dialog is stale either way, and only `finish()`
|
||||
// was reloading it. Closing a landed import with the X left an emptied-out home
|
||||
// showing its placeholder rows over a workspace that now holds a project.
|
||||
if (!finishing && execution) void reloadWhenSettled(execution, onImported)
|
||||
finishing = false
|
||||
onClose()
|
||||
}
|
||||
|
||||
const RELOAD_BACKSTOP_MS = 15_000
|
||||
|
||||
/**
|
||||
* The one way this dialog reloads the caller's list: after the run stops writing. Both
|
||||
* exits use it, because both can be taken mid-write — `abandon()` only stops the *next*
|
||||
* phase and the request already sent still lands, and `done` survives a retry so Finish is
|
||||
* clickable while the run is going again. Reading the list at either of those moments
|
||||
* reads it before the write commits, which is what the reload exists to prevent.
|
||||
*/
|
||||
async function reloadWhenSettled(run: ImportExecution, reload: (() => void) | undefined) {
|
||||
// A backstop as well, because `installProject` issues its writes serially and takes no
|
||||
// signal: one request left pending after earlier items committed would leave those
|
||||
// invisible until the next page load. It does not replace the settlement reload — that
|
||||
// was the flaw in the timeout this grew out of — so a hung run reloads on the bound and
|
||||
// again if it ever finishes.
|
||||
const backstop = setTimeout(() => {
|
||||
if (run.running) reload?.()
|
||||
}, RELOAD_BACKSTOP_MS)
|
||||
await run.whenIdle()
|
||||
clearTimeout(backstop)
|
||||
reload?.()
|
||||
}
|
||||
|
||||
// The wizard route asks step 1 which workspace to import into and step 2 which one it
|
||||
// is. Opened from inside a workspace both answers are already given, so the dialog
|
||||
// starts at the import itself and the plan is fixed rather than URL-driven.
|
||||
let folder = $state<string | undefined>(undefined)
|
||||
let onSetupStep = $state(false)
|
||||
let execution = $state<ImportExecution | undefined>(undefined)
|
||||
|
||||
let plan = $derived<ImportPlan>({
|
||||
slug: slug ?? '',
|
||||
destination: { kind: 'existing', workspaceId: $workspaceStore },
|
||||
folder
|
||||
})
|
||||
|
||||
const setup = useSetupStep(
|
||||
() => execution,
|
||||
() => $workspaceStore
|
||||
)
|
||||
|
||||
// The catalogue the picker reads carries no item counts, and both the header card and
|
||||
// the import step show them, so the detail is fetched for the one project chosen. The
|
||||
// card renders from the pick until it lands — counts are the only thing missing, and
|
||||
// zero counts render as no badges rather than as zeroes.
|
||||
//
|
||||
// `fetchHubProject` takes no abort signal and nothing orders the responses, so a fetch
|
||||
// records its answer only while its own slug is still the chosen one, tagged with that
|
||||
// slug. A late answer for a project the user has moved on from can then neither reach the
|
||||
// card nor take the current project's counts back off it. Not `resource()`, which
|
||||
// publishes whatever its fetcher returns, superseded or not — that is the second half
|
||||
// back again, since the card would read a value the slug guard can only reject.
|
||||
let answered = $state<{ slug: string; project: ImportProjectSummary } | undefined>(undefined)
|
||||
watch(
|
||||
() => slug,
|
||||
(s) => {
|
||||
if (!s) return
|
||||
void fetchHubProject(s)
|
||||
.then((project) => {
|
||||
if (s === slug) answered = { slug: s, project }
|
||||
})
|
||||
.catch((error) => console.error('Could not load the hub project:', error))
|
||||
}
|
||||
)
|
||||
let fetchedDetail = $derived<ImportProjectSummary | undefined>(
|
||||
answered && answered.slug === slug ? answered.project : undefined
|
||||
)
|
||||
let project = $derived<ImportProjectSummary | undefined>(
|
||||
fetchedDetail ??
|
||||
(pick
|
||||
? {
|
||||
slug: pick.slug,
|
||||
name: pick.name,
|
||||
summary: pick.summary,
|
||||
author: pick.author,
|
||||
apps: pick.apps,
|
||||
logoUrl: pick.logoUrl,
|
||||
iconApps: pick.iconApps,
|
||||
counts: { apps: 0, flows: 0, scripts: 0, resources: 0 }
|
||||
}
|
||||
: undefined)
|
||||
)
|
||||
|
||||
// Asked for on the first pick, not at init: this dialog is mounted by the home list for
|
||||
// every user on every arrival, and the host is a string only the project card renders.
|
||||
let hubHost = $state('hub.windmill.dev')
|
||||
let hubHostAsked = false
|
||||
$effect(() => {
|
||||
if (!slug || hubHostAsked) return
|
||||
hubHostAsked = true
|
||||
void hubBrowserUrl()
|
||||
.then((u) => (hubHost = new URL(u).host))
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
// Each open is its own import: a dialog reopened for another project must not inherit
|
||||
// the previous run, or its step would offer to resume a bundle from a different slug.
|
||||
$effect(() => {
|
||||
if (slug === undefined) {
|
||||
folder = undefined
|
||||
onSetupStep = false
|
||||
execution = undefined
|
||||
}
|
||||
})
|
||||
|
||||
// The counters' key vocabularies, enumerated here so the whole set is reviewable at once.
|
||||
type AbandonStage = 'running' | 'setup' | 'done' | 'idle'
|
||||
type SetupOutcome = 'filled' | 'skipped' | 'none' | 'unchecked'
|
||||
type SetupBucket = 'filled' | 'none' | 'unchecked' | 'skipped_1' | 'skipped_2_5' | 'skipped_6plus'
|
||||
|
||||
// Set for the closing that Finish itself asks for, since that closing reaches `dismiss()`
|
||||
// by the same falling edge as the X.
|
||||
let finishing = false
|
||||
|
||||
/**
|
||||
* How the credentials step ended, counted alongside the import itself: `filled` only when
|
||||
* nothing was left outstanding — the step disables Finish until then — `none` where the
|
||||
* project asked for nothing, `unchecked` where the step could not read the export and so
|
||||
* offered Finish over lists it never filled, and a `skipped_*` bucket carrying roughly how
|
||||
* many rows were walked away from, since skipping with one credential left and skipping
|
||||
* with eight are different problems.
|
||||
*
|
||||
* A bucket rather than `value`: `value` is an increment, so counting rows there would make
|
||||
* `skipped` a sum of rows while its siblings count imports — two units in one counter, and
|
||||
* no way to recover filled-versus-skipped.
|
||||
*/
|
||||
function setupKey(outcome: SetupOutcome, outstanding: number): SetupBucket {
|
||||
if (outcome !== 'skipped') return outcome
|
||||
if (outstanding <= 1) return 'skipped_1'
|
||||
return outstanding <= 5 ? 'skipped_2_5' : 'skipped_6plus'
|
||||
}
|
||||
|
||||
function finish(setupOutcome: SetupOutcome, outstanding = 1) {
|
||||
// On the way out rather than on the pick: what is worth counting is an import that
|
||||
// landed, not a dialog that was opened and abandoned.
|
||||
if (slug) logFeatureUsage('home', 'template_import', { key: hubProjectUsageKey(slug) })
|
||||
logFeatureUsage('home', 'template_setup', { key: setupKey(setupOutcome, outstanding) })
|
||||
finishing = true
|
||||
// Through the same deferred reload every closing uses. `done` survives a retry, so
|
||||
// Finish is clickable while the run is going again — reloading here would read the
|
||||
// list mid-write, and `finishing` then stops `dismiss()` from reloading after it.
|
||||
// One reload per closing, always after the writing stops.
|
||||
if (execution) void reloadWhenSettled(execution, onImported)
|
||||
else onImported?.()
|
||||
onClose()
|
||||
}
|
||||
|
||||
// The two pages this dialog has, named the way the dialog titles them. The route wizard
|
||||
// asks two more questions before these; here both were answered by being in a workspace.
|
||||
const IMPORT_PAGE = 'Import the project'
|
||||
const SETUP_PAGE = 'Fill credentials'
|
||||
let currentPage = $derived(onSetupStep ? SETUP_PAGE : IMPORT_PAGE)
|
||||
// Whether this import ends on the credentials step, predicted before it runs so the stepper
|
||||
// can name both steps from the first frame rather than growing one mid-flow. The hub's
|
||||
// count is every resource the project ships, while the step only asks about the ones
|
||||
// something in it points at, so this errs towards naming a step the dialog then skips —
|
||||
// `setup.needed` is the real answer and lands with the export. Forward navigation is
|
||||
// blocked on that one, so an over-named step is a label, never a page with nothing on it.
|
||||
let setupExists = $derived((project?.counts?.resources ?? 0) > 0 || setup.needed || onSetupStep)
|
||||
|
||||
// The box height, decided when the dialog opens and left alone. It cannot follow
|
||||
// `setupExists`: that turns true when the detail fetch lands a moment after opening, and a
|
||||
// box that grows then is the dialog visibly loading in two steps. The catalogue already
|
||||
// says which integrations a project uses, which is what its credential stubs are, so the
|
||||
// answer is there in the first frame. A page that outgrows the box scrolls instead, with
|
||||
// its actions pinned.
|
||||
let tallBox = $state(false)
|
||||
$effect(() => {
|
||||
const opened = pick
|
||||
untrack(() => {
|
||||
if (opened) tallBox = (opened.apps?.length ?? 0) > 0
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet importPage()}
|
||||
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
|
||||
{#if project}
|
||||
<!-- What is about to be imported, before the checklist says what will happen to
|
||||
it: the project's own logo, name and prose. -->
|
||||
<ImportProjectCard {project} {hubHost} description={pick?.description} showCounts={false} />
|
||||
{/if}
|
||||
<!-- The note stays: this dialog always imports into a workspace that already holds
|
||||
things, which is the case it is about. Only a brand-new workspace has nothing to
|
||||
say about resources it will not overwrite or triggers that arrive disabled. -->
|
||||
<ImportProjectStep
|
||||
chooseFolder={false}
|
||||
fillHeight
|
||||
{plan}
|
||||
{project}
|
||||
setupPending={setup.needed}
|
||||
setupUndecided={setup.undecided}
|
||||
onFolderChange={(f) => (folder = f)}
|
||||
onFinish={() => (setup.needed ? (onSetupStep = true) : finish('none'))}
|
||||
onBack={onClose}
|
||||
onExecution={(e) => (execution = e)}
|
||||
resume={execution}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet setupPlaceholder()}
|
||||
<!-- The shape of the credentials page — a line of prose, then the rows to fill — so the
|
||||
slide has something to carry. The real step mounts on arrival and replaces it. -->
|
||||
<div class="flex flex-1 flex-col gap-4 pt-1">
|
||||
<Skeleton layout={[[2], 0.5, [1], 0.8, [3], 0.5, [3], 0.5, [3]]} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet setupPage()}
|
||||
<div class="flex flex-1 flex-col overflow-y-auto">
|
||||
<ImportSetupStep
|
||||
fillHeight
|
||||
workspace={$workspaceStore ?? ''}
|
||||
slug={slug ?? ''}
|
||||
{folder}
|
||||
showHeading={false}
|
||||
onSkip={(outstanding) => finish('skipped', outstanding)}
|
||||
onFinish={(checked) => finish(checked ? 'filled' : 'unchecked')}
|
||||
onBack={execution ? () => (onSetupStep = false) : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- No title: the stepper names the step and the card names the project, so a third label for
|
||||
what those two already say would only compete with them. -->
|
||||
<Modal
|
||||
title=""
|
||||
paginated
|
||||
bind:open={modalOpen}
|
||||
enterConfirms={false}
|
||||
class="sm:!max-w-[640px]"
|
||||
kind="X"
|
||||
>
|
||||
{#if slug}
|
||||
<!-- The wizard's own stepper, not the dialog's page breadcrumb: this is the same flow
|
||||
the /projects/import route runs, minus the two steps a workspace already answers.
|
||||
Outside the pages, so it stays put while they slide. `lowestStep` closes the way
|
||||
back once the run this dialog held is gone. -->
|
||||
<ImportWizardSteps
|
||||
step={onSetupStep ? 2 : 1}
|
||||
labels={['Import']}
|
||||
setupLabel={SETUP_PAGE}
|
||||
hasSetup={setupExists}
|
||||
lowestStep={execution ? 1 : 2}
|
||||
onNavigate={(s) => (onSetupStep = s === 2)}
|
||||
/>
|
||||
<!-- A definite height, which laid-over pages need: they are absolutely positioned, so
|
||||
without one the box collapses. Fixed per shape rather than per page — a dialog that
|
||||
resizes as a page slides in is the jump this pattern exists to avoid — but the setup
|
||||
page is much the taller of the two, so a project that has no setup step is not given
|
||||
its room. -->
|
||||
<PagedContent
|
||||
class={tallBox ? 'h-[min(72vh,560px)]' : 'h-[380px]'}
|
||||
current={currentPage}
|
||||
onNavigate={(key) => {
|
||||
if (key === IMPORT_PAGE && execution) onSetupStep = false
|
||||
else if (key === SETUP_PAGE && setup.needed) onSetupStep = true
|
||||
}}
|
||||
pages={[
|
||||
{ key: IMPORT_PAGE, content: importPage },
|
||||
{ key: SETUP_PAGE, content: setupPage, placeholder: setupPlaceholder }
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
</Modal>
|
||||
@@ -16,7 +16,7 @@
|
||||
} from '$lib/gen'
|
||||
import { resource } from 'runed'
|
||||
import { getDraftItems } from '$lib/workspaceDrafts.svelte'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { disableHubStore, userStore, workspaceStore } from '$lib/stores'
|
||||
import type uFuzzy from '@leeoniya/ufuzzy'
|
||||
import {
|
||||
ArrowDownUp,
|
||||
@@ -40,6 +40,10 @@
|
||||
type FilterSchemaRec
|
||||
} from '$lib/components/FilterSearchbar.svelte'
|
||||
import NoItemFound from './NoItemFound.svelte'
|
||||
import WorkspaceEmptyState from './WorkspaceEmptyState.svelte'
|
||||
import HubProjectPickerModal from './HubProjectPickerModal.svelte'
|
||||
import ImportProjectModal from './ImportProjectModal.svelte'
|
||||
import type { HubProjectPick } from '$lib/hubProject'
|
||||
import ListFilters from './ListFilters.svelte'
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
@@ -696,6 +700,10 @@
|
||||
// runnables an owner holds. A scope change (sort/archive/kind/…) doesn't go
|
||||
// through here: the counts resource keys on those itself.
|
||||
async function reloadItemsAndCounts(): Promise<void> {
|
||||
// The answer can change with the rows: archiving the last item leaves the listing empty
|
||||
// with something archived behind it, and a cached "nothing archived" would then call
|
||||
// the workspace empty and hide the way to it until a page load.
|
||||
archivedProbe = undefined
|
||||
// A mutated row can be gone, or sit at a new path, afterwards: snapshot what
|
||||
// was on screen so the selection can drop what this reload removes instead of
|
||||
// keeping a dead path. `tick` lets the reloaded rows re-register first.
|
||||
@@ -986,6 +994,105 @@
|
||||
treeLazyMode && ownerCountsRes.current == undefined && ownerCountsRes.loading
|
||||
)
|
||||
|
||||
// An import just landed, so the rows about to replace the empty state are all new: they
|
||||
// fade in one after another rather than appearing as a finished list. Cleared on a timer
|
||||
// because nothing else marks the end — the reload resolves before the rows animate.
|
||||
let justImported = $state(false)
|
||||
let justImportedTimer: ReturnType<typeof setTimeout> | undefined
|
||||
function onImported() {
|
||||
reloadItemsAndCounts()
|
||||
justImported = true
|
||||
clearTimeout(justImportedTimer)
|
||||
justImportedTimer = setTimeout(() => (justImported = false), 2500)
|
||||
}
|
||||
|
||||
// The hub import, owned here rather than by either entry point: the empty state's link and
|
||||
// the create menu's Import section open the same dialog, and mounting one per entry point
|
||||
// would put two of them on the page at once while the workspace is still empty.
|
||||
let hubPick = $state<HubProjectPick | undefined>(undefined)
|
||||
let hubPickerOpen = $state(false)
|
||||
|
||||
/**
|
||||
* Whether a workspace the default listing found empty is empty at all, or just has nothing
|
||||
* unarchived — two different states that want two different things said about them. Asked
|
||||
* only in that case, and once per workspace: one request for one row, never on a workspace
|
||||
* with something in it. `hasArchived` is undefined when the request failed — see the catch
|
||||
* for what that leaves standing.
|
||||
*/
|
||||
let archivedProbe = $state<{ workspace: string; hasArchived: boolean | undefined } | undefined>(
|
||||
undefined
|
||||
)
|
||||
$effect(() => {
|
||||
const ws = $workspaceStore
|
||||
if (!ws || !workspaceEmpty || archivedProbe?.workspace === ws) return
|
||||
untrack(() => void probeArchived(ws))
|
||||
})
|
||||
async function probeArchived(workspace: string) {
|
||||
try {
|
||||
// `includeWithoutMain` to match the listing: the backend drops `auto_kind = 'lib'`
|
||||
// without it, so a workspace holding only archived library scripts would answer
|
||||
// "nothing archived". Always true here — hiding library scripts puts a filter in
|
||||
// `activeFilters`, which `workspaceEmpty` requires to be empty.
|
||||
const res = await ScriptService.listRunnables({
|
||||
workspace,
|
||||
showArchived: true,
|
||||
includeWithoutMain: true,
|
||||
perPage: 1
|
||||
})
|
||||
archivedProbe = { workspace, hasArchived: (res.items?.length ?? 0) > 0 }
|
||||
} catch (error) {
|
||||
// Undefined, not false: false would say the workspace is empty and — since the
|
||||
// toolbar is inert on the strength of the placeholder carrying the way to archived
|
||||
// items — leave no way to them at all. Unknown keeps the ordinary caption, which
|
||||
// promises nothing, and leaves the searchbar live as the fallback it used to be.
|
||||
console.error('Could not check for archived items:', error)
|
||||
archivedProbe = { workspace, hasArchived: undefined }
|
||||
}
|
||||
}
|
||||
let emptyStateAnswered = $derived(archivedProbe?.workspace === $workspaceStore)
|
||||
/**
|
||||
* The probe could not tell. The toolbar stays usable in that case: `inert` is only right
|
||||
* while the placeholder is the way to archived items, and here it cannot be.
|
||||
*/
|
||||
let archivedUnknown = $derived(emptyStateAnswered && archivedProbe?.hasArchived === undefined)
|
||||
/**
|
||||
* Whether this user may be offered the create actions. The empty state's template import
|
||||
* and create menu do no permission check of their own, so an operator — or a workspace
|
||||
* whose direct-deploy protection cleared `showEditButtons` — must not be shown them.
|
||||
* Reading archived items is not a write, so it is not gated on this.
|
||||
*/
|
||||
let canCreateHere = $derived(!$userStore?.operator && showEditButtons)
|
||||
|
||||
// The workspace itself holds nothing — no filter is narrowing the list away. It stays
|
||||
// false until the first load resolves: a skeleton already means "loading", and the
|
||||
// empty state must not be mistaken for one. The controls it dims stay mounted, so
|
||||
// nothing moves when the first item lands.
|
||||
let workspaceEmpty = $derived(
|
||||
!loading &&
|
||||
!treeCountsPending &&
|
||||
!contentActive &&
|
||||
activeFilters.length === 0 &&
|
||||
filteredItems != undefined &&
|
||||
filteredItems.length === 0 &&
|
||||
visiblePipelineFolders.size === 0 &&
|
||||
!hasMoreServer
|
||||
)
|
||||
/**
|
||||
* Whether the placeholder below takes the toolbar's job over — it renders under the same
|
||||
* conditions. Standing the toolbar down depends on something else offering a way onwards:
|
||||
* where the placeholder holds back, as it does for an operator in a workspace that is
|
||||
* simply empty, these controls are all there is and stay live.
|
||||
*/
|
||||
let placeholderTakesOver = $derived(
|
||||
workspaceEmpty && emptyStateAnswered && (archivedProbe?.hasArchived === true || canCreateHere)
|
||||
)
|
||||
/**
|
||||
* The toolbar is dimmed either way; `inert` also takes it off the pointer, which is only
|
||||
* right while the placeholder carries the way to archived items. A probe that could not
|
||||
* tell leaves it live as the fallback.
|
||||
*/
|
||||
let toolbarInert = $derived(placeholderTakesOver && !archivedUnknown)
|
||||
|
||||
// Owners the counts found the user has something in, split by kind. They cover
|
||||
// what the folder/username lists miss: an item shared individually out of a
|
||||
// folder or user space the user is otherwise not a member of.
|
||||
@@ -1651,7 +1758,12 @@
|
||||
}}
|
||||
>
|
||||
{#if !contentActive}
|
||||
<div class="flex justify-start">
|
||||
<!-- Kept mounted, not hidden, so the toolbar doesn't reflow the moment the first item
|
||||
lands; `inert` takes it out of the tab order and off the pointer meanwhile. A
|
||||
workspace with nothing but archived items reaches them from its own placeholder,
|
||||
so these controls are not the way there — but only where that placeholder
|
||||
renders, which is what `placeholderTakesOver` tracks. -->
|
||||
<div class="flex justify-start" class:opacity-40={placeholderTakesOver} inert={toolbarInert}>
|
||||
<ToggleButtonGroup
|
||||
selected={itemKind}
|
||||
onSelected={(v) => {
|
||||
@@ -1692,9 +1804,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !loading && !contentActive}
|
||||
{#if !loading && !contentActive && !workspaceEmpty}
|
||||
<!-- List controls, between the kind toggle and the searchbar: select mode, tree
|
||||
view, expand/collapse (tree only), sort. -->
|
||||
view, expand/collapse (tree only), sort. Nothing to select, group or order on
|
||||
an empty workspace, so the whole row goes. -->
|
||||
<div class="flex items-center gap-2">
|
||||
{#if homeSelection.available && !homeSelection.active}
|
||||
<Button
|
||||
@@ -1749,7 +1862,11 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex grow items-center justify-end gap-2 min-w-0">
|
||||
<div class="relative text-primary w-full min-w-[200px] max-w-[26rem]">
|
||||
<div
|
||||
class="relative text-primary w-full min-w-[200px] max-w-[26rem]"
|
||||
class:opacity-40={placeholderTakesOver}
|
||||
inert={toolbarInert}
|
||||
>
|
||||
<FilterSearchbar
|
||||
schema={searchbarSchema}
|
||||
bind:value={filterValues.val}
|
||||
@@ -1764,8 +1881,12 @@
|
||||
<!-- Same gate the old create actions used: hidden from operators and in workspaces
|
||||
whose direct-deploy protection cleared showEditButtons (NoDirectDeployAlert), since
|
||||
the menu itself does no permission check. -->
|
||||
{#if !$userStore?.operator && showEditButtons}
|
||||
<CreateActionsMenu />
|
||||
{#if canCreateHere}
|
||||
<!-- No hub entry where the instance has the hub turned off: the same setting the
|
||||
script and flow hub pickers observe. -->
|
||||
<CreateActionsMenu
|
||||
onImportHubProject={$disableHubStore ? undefined : () => (hubPickerOpen = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1793,7 +1914,7 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if filteredItems?.length == 0}
|
||||
{#if filteredItems?.length == 0 && !workspaceEmpty}
|
||||
<div class="mt-10"></div>
|
||||
{/if}
|
||||
<div class="mt-3">
|
||||
@@ -1820,7 +1941,28 @@
|
||||
<!-- Pipelines aren't part of the text filter, so only fall through to show
|
||||
them (list rows / injected tree folders) when not actively searching;
|
||||
a no-match search still reads as empty. -->
|
||||
<NoItemFound {activeFilters} />
|
||||
{#if workspaceEmpty}
|
||||
<!-- Held until the archived probe answers rather than drawn and swapped: the two
|
||||
placeholders say different things, and showing the wrong one first says the
|
||||
workspace is empty when it is not. -->
|
||||
{#if emptyStateAnswered}
|
||||
{#if archivedProbe?.hasArchived || canCreateHere}
|
||||
<!-- Shown to whoever has something to do here: the archived notice to
|
||||
everyone, since reading archived items is not a write, and the create
|
||||
actions only to a user who may take them. -->
|
||||
<WorkspaceEmptyState
|
||||
archivedOnly={archivedProbe?.hasArchived === true}
|
||||
canCreate={canCreateHere}
|
||||
onPick={(project) => (hubPick = project)}
|
||||
onShowArchived={() => (filterValues.val = { ...filterValues.val, archived: true })}
|
||||
/>
|
||||
{:else}
|
||||
<NoItemFound {activeFilters} />
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<NoItemFound {activeFilters} />
|
||||
{/if}
|
||||
{#if hasMoreServer && !searching}
|
||||
<!-- The active filter matched nothing on the loaded pages, but the server
|
||||
has more: keep paging reachable so matches on later pages aren't lost. -->
|
||||
@@ -1863,7 +2005,7 @@
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="border rounded-md bg-surface-tertiary">
|
||||
<div class="border rounded-md bg-surface-tertiary" class:wm-imported={justImported}>
|
||||
{#if filter === ''}
|
||||
{#each [...visiblePipelineFolders].sort() as folder (folder)}
|
||||
<a
|
||||
@@ -1936,3 +2078,66 @@
|
||||
onDone={reloadItemsAndCounts}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<HubProjectPickerModal
|
||||
open={hubPickerOpen}
|
||||
onClose={() => (hubPickerOpen = false)}
|
||||
onPick={(project) => {
|
||||
hubPickerOpen = false
|
||||
hubPick = project
|
||||
}}
|
||||
/>
|
||||
<ImportProjectModal pick={hubPick} onClose={() => (hubPick = undefined)} {onImported} />
|
||||
|
||||
<style>
|
||||
/* Rows arriving after an import, one after another. The animation is declared on the
|
||||
container's children rather than on each row: a wrapper element around a row would make
|
||||
every row `first-of-type` and `last-of-type`, which is how Row draws its corners and
|
||||
separators. The delay steps for the first rows only — past those the stagger is longer
|
||||
than anyone waits, so they share the last one. */
|
||||
@keyframes wm-row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wm-imported > :global(*) {
|
||||
animation: wm-row-in 260ms ease-out both;
|
||||
animation-delay: 320ms;
|
||||
}
|
||||
.wm-imported > :global(*:nth-child(1)) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.wm-imported > :global(*:nth-child(2)) {
|
||||
animation-delay: 40ms;
|
||||
}
|
||||
.wm-imported > :global(*:nth-child(3)) {
|
||||
animation-delay: 80ms;
|
||||
}
|
||||
.wm-imported > :global(*:nth-child(4)) {
|
||||
animation-delay: 120ms;
|
||||
}
|
||||
.wm-imported > :global(*:nth-child(5)) {
|
||||
animation-delay: 160ms;
|
||||
}
|
||||
.wm-imported > :global(*:nth-child(6)) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
.wm-imported > :global(*:nth-child(7)) {
|
||||
animation-delay: 240ms;
|
||||
}
|
||||
.wm-imported > :global(*:nth-child(8)) {
|
||||
animation-delay: 280ms;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wm-imported > :global(*) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import CloseButton from '$lib/components/common/CloseButton.svelte'
|
||||
import { GraduationCap } from 'lucide-svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { goto } from '$app/navigation'
|
||||
import { sendUserToast, type ToastAction } from '$lib/toast'
|
||||
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import {
|
||||
skipAllTodos,
|
||||
syncTutorialsTodos,
|
||||
TUTORIAL_BANNER_DISMISSED_KEY
|
||||
} from '$lib/tutorialUtils'
|
||||
import { tutorialsToDo, userStore, skippedAll } from '$lib/stores'
|
||||
import { TUTORIALS_CONFIG } from '$lib/tutorials/config'
|
||||
import { hasRoleAccess } from '$lib/tutorials/roleUtils'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
type BannerState = 'hidden' | 'start' | 'new'
|
||||
|
||||
// Deciding what to show needs an API round-trip, so the banner paints the state the last visit
|
||||
// resolved to and reconciles once the sync answers. Guessing wrong once in a while beats
|
||||
// reflowing the home page on every load; nothing cached means hidden, the direction that does
|
||||
// not push the page down.
|
||||
const TUTORIAL_BANNER_STATE_KEY = 'tutorial_banner_state'
|
||||
|
||||
const cachedState =
|
||||
getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true'
|
||||
? 'hidden'
|
||||
: getLocalSetting(TUTORIAL_BANNER_STATE_KEY)
|
||||
let isDismissed = $state(cachedState !== 'start' && cachedState !== 'new')
|
||||
let hasCompletedAny = $state(cachedState === 'new')
|
||||
|
||||
/**
|
||||
* Get all tutorial indexes that are accessible to the current user based on their role.
|
||||
* Automatically recomputes when $userStore changes.
|
||||
*/
|
||||
const accessibleTutorialIndexes = $derived.by(() => {
|
||||
const indexes = new Set<number>()
|
||||
const user = $userStore
|
||||
|
||||
for (const tab of Object.values(TUTORIALS_CONFIG)) {
|
||||
// Check if user has access to this tab category
|
||||
if (!hasRoleAccess(user, tab.roles)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const tutorial of tab.tutorials) {
|
||||
// Check if tutorial has an index and user has access to it
|
||||
if (tutorial.index !== undefined && hasRoleAccess(user, tutorial.roles)) {
|
||||
indexes.add(tutorial.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return indexes
|
||||
})
|
||||
|
||||
function resolveState(state: BannerState) {
|
||||
isDismissed = state === 'hidden'
|
||||
hasCompletedAny = state === 'new'
|
||||
// Last: persisting is best-effort, and a storage failure must not leave the banner stuck on
|
||||
// whatever the cache said
|
||||
storeLocalSetting(TUTORIAL_BANNER_STATE_KEY, state)
|
||||
}
|
||||
|
||||
// The banner is interactive while the initial sync is still in flight, so a dismiss or a skip
|
||||
// can land mid-await. Once that happens the user's choice wins and the sync must not resurrect
|
||||
// the banner.
|
||||
let userHidBanner = false
|
||||
|
||||
function hideBannerForUser() {
|
||||
userHidBanner = true
|
||||
resolveState('hidden')
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Manually dismissed via the X button (soft dismiss, per-device). Checked before the network
|
||||
// call so a dismissed banner can never flash back in.
|
||||
if (getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true') {
|
||||
resolveState('hidden')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Sync tutorial progress from backend first
|
||||
await syncTutorialsTodos()
|
||||
} catch (error) {
|
||||
console.error('Failed to sync tutorial progress:', error)
|
||||
// Keep whatever the last successful sync resolved to rather than guessing again
|
||||
return
|
||||
}
|
||||
|
||||
if (userHidBanner) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user deliberately skipped all tutorials (permanent dismiss, from backend)
|
||||
if ($skippedAll) {
|
||||
resolveState('hidden')
|
||||
return
|
||||
}
|
||||
|
||||
// Safe to check tutorialsToDo here since we awaited syncTutorialsTodos() above
|
||||
// Filter tutorialsToDo to only include tutorials accessible to the user's role
|
||||
const remainingAccessibleTutorials = $tutorialsToDo.filter((index) =>
|
||||
accessibleTutorialIndexes.has(index)
|
||||
)
|
||||
|
||||
// Hide banner if all accessible tutorials are completed (but can reappear with new tutorials)
|
||||
if (remainingAccessibleTutorials.length === 0) {
|
||||
resolveState('hidden')
|
||||
return
|
||||
}
|
||||
|
||||
// Having completed at least one accessible tutorial switches the wording to
|
||||
// "New tutorial available!" instead of "Learn with interactive tutorials"
|
||||
resolveState(
|
||||
remainingAccessibleTutorials.length < accessibleTutorialIndexes.size ? 'new' : 'start'
|
||||
)
|
||||
})
|
||||
|
||||
async function handleSkipAllTutorials() {
|
||||
// Skip all tutorials and set skipped_all flag in backend (permanent)
|
||||
await skipAllTodos()
|
||||
await syncTutorialsTodos()
|
||||
// No need to set the dismissed flag - backend skipped_all flag is the source of truth
|
||||
hideBannerForUser()
|
||||
}
|
||||
|
||||
function dismissBanner() {
|
||||
storeLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY, 'true')
|
||||
hideBannerForUser()
|
||||
|
||||
const actions: ToastAction[] = [
|
||||
{
|
||||
label: 'Skip tutorials',
|
||||
callback: handleSkipAllTutorials,
|
||||
buttonType: 'default'
|
||||
}
|
||||
]
|
||||
|
||||
sendUserToast(
|
||||
'You can still access tutorials from the Tutorials page in the main menu or in the Help submenu.',
|
||||
false,
|
||||
actions,
|
||||
undefined,
|
||||
8000
|
||||
)
|
||||
}
|
||||
|
||||
function goToTutorials() {
|
||||
goto(`${base}/tutorials`)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isDismissed}
|
||||
<!-- A standing invitation, not an announcement: it sits inline at the start of the row rather
|
||||
than filling the page, so it reads as one more control and not as a card the user has to
|
||||
dispatch before getting to their work. -->
|
||||
<div class="flex items-center gap-2 mt-4 mb-4">
|
||||
<span class="text-hint text-xs truncate min-w-0">
|
||||
{#if hasCompletedAny}
|
||||
New tutorial available!
|
||||
{:else}
|
||||
First time?
|
||||
{/if}
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
onclick={goToTutorials}
|
||||
startIcon={{ icon: GraduationCap }}
|
||||
>
|
||||
Tutorials
|
||||
</Button>
|
||||
<CloseButton small noBg title="Dismiss tutorial banner" onClick={dismissBanner} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,124 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { CheckCircle2, Circle, RefreshCw, CheckCheck } from 'lucide-svelte'
|
||||
import type { ComponentType } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
icon: ComponentType
|
||||
title: string
|
||||
description: string
|
||||
onclick: () => void
|
||||
isCompleted?: boolean
|
||||
disabled?: boolean
|
||||
comingSoon?: boolean
|
||||
onReset?: () => void
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
onclick,
|
||||
isCompleted = false,
|
||||
disabled = false,
|
||||
comingSoon = false,
|
||||
onReset,
|
||||
onComplete
|
||||
}: Props = $props()
|
||||
|
||||
let isHovered = $state(false)
|
||||
|
||||
// Determine which action button to show
|
||||
const actionButton = $derived(() => {
|
||||
if (isCompleted && isHovered && onReset) {
|
||||
return {
|
||||
icon: RefreshCw,
|
||||
label: 'Reset',
|
||||
onClick: onReset
|
||||
}
|
||||
}
|
||||
if (!isCompleted && isHovered && onComplete) {
|
||||
return {
|
||||
icon: CheckCheck,
|
||||
label: 'Mark as completed',
|
||||
onClick: onComplete
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function handleAction(e: MouseEvent | KeyboardEvent) {
|
||||
const button = actionButton()
|
||||
if (!button) return
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
button.onClick()
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={disabled || comingSoon ? undefined : onclick}
|
||||
disabled={disabled || comingSoon}
|
||||
class="group relative flex items-center gap-4 w-full px-4 py-3 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b border-b border-light transition-colors text-left last:border-b-0 {disabled || comingSoon
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-surface-hover'}"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<Icon size={20} class="flex-shrink-0 text-accent-primary transition-colors" />
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold {!disabled && !comingSoon
|
||||
? 'group-hover:text-accent-primary'
|
||||
: ''} transition-colors">
|
||||
{title}
|
||||
{#if comingSoon}
|
||||
<span class="ml-2 text-3xs text-secondary">(Coming soon)</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-hint text-3xs truncate text-left font-normal">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div
|
||||
role="status"
|
||||
class="flex items-center gap-1.5 flex-shrink-0"
|
||||
onmouseenter={() => (isHovered = true)}
|
||||
onmouseleave={() => (isHovered = false)}
|
||||
>
|
||||
{#if actionButton()}
|
||||
{@const button = actionButton()!}
|
||||
{@const ActionIcon = button.icon}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={handleAction}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleAction(e)
|
||||
}
|
||||
}}
|
||||
class="flex items-center gap-1.5 px-2 py-1 text-xs font-normal text-secondary hover:text-primary hover:bg-surface-hover rounded transition-colors cursor-pointer"
|
||||
>
|
||||
<ActionIcon size={14} class="flex-shrink-0" />
|
||||
{button.label}
|
||||
</div>
|
||||
{:else}
|
||||
<span
|
||||
class="text-xs font-normal {isCompleted
|
||||
? 'text-green-500'
|
||||
: 'text-blue-300'}"
|
||||
>
|
||||
{isCompleted ? 'Completed' : 'Not started'}
|
||||
</span>
|
||||
{#if isCompleted}
|
||||
<CheckCircle2 size={14} class="text-green-500 flex-shrink-0" />
|
||||
{:else}
|
||||
<Circle size={14} class="text-blue-300 flex-shrink-0" />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import type { HubProjectPick } from '$lib/hubProject'
|
||||
import { disableHubStore } from '$lib/stores'
|
||||
import CreateActionsMenu from './CreateActionsMenu.svelte'
|
||||
import HubTemplatePicker from './HubTemplatePicker.svelte'
|
||||
|
||||
interface Props {
|
||||
/** A project was chosen here. The list owns the import dialog, and opens it on this. */
|
||||
onPick: (project: HubProjectPick) => void
|
||||
/**
|
||||
* The workspace holds items, all of them archived. It is not empty and must not be
|
||||
* told it is: the caption names what is there and offers the way to it instead.
|
||||
*/
|
||||
archivedOnly?: boolean
|
||||
/** Switches the list to the archived view. */
|
||||
onShowArchived: () => void
|
||||
/**
|
||||
* Whether to offer the template import and the create menu. Neither checks permissions
|
||||
* itself, so an operator gets the state described without the two actions it may not
|
||||
* take — the archived link stays, since reading archived items is not a write.
|
||||
*/
|
||||
canCreate?: boolean
|
||||
}
|
||||
|
||||
let { onPick, archivedOnly = false, onShowArchived, canCreate = true }: Props = $props()
|
||||
|
||||
// Row opacities: the list fading out of existence. Static on purpose — motion is what
|
||||
// makes a skeleton mean "loading", and this state means "empty".
|
||||
// `border-border-*`, not `border-*`: the Tailwind colour keys are themselves named
|
||||
// `border-light` / `border-normal`, so `border-light` matches no utility and silently
|
||||
// falls back to the global default border colour in app.css. Bars and dashes both sit on
|
||||
// `border-light`; only the container outline steps up, so nothing outweighs its frame.
|
||||
const rowOpacities = [1, 0.7, 0.4]
|
||||
|
||||
// The inline "create a new one" link is the anchor for the very same New menu the
|
||||
// toolbar button opens, so the menu pops next to the words that promised it.
|
||||
let newLinkEl: HTMLButtonElement | undefined = $state(undefined)
|
||||
|
||||
onMount(() => {
|
||||
// Only the empty case: the counter answers how many workspaces sit empty and what
|
||||
// their owners do next, and a workspace whose items are all archived is neither.
|
||||
if (!archivedOnly) logFeatureUsage('home', 'empty_state_view')
|
||||
})
|
||||
|
||||
// The catalogue is fetched when the picker opens, never on render: `disable_hub` says an
|
||||
// instance makes no hub requests at all, and it loads asynchronously, so anything fired
|
||||
// from here goes out before the setting that forbids it is known.
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-md border-[1.5px] border-dashed border-border-normal/60 bg-surface"
|
||||
role="status"
|
||||
aria-label={archivedOnly ? 'Everything here is archived' : 'Your workspace is empty'}
|
||||
>
|
||||
{#each rowOpacities as opacity, i (i)}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex items-center gap-[14px] px-4 py-[13px] {i > 0
|
||||
? 'border-t border-dashed border-border-light'
|
||||
: ''}"
|
||||
style="opacity: {opacity}"
|
||||
>
|
||||
<div class="size-4 shrink-0 rounded bg-border-light"></div>
|
||||
<div>
|
||||
<div class="h-[9px] w-[140px] rounded-full bg-border-light"></div>
|
||||
<div class="mt-[5px] h-[7px] w-[70px] rounded-full bg-border-light/60"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- A <div>, not a <p>: CreateActionsMenu wraps its trigger in an element, which a
|
||||
paragraph may not contain. -->
|
||||
<div
|
||||
class="border-t border-dashed border-border-light px-4 pb-[22px] pt-[18px] text-center text-[13.5px] leading-relaxed text-hint"
|
||||
>
|
||||
{#if archivedOnly}
|
||||
<!-- Its own line: the state and the invitation are two sentences, and splicing them
|
||||
into one leaves a link doing the work of a conjunction.
|
||||
Every link in this caption is a bare <button>, signed off by design: <Button>
|
||||
carries its own padding and background and cannot sit inline in running text.
|
||||
They take `text-accent`, never a raw Tailwind blue. -->
|
||||
<span class="block">
|
||||
Everything in this workspace is archived.
|
||||
<button
|
||||
class="border-b border-transparent text-accent hover:border-accent"
|
||||
onclick={onShowArchived}>Show archived items</button
|
||||
>.
|
||||
</span>
|
||||
{:else}
|
||||
Your scripts, flows and apps will show up here.
|
||||
{/if}
|
||||
{#if canCreate}
|
||||
<!-- The hub half goes when the instance has the hub turned off, and the remaining link
|
||||
opens the sentence instead of continuing it. -->
|
||||
{#if !$disableHubStore}
|
||||
<!-- Opens downward into the page rather than upward into the hero: the caption sits
|
||||
high when the AI composer is hidden, so the room is below it. `fitViewport` caps
|
||||
the box on a short viewport, which is why the height below is definite and the
|
||||
list inside fills it — a squeezed box with a fixed-height list inside overflows
|
||||
its own frame. -->
|
||||
<Popover
|
||||
floatingConfig={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
gutter: 8,
|
||||
overflowPadding: 16,
|
||||
flip: { fallbackPlacements: ['top', 'bottom-start', 'top-start'] },
|
||||
fitViewport: true,
|
||||
overlap: false
|
||||
}}
|
||||
contentClasses="p-0 flex"
|
||||
contentStyle="height: min(72vh, 520px);"
|
||||
class="border-b border-transparent text-accent hover:border-accent"
|
||||
triggerAttrs={{ 'aria-label': 'Start from a template' }}
|
||||
on:openChange={(e) =>
|
||||
e.detail && logFeatureUsage('home', 'template_picker_open', { key: 'empty_state' })}
|
||||
>
|
||||
{#snippet trigger()}Start from a template{/snippet}
|
||||
{#snippet content({ close })}
|
||||
<HubTemplatePicker
|
||||
onPick={(project) => {
|
||||
close()
|
||||
onPick(project)
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
or
|
||||
{/if}
|
||||
<CreateActionsMenu source="empty_state" triggerElement={newLinkEl}>
|
||||
{#snippet trigger()}
|
||||
<!-- The full stop rides inside the snippet: across a component boundary Svelte
|
||||
keeps the markup whitespace, which would leave a gap before it. -->
|
||||
<button
|
||||
bind:this={newLinkEl}
|
||||
class="border-b border-transparent text-accent hover:border-accent"
|
||||
>{$disableHubStore ? 'Create a new one' : 'create a new one'}</button
|
||||
>.
|
||||
{/snippet}
|
||||
</CreateActionsMenu>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,6 +179,7 @@ repeatedly — check the brand's own page.
|
||||
| `KeycloakIcon` | `keycloak` | fixed | #00B8E3 | #00B8E3 | 8.18 | 10.65 | keycloak.org's own mark, https://www.keycloak.org/resources/images/icon.svg (cyan #00B8E3/#33C6E9/#008AAA over greys #4D4D4D–#EDEDED, single theme) |
|
||||
| `KlaviyoIcon` | `klaviyo` | pair | #1D1E20 | #FFFFFF | 16.14 | 12.47 | klaviyo.com --color-core-charcoal; the flag mark is the standalone logomark the site header collapses to, and the shape of klaviyo.com/icons/icon-512x512.png |
|
||||
| `KoboToolboxIcon` | `kobotoolbox` | fixed | #2095F3 | #2095F3 | 3.05 | 3.95 | the kobotoolbox.org header logo and $kobo-blue in kobotoolbox/kpi jsapp/scss/colors.scss |
|
||||
| `KubernetesIcon` | — | fixed | #326CE5 | #326CE5 | 4.61 | 2.62 | the CNCF artwork repo (github.com/cncf/artwork/projects/kubernetes/icon/color/kubernetes-icon-color.svg, CC-BY-4.0) |
|
||||
| `KustomerIcon` | `kustomer` | fixed | #FBEC2A | #FBEC2A | 14.08 | 12.47 | kustomer.com/images/kustomer/Kusty.svg |
|
||||
| `LangfuseIcon` | `langfuse` | fixed | #FF5D5F | #FF5D5F | 2.91 | 4.47 | langfuse.com/brand "Icon - Color (SVG)", used unmodified |
|
||||
| `LessIcon` | — | pair | #274F82 | #FFFFFF | 8.04 | 12.47 | github.com/less/logo (MIT) |
|
||||
@@ -443,6 +444,7 @@ Constraints that would otherwise be broken by a well-meaning change.
|
||||
- **JsonIcon** — JSON itself has no brand owner or published colours — json.org states none — so this is a Material palette pick, not a brand colour.
|
||||
- **KanidmIcon** — Kanidm's artwork is CC-BY-NC-ND — no recolouring or other derivatives.
|
||||
- **KlaviyoIcon** — Klaviyo draws it in currentColor, hence the white swap on dark.
|
||||
- **KubernetesIcon** — The Linux Foundation trademark guidelines allow only the published variants (colour, all-blue, black, white); the colour mark's white knockouts are part of the artwork, not a theme swap.
|
||||
- **LangfuseIcon** — Langfuse's trademark terms forbid modifying the assets.
|
||||
- **LineIcon** — LINE forbids any change to the logo's colour, so there is no reversed variant.
|
||||
- **LinearIcon** — Guidelines ship a light/dark logomark pair and forbid altering the assets in any other way.
|
||||
@@ -571,7 +573,7 @@ Not brands. These inherit `currentColor` on purpose and must not be given a pair
|
||||
|
||||
## Coverage
|
||||
|
||||
- brand icons: **314**, of which **310** carry a recorded source
|
||||
- brand icons: **315**, of which **311** carry a recorded source
|
||||
- per-theme pairs applied: **136** (5 of them by inversion or a two-SVG swap, see above)
|
||||
- concept icons: **34**
|
||||
- effectively invisible on light: **1** (AbstractApiIcon)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<!-- #326CE5 per the CNCF artwork repo (github.com/cncf/artwork/projects/kubernetes/icon/color, CC-BY-4.0). Kubernetes publishes the icon in colour, all-blue, black and white; the colour mark carries its own white knockouts so it reads on both themes. -->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
height?: string
|
||||
width?: string
|
||||
}
|
||||
|
||||
let { height = '24px', width = '24px' }: Props = $props()
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Kubernetes"
|
||||
role="img"
|
||||
viewBox="-0.17 0.08 230.10 223.35"
|
||||
{width}
|
||||
{height}
|
||||
>
|
||||
<path
|
||||
fill="#326CE5"
|
||||
d="M134.358 126.466a3.59 3.59 0 0 0-.855-.065 3.685 3.685 0 0 0-1.425.37 3.725 3.725 0 0 0-1.803 4.825l-.026.037 8.528 20.603a43.53 43.53 0 0 0 17.595-22.102l-21.976-3.714zm-34.194 2.92a3.72 3.72 0 0 0-3.568-2.894 3.656 3.656 0 0 0-.733.065l-.037-.045-21.785 3.698a43.695 43.695 0 0 0 17.54 21.946l8.442-20.4-.066-.08a3.683 3.683 0 0 0 .207-2.29zm18.245 8a3.718 3.718 0 0 0-6.557.008h-.018l-10.713 19.372a43.637 43.637 0 0 0 23.815 1.225q2.197-.5 4.292-1.2l-10.738-19.406zm33.914-45l-16.483 14.753.009.047a3.725 3.725 0 0 0 1.46 6.395l.02.089 21.35 6.15a44.278 44.278 0 0 0-6.356-27.432zM121.7 94.039a3.725 3.725 0 0 0 5.913 2.84l.065.027 18.036-12.788a43.85 43.85 0 0 0-25.287-12.19l1.253 22.105zm-19.1 2.921a3.72 3.72 0 0 0 5.904-2.85l.092-.043 1.253-22.14a44.682 44.682 0 0 0-4.501.776 43.467 43.467 0 0 0-20.937 11.409l18.154 12.869zm-9.678 16.729a3.72 3.72 0 0 0 1.462-6.396l.018-.088-16.574-14.824a43.454 43.454 0 0 0-6.168 27.51l21.245-6.13zm16.098 6.512l6.114 2.94 6.096-2.934 1.514-6.581-4.219-5.276h-6.79l-4.231 5.268z"
|
||||
/>
|
||||
<path
|
||||
fill="#326CE5"
|
||||
d="M216.208 133.167l-17.422-75.675a13.602 13.602 0 0 0-7.293-9.073l-70.521-33.67a13.589 13.589 0 0 0-11.705 0L38.76 48.437a13.598 13.598 0 0 0-7.295 9.072l-17.394 75.673a13.315 13.315 0 0 0-.004 5.81 13.506 13.506 0 0 0 .491 1.718 13.1 13.1 0 0 0 1.343 2.726c.239.365.491.72.765 1.064l48.804 60.678c.213.264.448.505.681.75a13.423 13.423 0 0 0 2.574 2.133 13.924 13.924 0 0 0 3.857 1.677 13.298 13.298 0 0 0 3.43.473h.759l77.504-.018a12.993 12.993 0 0 0 1.41-.083 13.47 13.47 0 0 0 1.989-.378 13.872 13.872 0 0 0 1.381-.442c.353-.135.705-.27 1.045-.433a13.941 13.941 0 0 0 1.479-.822 13.303 13.303 0 0 0 3.237-2.865l1.488-1.85 47.299-58.84a13.185 13.185 0 0 0 2.108-3.785 13.67 13.67 0 0 0 .5-1.724 13.282 13.282 0 0 0-.004-5.81zm-73.147 29.432a14.516 14.516 0 0 0 .703 1.703 3.314 3.314 0 0 0-.327 2.49 39.372 39.372 0 0 0 3.742 6.7 35.06 35.06 0 0 1 2.263 3.364c.17.315.392.803.553 1.136a4.24 4.24 0 1 1-7.63 3.607c-.161-.33-.385-.77-.522-1.082a35.275 35.275 0 0 1-1.225-3.868 39.305 39.305 0 0 0-2.896-7.097 3.335 3.335 0 0 0-2.154-1.307c-.135-.233-.635-1.15-.903-1.623a54.617 54.617 0 0 1-38.948-.1l-.955 1.73a3.429 3.429 0 0 0-1.819.887 29.517 29.517 0 0 0-3.268 7.582 34.9 34.9 0 0 1-1.218 3.868c-.135.31-.361.744-.522 1.073v.009l-.007.008a4.238 4.238 0 1 1-7.619-3.616c.159-.335.372-.82.54-1.135a35.177 35.177 0 0 1 2.262-3.373 41.228 41.228 0 0 0 3.82-6.866 4.188 4.188 0 0 0-.376-2.387l.768-1.84a54.922 54.922 0 0 1-24.338-30.387l-1.839.313a4.68 4.68 0 0 0-2.428-.855 39.524 39.524 0 0 0-7.356 2.165 35.589 35.589 0 0 1-3.787 1.45c-.305.084-.745.168-1.093.244-.028.01-.052.022-.08.029a.605.605 0 0 1-.065.006 4.236 4.236 0 1 1-1.874-8.224l.061-.015.037-.01c.353-.083.805-.2 1.127-.262a35.27 35.27 0 0 1 4.05-.326 39.388 39.388 0 0 0 7.564-1.242 5.835 5.835 0 0 0 1.814-1.83l1.767-.516a54.613 54.613 0 0 1 8.613-38.073l-1.353-1.206a4.688 4.688 0 0 0-.848-2.436 39.366 39.366 0 0 0-6.277-4.41 35.25 35.25 0 0 1-3.499-2.046c-.256-.191-.596-.478-.874-.704l-.063-.044a4.473 4.473 0 0 1-1.038-6.222 4.066 4.066 0 0 1 3.363-1.488 5.03 5.03 0 0 1 2.942 1.11c.287.225.68.526.935.745a35.253 35.253 0 0 1 2.78 2.95 39.383 39.383 0 0 0 5.69 5.142 3.333 3.333 0 0 0 2.507.243q.754.55 1.522 1.082A54.289 54.289 0 0 1 102.86 61.89a55.052 55.052 0 0 1 7.63-1.173l.1-1.784a4.6 4.6 0 0 0 1.37-2.184 39.476 39.476 0 0 0-.47-7.654 35.466 35.466 0 0 1-.576-4.014c-.011-.307.006-.731.01-1.081 0-.04-.01-.08-.01-.118a4.242 4.242 0 1 1 8.441-.004c0 .37.022.86.009 1.2a35.109 35.109 0 0 1-.579 4.013 39.533 39.533 0 0 0-.478 7.656 3.344 3.344 0 0 0 1.379 2.11c.015.305.065 1.323.102 1.884a55.309 55.309 0 0 1 35.032 16.927l1.606-1.147a4.69 4.69 0 0 0 2.56-.278 39.532 39.532 0 0 0 5.69-5.148 35.004 35.004 0 0 1 2.787-2.95c.259-.222.65-.52.936-.746a4.242 4.242 0 1 1 5.258 6.598c-.283.229-.657.548-.929.75a35.095 35.095 0 0 1-3.507 2.046 39.495 39.495 0 0 0-6.277 4.41 3.337 3.337 0 0 0-.792 2.39c-.235.216-1.06.947-1.497 1.343a54.837 54.837 0 0 1 8.792 37.983l1.704.496a4.745 4.745 0 0 0 1.82 1.83 39.464 39.464 0 0 0 7.568 1.246 35.64 35.64 0 0 1 4.046.324c.355.065.868.207 1.23.29a4.236 4.236 0 1 1-1.878 8.223l-.061-.008c-.028-.007-.054-.022-.083-.03-.348-.075-.785-.151-1.09-.231a35.14 35.14 0 0 1-3.785-1.462 39.477 39.477 0 0 0-7.363-2.165 3.337 3.337 0 0 0-2.362.877q-.9-.171-1.804-.316a54.92 54.92 0 0 1-24.328 30.605z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M225.407 135.107L206.4 52.547a14.838 14.838 0 0 0-7.958-9.9l-76.935-36.73a14.825 14.825 0 0 0-12.771 0L31.808 42.669a14.838 14.838 0 0 0-7.961 9.895L4.873 135.129a14.668 14.668 0 0 0 1.995 11.185c.261.4.538.788.838 1.162l53.246 66.205a14.98 14.98 0 0 0 11.499 5.487l85.387-.02a14.986 14.986 0 0 0 11.5-5.48l53.227-66.211a14.72 14.72 0 0 0 2.842-12.347zm-9.197 3.866a13.677 13.677 0 0 1-.498 1.723 13.184 13.184 0 0 1-2.11 3.786l-47.299 58.838-1.486 1.852a13.305 13.305 0 0 1-3.24 2.865 13.945 13.945 0 0 1-1.474.822q-.513.237-1.045.43a13.873 13.873 0 0 1-1.383.445 13.473 13.473 0 0 1-1.989.379 12.988 12.988 0 0 1-1.41.082l-77.504.018h-.76a13.298 13.298 0 0 1-3.429-.472 13.925 13.925 0 0 1-3.855-1.679 13.424 13.424 0 0 1-2.576-2.132c-.233-.246-.468-.487-.68-.75l-48.805-60.679q-.408-.514-.765-1.066a13.102 13.102 0 0 1-1.343-2.726 13.505 13.505 0 0 1-.491-1.719 13.315 13.315 0 0 1 .004-5.809l17.394-75.675a13.598 13.598 0 0 1 7.295-9.07l70.508-33.685a13.589 13.589 0 0 1 11.705 0l70.519 33.67a13.602 13.602 0 0 1 7.293 9.073l17.422 75.674a13.282 13.282 0 0 1 .002 5.807z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M185.814 127.106c-.36-.083-.874-.225-1.227-.29a35.642 35.642 0 0 0-4.046-.326 39.464 39.464 0 0 1-7.57-1.242 4.745 4.745 0 0 1-1.82-1.832l-1.704-.496a54.837 54.837 0 0 0-8.79-37.983c.436-.396 1.262-1.127 1.495-1.342a3.338 3.338 0 0 1 .792-2.39 39.495 39.495 0 0 1 6.277-4.41 35.095 35.095 0 0 0 3.507-2.046c.272-.202.644-.522.929-.75a4.242 4.242 0 1 0-5.256-6.6c-.288.227-.68.525-.936.747a35.004 35.004 0 0 0-2.789 2.95 39.533 39.533 0 0 1-5.69 5.148 4.69 4.69 0 0 1-2.56.278l-1.606 1.147a55.309 55.309 0 0 0-35.032-16.927c-.039-.561-.087-1.577-.102-1.884a3.344 3.344 0 0 1-1.377-2.11 39.533 39.533 0 0 1 .478-7.656 35.112 35.112 0 0 0 .575-4.012c.013-.34-.007-.834-.007-1.201a4.242 4.242 0 1 0-8.441.004c0 .04.009.078.01.118-.004.35-.021.774-.01 1.08a35.476 35.476 0 0 0 .576 4.015 39.475 39.475 0 0 1 .47 7.654 4.601 4.601 0 0 1-1.37 2.182l-.1 1.786a55.052 55.052 0 0 0-7.63 1.173 54.289 54.289 0 0 0-27.574 15.754q-.77-.531-1.526-1.082a3.333 3.333 0 0 1-2.506-.243 39.383 39.383 0 0 1-5.69-5.141 35.255 35.255 0 0 0-2.777-2.95c-.257-.22-.65-.52-.938-.75a5.03 5.03 0 0 0-2.942-1.11 4.066 4.066 0 0 0-3.363 1.49 4.473 4.473 0 0 0 1.038 6.222l.065.046c.276.226.616.515.872.702a35.256 35.256 0 0 0 3.499 2.048 39.367 39.367 0 0 1 6.276 4.412 4.69 4.69 0 0 1 .849 2.434l1.351 1.208a54.613 54.613 0 0 0-8.611 38.073l-1.767.514a5.835 5.835 0 0 1-1.814 1.827 39.39 39.39 0 0 1-7.565 1.247 35.266 35.266 0 0 0-4.049.326c-.324.06-.774.174-1.127.262l-.037.008-.06.018a4.236 4.236 0 1 0 1.875 8.224l.063-.01c.028-.006.052-.02.08-.025.348-.08.786-.163 1.092-.246a35.59 35.59 0 0 0 3.786-1.451 39.527 39.527 0 0 1 7.358-2.165 4.68 4.68 0 0 1 2.426.857l1.84-.315a54.922 54.922 0 0 0 24.34 30.387l-.769 1.84a4.188 4.188 0 0 1 .377 2.387 41.228 41.228 0 0 1-3.82 6.864 35.183 35.183 0 0 0-2.263 3.372c-.168.318-.381.805-.542 1.138a4.238 4.238 0 1 0 7.621 3.616l.007-.008v-.01c.16-.33.387-.763.522-1.072a34.903 34.903 0 0 0 1.218-3.868 29.517 29.517 0 0 1 3.268-7.582 3.43 3.43 0 0 1 1.819-.888l.957-1.73a54.617 54.617 0 0 0 38.946.099c.268.478.768 1.392.9 1.623a3.335 3.335 0 0 1 2.155 1.31 39.306 39.306 0 0 1 2.898 7.096 35.275 35.275 0 0 0 1.225 3.868c.137.312.36.75.522 1.082a4.24 4.24 0 1 0 7.63-3.607c-.161-.333-.383-.82-.55-1.136a35.06 35.06 0 0 0-2.263-3.364 39.372 39.372 0 0 1-3.742-6.7 3.314 3.314 0 0 1 .324-2.49 14.519 14.519 0 0 1-.703-1.703 54.92 54.92 0 0 0 24.328-30.605c.546.087 1.497.253 1.806.316a3.337 3.337 0 0 1 2.36-.877 39.476 39.476 0 0 1 7.36 2.165 35.135 35.135 0 0 0 3.788 1.462c.305.08.74.156 1.09.233.029.008.055.02.083.028l.06.009a4.236 4.236 0 1 0 1.878-8.224zm-40.1-42.987l-18.037 12.787-.063-.03a3.723 3.723 0 0 1-5.913-2.838l-.02-.01-1.253-22.103a43.85 43.85 0 0 1 25.285 12.194zm-33.978 24.228h6.788l4.22 5.276-1.513 6.58-6.096 2.934-6.114-2.94-1.516-6.583zm-6.386-35.648a44.672 44.672 0 0 1 4.503-.774l-1.255 22.137-.092.044a3.72 3.72 0 0 1-5.904 2.852l-.035.02-18.154-12.872a43.467 43.467 0 0 1 20.937-11.407zm-27.52 19.68l16.574 14.824-.018.09a3.72 3.72 0 0 1-1.462 6.395l-.017.072-21.245 6.13a43.454 43.454 0 0 1 6.168-27.51zm22.191 39.38l-8.441 20.397a43.696 43.696 0 0 1-17.536-21.948l21.783-3.7.037.049a3.655 3.655 0 0 1 .73-.065 3.72 3.72 0 0 1 3.364 5.185zm24.916 26.23a43.637 43.637 0 0 1-23.815-1.223l10.713-19.372h.018a3.725 3.725 0 0 1 6.557-.006h.08l10.74 19.404q-2.091.698-4.293 1.199zm13.841-5.751l-8.528-20.605.026-.037a3.725 3.725 0 0 1 1.803-4.823 3.685 3.685 0 0 1 1.425-.37 3.59 3.59 0 0 1 .855.063l.037-.046 21.977 3.714a43.53 43.53 0 0 1-17.595 22.105zm19.903-32.42l-21.352-6.15-.02-.09a3.725 3.725 0 0 1-1.46-6.395l-.008-.043 16.482-14.751a44.279 44.279 0 0 1 6.357 27.43z"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
INSTANCE_BANNER_MESSAGE_MAX_LEN,
|
||||
isInstanceBannerVisible,
|
||||
resolveInstanceBanner
|
||||
} from './instanceBanner'
|
||||
|
||||
const BANNER = {
|
||||
enabled: true,
|
||||
message: 'Scheduled maintenance on Saturday.',
|
||||
severity: 'warning',
|
||||
dismissible: true
|
||||
}
|
||||
|
||||
describe('resolveInstanceBanner', () => {
|
||||
it('drops a link that is not an absolute http(s) URL', () => {
|
||||
// Declarative instance config writes global_settings rows directly, so the API's
|
||||
// scheme check is not the only thing standing between a stored value and an href.
|
||||
for (const link of ['javascript:alert(1)', 'data:text/html,x', 'status.example.com', 123]) {
|
||||
expect(resolveInstanceBanner({ ...BANNER, link })?.link).toBeUndefined()
|
||||
}
|
||||
expect(resolveInstanceBanner({ ...BANNER, link: 'https://status.example.com' })?.link).toBe(
|
||||
'https://status.example.com'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a message the backend accepted whole', () => {
|
||||
// The backend caps at INSTANCE_BANNER_MESSAGE_MAX_LEN code points (`chars().count()`).
|
||||
// Truncating with `slice` here would count UTF-16 units and halve an all-emoji message
|
||||
// that passed validation, so the two sides must measure the same way.
|
||||
const emoji = '\u{1F6A7}'.repeat(INSTANCE_BANNER_MESSAGE_MAX_LEN)
|
||||
expect([...resolveInstanceBanner({ ...BANNER, message: emoji })!.message]).toHaveLength(
|
||||
INSTANCE_BANNER_MESSAGE_MAX_LEN
|
||||
)
|
||||
})
|
||||
|
||||
it('shows nothing when disabled or without a message', () => {
|
||||
// An enabled banner with no message is a writable state (the backend accepts it so a
|
||||
// half-typed announcement cannot fail an admin's whole settings save), so this is the
|
||||
// only thing keeping it off everyone's screen.
|
||||
expect(resolveInstanceBanner({ ...BANNER, enabled: false })).toBeUndefined()
|
||||
expect(resolveInstanceBanner({ ...BANNER, message: ' ' })).toBeUndefined()
|
||||
expect(resolveInstanceBanner({ ...BANNER, message: 42 })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInstanceBannerVisible', () => {
|
||||
it('honours a dismissal only while the announcement is dismissible', () => {
|
||||
const dismissible = resolveInstanceBanner(BANNER)!
|
||||
expect(isInstanceBannerVisible(dismissible, dismissible.fingerprint)).toBe(false)
|
||||
expect(isInstanceBannerVisible(dismissible, 'some other announcement')).toBe(true)
|
||||
|
||||
// Escalating the same announcement to mandatory must reach the people who already
|
||||
// dismissed it — the fingerprint does not change, so nothing else would bring it back.
|
||||
const mandatory = resolveInstanceBanner({ ...BANNER, dismissible: false })!
|
||||
expect(mandatory.fingerprint).toBe(dismissible.fingerprint)
|
||||
expect(isInstanceBannerVisible(mandatory, mandatory.fingerprint)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { SettingService } from '$lib/gen'
|
||||
|
||||
/** Drives the banner palette and icon; the subset of `AlertType` that fits an announcement. */
|
||||
export type InstanceBannerSeverity = 'info' | 'warning' | 'error'
|
||||
|
||||
/** Stored shape of the `instance_banner` global setting. Every field is optional: a
|
||||
* stored value can predate a field this code knows about. */
|
||||
export interface InstanceBanner {
|
||||
enabled?: boolean
|
||||
message?: string
|
||||
severity?: InstanceBannerSeverity
|
||||
/** Whether a viewer may dismiss the banner for themselves. Absent means yes. */
|
||||
dismissible?: boolean
|
||||
link?: string
|
||||
link_label?: string
|
||||
}
|
||||
|
||||
export const INSTANCE_BANNER_SETTING = 'instance_banner'
|
||||
|
||||
/** Mirror `INSTANCE_BANNER_MESSAGE_MAX_LEN` / `INSTANCE_BANNER_LINK_LABEL_MAX_LEN` in
|
||||
* backend/windmill-common/src/global_settings.rs, which reject longer values at write time. */
|
||||
export const INSTANCE_BANNER_MESSAGE_MAX_LEN = 500
|
||||
export const INSTANCE_BANNER_LINK_LABEL_MAX_LEN = 60
|
||||
|
||||
export type ResolvedInstanceBanner = {
|
||||
message: string
|
||||
severity: InstanceBannerSeverity
|
||||
dismissible: boolean
|
||||
link?: string
|
||||
linkLabel: string
|
||||
/** Dismissal token: a viewer who dismissed one announcement sees the next one,
|
||||
* because editing any displayed part of the banner changes this string. */
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a stored banner field as a string.
|
||||
*
|
||||
* The setting is a raw `global_settings` row, so its shape is only ever as good as the
|
||||
* writer that last touched it — and anything here that calls `.trim()` on a number throws,
|
||||
* taking the whole settings form down with it.
|
||||
*/
|
||||
export function bannerString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate to `max` code points, matching the backend's `chars().count()` cap.
|
||||
*
|
||||
* `String.slice` counts UTF-16 code units, so it would cut a 500-emoji message the backend
|
||||
* accepted in half — and the two sides must agree on what "500 characters" means.
|
||||
*/
|
||||
function truncateChars(value: string, max: number): string {
|
||||
const chars = [...value]
|
||||
return chars.length > max ? chars.slice(0, max).join('') : value
|
||||
}
|
||||
|
||||
export function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the raw setting into what the banner renders, or `undefined` for "show nothing".
|
||||
*
|
||||
* The scheme check repeats the one the writers run on purpose. The link becomes the href of
|
||||
* an anchor shown to every user of the instance, and this is the last place that can refuse
|
||||
* it — a row predating the validator, or written straight to the table, reaches here having
|
||||
* passed nothing.
|
||||
*/
|
||||
export function resolveInstanceBanner(raw: unknown): ResolvedInstanceBanner | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined
|
||||
const banner = raw as InstanceBanner
|
||||
const message = bannerString(banner.message).trim()
|
||||
if (banner.enabled !== true || message === '') return undefined
|
||||
|
||||
const severity: InstanceBannerSeverity =
|
||||
banner.severity === 'warning' || banner.severity === 'error' ? banner.severity : 'info'
|
||||
const rawLink = bannerString(banner.link).trim()
|
||||
const link = isHttpUrl(rawLink) ? rawLink : undefined
|
||||
const linkLabel =
|
||||
truncateChars(bannerString(banner.link_label).trim(), INSTANCE_BANNER_LINK_LABEL_MAX_LEN) ||
|
||||
'Learn more'
|
||||
|
||||
return {
|
||||
message: truncateChars(message, INSTANCE_BANNER_MESSAGE_MAX_LEN),
|
||||
severity,
|
||||
dismissible: banner.dismissible !== false,
|
||||
link,
|
||||
linkLabel,
|
||||
fingerprint: JSON.stringify([message, severity, link ?? '', link ? linkLabel : ''])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason the banner form cannot be saved, or `undefined` when it can.
|
||||
*
|
||||
* Shared with the setting's `isValid` so the Save button and the inline message agree: the
|
||||
* backend refuses a bad link, and a category save fires its settings concurrently, so a Save
|
||||
* that got this far would persist the other Core settings and fail only the banner.
|
||||
*/
|
||||
export function instanceBannerFormError(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const link = bannerString((value as InstanceBanner).link).trim()
|
||||
return link !== '' && !isHttpUrl(link) ? 'Link must be an absolute http(s) URL' : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a viewer holding `dismissedFingerprint` should see this announcement.
|
||||
*
|
||||
* A non-dismissible announcement ignores stored dismissals entirely: an admin escalating an
|
||||
* existing notice to mandatory must reach the people who already dismissed it, and the
|
||||
* fingerprint deliberately does not cover `dismissible`, so nothing else would bring it back.
|
||||
*/
|
||||
export function isInstanceBannerVisible(
|
||||
banner: ResolvedInstanceBanner | undefined,
|
||||
dismissedFingerprint: string
|
||||
): banner is ResolvedInstanceBanner {
|
||||
if (banner == undefined) return false
|
||||
return !banner.dismissible || dismissedFingerprint !== banner.fingerprint
|
||||
}
|
||||
|
||||
export async function fetchInstanceBanner(): Promise<ResolvedInstanceBanner | undefined> {
|
||||
return resolveInstanceBanner(await SettingService.getGlobal({ key: INSTANCE_BANNER_SETTING }))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ButtonType } from './common/button/model'
|
||||
import { z } from 'zod'
|
||||
import { instanceBannerFormError } from './instanceBanner'
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
/**
|
||||
@@ -68,6 +69,7 @@ export interface Setting {
|
||||
| 'webhook_base_url'
|
||||
| 'ws_connectivity'
|
||||
| 'retention_overrides'
|
||||
| 'instance_banner'
|
||||
storage: SettingStorage
|
||||
advancedToggle?: {
|
||||
label: string
|
||||
@@ -236,6 +238,19 @@ export const settings: Record<string, Setting[]> = {
|
||||
placeholder: 'only for EE',
|
||||
storage: 'setting'
|
||||
},
|
||||
{
|
||||
label: 'Announcement banner',
|
||||
description:
|
||||
'Message shown above every page of the instance, for maintenance windows and incidents.',
|
||||
key: 'instance_banner',
|
||||
fieldType: 'instance_banner',
|
||||
storage: 'setting',
|
||||
// The banner only renders on the managed cloud, so only offer it there.
|
||||
cloudonly: true,
|
||||
hideInQuickSetup: true,
|
||||
// Gates Save. The card renders the specific message itself, so no `error` here.
|
||||
isValid: (value: any) => instanceBannerFormError(value) == undefined
|
||||
},
|
||||
{
|
||||
label: 'Non-prod instance',
|
||||
description:
|
||||
|
||||
@@ -65,33 +65,46 @@
|
||||
description="People your identity provider authenticated who opened an app set to Guests without a Windmill account. One email is one guest, however many workspaces it opened."
|
||||
/>
|
||||
|
||||
<div class="flex flex-row gap-2 items-center mb-4">
|
||||
{#key usage}
|
||||
<Toggle
|
||||
bind:checked={switchOn}
|
||||
disabled={switchPending}
|
||||
on:change={(e) => setInstanceSwitch(e.detail)}
|
||||
options={{
|
||||
right: 'Allow guests on this instance',
|
||||
rightTooltip:
|
||||
'Off, no guest can sign in anywhere, whatever a workspace or an app says, and sessions already issued stop on their next request.'
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{#if !usage.available}
|
||||
<div class="mb-4">
|
||||
<Alert type="info" size="xs" title="Guests are not available on Windmill Cloud">
|
||||
No guest can sign in here, whatever a workspace or an app says. Guests require a self-hosted
|
||||
instance or a dedicated Windmill Cloud deployment.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row gap-2 items-center mb-4">
|
||||
{#key usage}
|
||||
<Toggle
|
||||
bind:checked={switchOn}
|
||||
disabled={switchPending}
|
||||
on:change={(e) => setInstanceSwitch(e.detail)}
|
||||
options={{
|
||||
right: 'Allow guests on this instance',
|
||||
rightTooltip:
|
||||
'Off, no guest can sign in anywhere, whatever a workspace or an app says, and sessions already issued stop on their next request.'
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<Alert type={pastAllowance ? 'warning' : 'info'} size="xs" title="{usage.guest_count} of {usage.free_allowance} free guests used in the last {usage.window_days} days">
|
||||
{#if usage.metered}
|
||||
Beyond the allowance, every four guests count as one seat{usage.guest_seats > 0
|
||||
? `: ${usage.billable_guests} guests past it take ${usage.guest_seats} ${usage.guest_seats === 1 ? 'seat' : 'seats'} now`
|
||||
: ''}.
|
||||
{:else}
|
||||
Beyond the allowance, new guests are refused until the count drops below it; an
|
||||
Enterprise license meters them instead.
|
||||
{/if}
|
||||
</Alert>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<Alert
|
||||
type={pastAllowance ? 'warning' : 'info'}
|
||||
size="xs"
|
||||
title="{usage.guest_count} of {usage.free_allowance} free guests used in the last {usage.window_days} days"
|
||||
>
|
||||
{#if usage.metered}
|
||||
Beyond the allowance, every four guests count as one seat{usage.guest_seats > 0
|
||||
? `: ${usage.billable_guests} guests past it take ${usage.guest_seats} ${usage.guest_seats === 1 ? 'seat' : 'seats'} now`
|
||||
: ''}.
|
||||
{:else}
|
||||
Beyond the allowance, new guests are refused until the count drops below it; an Enterprise
|
||||
license meters them instead.
|
||||
{/if}
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DataTable
|
||||
shouldLoadMore={hasMore}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from 'svelte/store'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { classes as alertClasses, icons as alertIcons } from '../common/alert/model'
|
||||
import {
|
||||
bannerString,
|
||||
INSTANCE_BANNER_LINK_LABEL_MAX_LEN,
|
||||
INSTANCE_BANNER_MESSAGE_MAX_LEN,
|
||||
INSTANCE_BANNER_SETTING,
|
||||
instanceBannerFormError,
|
||||
resolveInstanceBanner,
|
||||
type InstanceBanner,
|
||||
type InstanceBannerSeverity
|
||||
} from '../instanceBanner'
|
||||
|
||||
interface Props {
|
||||
values: Writable<Record<string, any>>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
let { values, disabled = false }: Props = $props()
|
||||
|
||||
// Writes go through `$values[...]` member assignments: only those push the nested
|
||||
// mutation back into the store, which is what the unsaved-changes check and the Save
|
||||
// button watch. Reads go through a *snapshot* because the form mutates that object in
|
||||
// place — a derived returning the object itself keeps its identity across edits, and
|
||||
// Svelte then stops propagating to whatever depends on it. Every getter below applies
|
||||
// the same default `resolveInstanceBanner` does, so a value missing a field displays
|
||||
// as it will actually render.
|
||||
let banner: InstanceBanner = $derived(
|
||||
$state.snapshot($values[INSTANCE_BANNER_SETTING] ?? {}) as InstanceBanner
|
||||
)
|
||||
|
||||
const severities: { value: InstanceBannerSeverity; label: string }[] = [
|
||||
{ value: 'info', label: 'Info' },
|
||||
{ value: 'warning', label: 'Warning' },
|
||||
{ value: 'error', label: 'Critical' }
|
||||
]
|
||||
|
||||
// Runs the resolver the banner itself uses, so this shows what the instance gets —
|
||||
// including the "nothing is shown" cases (disabled, or an empty message).
|
||||
let preview = $derived(resolveInstanceBanner(banner))
|
||||
let linkError = $derived(instanceBannerFormError(banner))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<Toggle
|
||||
{disabled}
|
||||
bind:checked={
|
||||
() => banner.enabled === true, (v) => ($values[INSTANCE_BANNER_SETTING].enabled = v)
|
||||
}
|
||||
options={{ right: 'Show the banner to every user' }}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-secondary text-xs">Message</span>
|
||||
<TextInput
|
||||
underlyingInputEl="textarea"
|
||||
size="sm"
|
||||
class="min-h-14 resize-y"
|
||||
inputProps={{
|
||||
disabled,
|
||||
rows: 2,
|
||||
maxlength: INSTANCE_BANNER_MESSAGE_MAX_LEN,
|
||||
placeholder: 'Scheduled maintenance on Saturday 12:00–14:00 UTC. Jobs may be delayed.'
|
||||
}}
|
||||
bind:value={
|
||||
() => bannerString(banner.message),
|
||||
(v) => ($values[INSTANCE_BANNER_SETTING].message = String(v))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-secondary text-xs">Severity</span>
|
||||
<ToggleButtonGroup
|
||||
{disabled}
|
||||
bind:selected={
|
||||
() => bannerString(banner.severity) || 'info',
|
||||
(v) => ($values[INSTANCE_BANNER_SETTING].severity = v)
|
||||
}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
{#each severities as severity (severity.value)}
|
||||
<ToggleButton value={severity.value} label={severity.label} {item} />
|
||||
{/each}
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-secondary text-xs">Link (optional)</span>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<TextInput
|
||||
size="sm"
|
||||
inputProps={{ type: 'text', disabled, placeholder: 'https://status.windmill.dev' }}
|
||||
error={linkError}
|
||||
bind:value={
|
||||
() => bannerString(banner.link),
|
||||
(v) => ($values[INSTANCE_BANNER_SETTING].link = String(v))
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
size="sm"
|
||||
inputProps={{
|
||||
type: 'text',
|
||||
disabled,
|
||||
maxlength: INSTANCE_BANNER_LINK_LABEL_MAX_LEN,
|
||||
placeholder: 'Learn more'
|
||||
}}
|
||||
bind:value={
|
||||
() => bannerString(banner.link_label),
|
||||
(v) => ($values[INSTANCE_BANNER_SETTING].link_label = String(v))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{#if linkError}
|
||||
<span class="text-red-600 dark:text-red-400 text-xs">{linkError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
{disabled}
|
||||
bind:checked={
|
||||
() => banner.dismissible !== false, (v) => ($values[INSTANCE_BANNER_SETTING].dismissible = v)
|
||||
}
|
||||
options={{
|
||||
right: 'Let users dismiss it',
|
||||
rightTooltip:
|
||||
'Dismissal is remembered per browser and only for this exact announcement: editing the message, severity or link brings it back for everyone. Turning this off also shows it again to everyone who had dismissed it.'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-secondary text-xs">Preview</span>
|
||||
{#if preview}
|
||||
{@const palette = alertClasses[preview.severity]}
|
||||
{@const Icon = alertIcons[preview.severity]}
|
||||
<div
|
||||
class="px-4 py-1.5 rounded-md flex items-center justify-center gap-3 flex-wrap {palette.bgClass}"
|
||||
>
|
||||
<Icon size={16} class="shrink-0 {palette.iconClass}" />
|
||||
<span class="text-xs font-medium {palette.titleClass}">{preview.message}</span>
|
||||
{#if preview.link}
|
||||
<span class="text-xs underline {palette.titleClass}">{preview.linkLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-tertiary text-xs">
|
||||
{banner.enabled === true
|
||||
? 'Nothing is shown until the message is filled in.'
|
||||
: 'Nothing is shown while the banner is off.'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user