From 94ac34a4891a8572ad2493bdbbbf2f4911236aff Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 30 Sep 2026 13:02:30 +0200 Subject: [PATCH 01/31] fix: await async def main in python scripts (#11437) * fix: await async main and preprocessor in python scripts * fix: reuse one event loop per python process for async main * fix: reuse the event loop a python script sets at import * docs: clarify python await helper comment --- backend/tests/python_jobs.rs | 74 +++++++++++++++++++ .../windmill-worker/src/python_executor.rs | 45 +++++++++-- 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index b7aa671b21..9616df267a 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -465,6 +465,37 @@ def main(x: int): ); } + #[test] + fn test_python_execd_async() { + let script = r#" +import asyncio + +_import_loop = asyncio.new_event_loop() +asyncio.set_event_loop(_import_loop) + +async def preprocessor(x: int): + return {"x": x * 2} + +async def main(x: int): + return {"x": x + 100, "same_loop": asyncio.get_running_loop() is _import_loop} +"#; + let results = run_py_raw_protocol_test( + &[("f/test/async", script)], + vec![ + ProtocolCmd::ExecdPreprocess { args: serde_json::json!({"x": 5}) }, + ProtocolCmd::Execd { args: serde_json::json!({"x": 7}) }, + ], + ); + assert_eq!( + results, + vec![ + DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10})), + DedicatedWorkerResult::Success(serde_json::json!({"x": 110, "same_loop": true})), + DedicatedWorkerResult::Success(serde_json::json!({"x": 107, "same_loop": true})), + ] + ); + } + // ==================== Argument Transformation Tests ==================== #[test] @@ -760,6 +791,49 @@ def main(): Ok(()) } +#[cfg(feature = "python")] +#[sqlx::test(fixtures("base"))] +async fn test_python_async_main(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#" +import asyncio + +async def main(x: int): + await asyncio.sleep(0) + return x + 1 + "# + .to_owned(); + + let job = JobPayload::Code(RawCode { + hash: None, + content, + path: None, + language: ScriptLang::Python3, + lock: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + }); + + let result = RunJob::from(job) + .arg("x", serde_json::json!(41)) + .run_until_complete(&db, false, port) + .await + .json_result() + .unwrap(); + + assert_eq!(result, serde_json::json!(42)); + Ok(()) +} + #[cfg(feature = "python")] #[sqlx::test(fixtures("base"))] async fn test_python_result_preserves_infinity_in_string(db: Pool) -> anyhow::Result<()> { diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 5f5ca170b4..6b24b93e77 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -828,7 +828,7 @@ pub async fn handle_python_job( for k, v in list(pre_args.items()): if v == '': del pre_args[k] - kwargs = inner_script.preprocessor(**pre_args) + kwargs = _await(inner_script.preprocessor(**pre_args)) kwrags_json = res_to_json(kwargs, type(kwargs)) with open("args.json", 'w', encoding="utf-8") as f: f.write(kwrags_json)"# @@ -969,6 +969,8 @@ result_json = os.path.join(os.path.abspath(os.path.dirname(__file__)), "result.j def res_to_json(res, typ): {res_to_json_body} +{PY_AWAIT_HELPER} + try: {preprocessor} {spread} @@ -977,7 +979,7 @@ try: del args[k] if inner_script.{main_override} is None or not callable(inner_script.{main_override}): raise ValueError("{main_override} function is missing") - res = inner_script.{main_override}(**args) + res = _await(inner_script.{main_override}(**args)) typ = type(res) if hasattr(res, '__iter__') and not isinstance(res, (str, dict, list, bytes, tuple, set, frozenset, range, memoryview, bytearray)) and typ.__name__ != 'DataFrame': for chunk in res: @@ -1513,6 +1515,8 @@ _fix=lambda s:s if 'Infinity' not in s and 'NaN' not in s and '\\u0000' not in s def res_to_json(res, typ): {res_to_json_body} + +{PY_AWAIT_HELPER} {functions} {registrations} @@ -1546,11 +1550,11 @@ for line in sys.stdin: continue kwargs = json.loads(args_json, strict=False) pre_args = entry['pre_transform'](kwargs) - preprocessed = mod.preprocessor(**pre_args) + preprocessed = _await(mod.preprocessor(**pre_args)) preprocessed_json = json.dumps(preprocessed, separators=(',', ':'), default=str).replace('\n', '') sys.stdout.write("wm_res[preprocessed_args]:" + preprocessed_json + "\n") main_args = entry['transform'](preprocessed if preprocessed else {{}}) - res = mod.main(**main_args) + res = _await(mod.main(**main_args)) typ = type(res) res_json = res_to_json(res, typ) sys.stdout.write("wm_res[success]:" + res_json + "\n") @@ -1574,11 +1578,11 @@ for line in sys.stdin: continue kwargs = json.loads(args_json, strict=False) pre_args = entry['pre_transform'](kwargs) - preprocessed = mod.preprocessor(**pre_args) + preprocessed = _await(mod.preprocessor(**pre_args)) preprocessed_json = json.dumps(preprocessed, separators=(',', ':'), default=str).replace('\n', '') sys.stdout.write("wm_res[preprocessed_args]:" + preprocessed_json + "\n") main_args = entry['transform'](preprocessed if preprocessed else {{}}) - res = mod.main(**main_args) + res = _await(mod.main(**main_args)) typ = type(res) res_json = res_to_json(res, typ) sys.stdout.write("wm_res[success]:" + res_json + "\n") @@ -1596,7 +1600,7 @@ for line in sys.stdin: entry = next(iter(scripts.values())) kwargs = json.loads(args_json, strict=False) args = entry['transform'](kwargs) - res = entry['mod'].main(**args) + res = _await(entry['mod'].main(**args)) typ = type(res) res_json = res_to_json(res, typ) sys.stdout.write("wm_res[success]:" + res_json + "\n") @@ -1624,7 +1628,7 @@ for line in sys.stdin: kwargs = json.loads(args_json, strict=False) args = entry['transform'](kwargs) - res = entry['mod'].main(**args) + res = _await(entry['mod'].main(**args)) typ = type(res) res_json = res_to_json(res, typ) sys.stdout.write("wm_res[success]:" + res_json + "\n") @@ -3330,6 +3334,31 @@ This is not normal behavior, please make sure all workers have enough memory.\n }; } +/// Python helper running what an `async def` main or preprocessor returns. +/// One loop per process: a dedicated worker's module-level async clients and +/// locks are bound to the loop they were first used on, so `asyncio.run`'s +/// loop-per-call would break them from the second job on. A loop the script +/// set while importing (`asyncio.set_event_loop`) is reused. asyncio is +/// imported lazily so sync jobs don't pay its import time. +const PY_AWAIT_HELPER: &str = r#"_loop = None +def _await(r): + global _loop + if not hasattr(r, '__await__'): + return r + import asyncio + if _loop is None or _loop.is_closed(): + import warnings + try: + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + _loop = asyncio.get_event_loop() + if _loop.is_closed(): + raise RuntimeError + except RuntimeError: + _loop = asyncio.new_event_loop() + asyncio.set_event_loop(_loop) + return _loop.run_until_complete(r)"#; + /// Python function body for `res_to_json(res, typ)`. /// Handles DataFrame, bytes, dict coercion + JSON serialization. fn python_res_to_json_body(postprocessor: &str) -> String { From e117871becc04a84baabe2aa0db6ae6e58cdd058 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 30 Sep 2026 13:10:24 +0200 Subject: [PATCH 02/31] weekly ai evals on current models, and claude 5.5/gpt-6 support (#11409) * feat: run ai evals weekly on current models and post results to a dashboard Co-Authored-By: Claude Opus 5.5 * feat: add current flagship models, a reasoning flag and claude 5.5 defaults Co-Authored-By: Claude Opus 5.5 * fix: never send a reasoning disable claude 5.5 or gpt-6-astra reject, and treat gpt-6 as a reasoning model Co-Authored-By: Claude Opus 5.5 * fix: address review on gpt-6 support, chat completions tools and model metadata Co-Authored-By: Claude Opus 5.5 * fix: leave tiered gpt-6 unpriced and drop the off sentinel on gpt-5 and o-series Co-Authored-By: Claude Opus 5.5 * docs: point the ai_evals readme at the model registry instead of copying it Co-Authored-By: Claude Opus 5.5 * refactor: encode the reasoning rules as per-family maps with a shared parity fixture Co-Authored-By: Claude Opus 5.5 * fix: keep the chat completions tools rule open-ended past gpt-5.6 and scope the parity fixture Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/ai-evals-scheduled.yml | 171 ++++++ ai_evals/README.md | 17 +- .../frontend/core/script/scriptEvalRunner.ts | 10 +- .../frontend/core/shared/providerConfig.ts | 13 + ai_evals/bun.lock | 120 +---- ai_evals/cli/index.ts | 18 + ai_evals/core/judge.ts | 14 +- ai_evals/core/models.ts | 71 +++ ai_evals/package.json | 4 +- backend/windmill-ai/src/providers/mod.rs | 164 ++++++ backend/windmill-ai/src/providers/other.rs | 10 + backend/windmill-ai/src/types.rs | 5 +- frontend/src/lib/components/copilot/lib.ts | 22 +- .../components/copilot/modelConfig.test.ts | 7 + .../src/lib/components/copilot/modelConfig.ts | 4 +- .../components/copilot/modelPricing.test.ts | 10 + .../lib/components/copilot/modelPricing.ts | 5 + .../components/copilot/reasoningParity.json | 84 +++ .../copilot/reasoningRegistry.test.ts | 57 ++ .../components/copilot/reasoningRegistry.ts | 496 ++++++++---------- 20 files changed, 888 insertions(+), 414 deletions(-) create mode 100644 .github/workflows/ai-evals-scheduled.yml create mode 100644 frontend/src/lib/components/copilot/reasoningParity.json diff --git a/.github/workflows/ai-evals-scheduled.yml b/.github/workflows/ai-evals-scheduled.yml new file mode 100644 index 0000000000..964e5d0cab --- /dev/null +++ b/.github/workflows/ai-evals-scheduled.yml @@ -0,0 +1,171 @@ +name: AI Evals (scheduled) + +# Full ai_evals suites on current models, posted to the AI evals dashboard in +# the windmill-prod workspace (f/ai/ai_evals_dashboard) so quality is tracked +# over time. Weekly, since one pass of every suite at --runs 3 costs about 50M +# tokens per model: every suite runs on MODELS, and global (the mode users get) +# also runs on GLOBAL_EXTRA_MODELS, one flagship per other provider. Run it by +# hand to measure a branch against main. +on: + schedule: + - cron: "0 3 * * 1" + workflow_dispatch: + inputs: + modes: + description: "Space-separated modes" + default: "global flow app script cli" + models: + description: "Space-separated model aliases (bun run cli -- models)" + default: "sonnet-5.5" + global_extra_models: + description: "Extra model aliases for global mode only" + default: "gpt-6-astra gemini-3.8-flash" + runs: + description: "Runs per case" + default: "3" + reasoning: + description: "Reasoning effort for frontend modes (empty: the product default)" + default: "" + +concurrency: + group: ai-evals-scheduled-${{ github.ref }} + +env: + MODELS: ${{ inputs.models || 'sonnet-5.5' }} + GLOBAL_EXTRA_MODELS: ${{ inputs.global_extra_models || 'gpt-6-astra gemini-3.8-flash' }} + RUNS: ${{ inputs.runs || '3' }} + REASONING: ${{ inputs.reasoning }} + INGEST_URL: https://app.windmill.dev/api/r/f/ai/ingest_ai_eval_run + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + modes: ${{ steps.modes.outputs.modes }} + steps: + - id: modes + env: + MODES: ${{ inputs.modes || 'global flow app script cli' }} + run: | + echo "modes=$(jq -cn --arg m "$MODES" '$m | split(" ") + | map(select(IN("global", "flow", "app", "script", "cli")))')" >> "$GITHUB_OUTPUT" + + evals: + needs: setup + runs-on: ubicloud-standard-16 + timeout-minutes: 330 + strategy: + fail-fast: false + matrix: + mode: ${{ fromJSON(needs.setup.outputs.modes) }} + services: + postgres: + image: postgres:16 + ports: + - 5432:5432 + env: + POSTGRES_DB: windmill + POSTGRES_PASSWORD: changeme + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + cache-workspaces: backend + toolchain: 1.97.0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + + - name: Build Windmill + working-directory: ./backend + env: + SQLX_OFFLINE: true + CARGO_BUILD_JOBS: 12 + RUSTFLAGS: "" + run: cargo build --features quickjs + + - name: Start Windmill + working-directory: ./backend + env: + DATABASE_URL: postgres://postgres:changeme@localhost:5432/windmill + RUST_LOG: info + run: | + mkdir -p ../ai_evals/logs + ./target/debug/windmill > ../ai_evals/logs/windmill.log 2>&1 & + for i in $(seq 1 60); do + curl -sf http://localhost:8000/api/version > /dev/null 2>&1 && break + sleep 2 + done + curl -sf http://localhost:8000/api/version > /dev/null || { tail -50 ../ai_evals/logs/windmill.log; exit 1; } + + - name: Install frontend deps + generate client + working-directory: ./frontend + run: | + npm ci + npm run generate-backend-client + + - name: Install CLI deps + generate CLI client + working-directory: ./cli + run: bun install && ./gen_wm_client.sh && ./windmill-utils-internal/gen_wm_client.sh + + - name: Run ${{ matrix.mode }} evals + working-directory: ./ai_evals + env: + MODE: ${{ matrix.mode }} + WMILL_AI_EVAL_BACKEND_URL: http://localhost:8000 + WMILL_AI_EVAL_BACKEND_WORKSPACE: integration-tests + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + run: | + bun install + mkdir -p results + models="$MODELS" + [ "$MODE" = global ] && models="$models $GLOBAL_EXTRA_MODELS" + reasoning=() + [ -n "$REASONING" ] && [ "$MODE" != cli ] && reasoning=(--reasoning "$REASONING") + for m in $models; do + bun run cli -- run "$MODE" --model "$m" --runs "$RUNS" "${reasoning[@]}" \ + --output "$PWD/results/$MODE-$m.json" || echo "::warning::$MODE on $m errored" + done + + - name: Post results to the dashboard + if: always() + working-directory: ./ai_evals + env: + MODE: ${{ matrix.mode }} + INGEST_TOKEN: ${{ secrets.AI_EVALS_INGEST_TOKEN }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + [ -n "$INGEST_TOKEN" ] || { echo "::warning::AI_EVALS_INGEST_TOKEN is not set"; exit 0; } + shopt -s nullglob + for f in results/"$MODE"-*.json; do + # Keep what the dashboard reads; the full traces stay in the run's artifacts. + jq -c --arg ref "$GITHUB_REF" --arg trigger "$GITHUB_EVENT_NAME" --arg url "$RUN_URL" ' + {result_json: (del(.cases[].prompt, .cases[].initialPath, .cases[].expectedPath) + | .cases[].attempts[] |= {attempt, passed, durationMs, toolCallCount, judgeScore, + judgeSummary, error, tokenUsage, checks: [.checks[]? | {name, passed}]}), + git_ref: $ref, trigger: $trigger, run_url: $url}' "$f" \ + | curl -sf --retry 3 -X POST "$INGEST_URL" \ + -H "Authorization: Bearer $INGEST_TOKEN" -H 'content-type: application/json' \ + --data-binary @- && echo " <- $f" || echo "::warning::failed to post $f" + done + + - name: Archive logs and results + uses: actions/upload-artifact@v4 + if: always() + with: + name: ai-evals-${{ matrix.mode }} + path: | + ai_evals/logs + ai_evals/results diff --git a/ai_evals/README.md b/ai_evals/README.md index 603f28e5f9..bff4294e52 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -77,31 +77,20 @@ Public CLI surface: - `--verbose`: stream assistant output for frontend runs - `--skip-judge`: skip LLM judge scoring for the run - `--execution-only`: only require the model/proxy/frontend loop to complete; skip validators, tool expectations, backend artifact validation, and judge scoring +- `--reasoning `: reasoning effort for frontend modes (`off`, `low`, `medium`, `high`, `max`, …); without it the product's default applies (`high` on models that can reason). The effort is appended to the recorded model label (`anthropic:claude-sonnet-5-5@max`) - `--record`: append a compact tracked summary line to `ai_evals/history/.jsonl` for full-suite runs only - `--backend-validation `: optional backend smoke validation (`off` or `preview`) for `script` and `flow` evals ## Models -Use `bun run cli -- models` to see the current aliases. - -Today: - -- `haiku` -- `sonnet` -- `opus` -- `4o` -- `gpt-5.5` -- `gemini-3-flash-preview` -- `gemini-3.1-pro-preview` -- `deepseek-v4-flash` -- `deepseek-v4-pro` +Use `bun run cli -- models` to see the current aliases; `core/models.ts` is the list. Notes: - the command also prints accepted alias spellings such as `gpt-4o`, `gpt-55`, `claude-opus-4.6`, and `claude-haiku-4.5` - frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, Gemini, and DeepSeek-backed aliases - `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there -- the judge model is separate and currently defaults to `claude-sonnet-4-6`; use `--skip-judge` for deterministic-only runs +- the judge model is separate and currently defaults to `claude-sonnet-5-5`; use `--skip-judge` for deterministic-only runs ## Case Format diff --git a/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts b/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts index b9c80f3554..0ff352f3d4 100644 --- a/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts @@ -15,6 +15,7 @@ import { runEval } from "../shared"; import type { ModeRunContext } from "../../../../core/types"; import type { TokenUsage, ToolCallDetail } from "../shared/types"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; +import { evalReasoningEffort } from "../shared/providerConfig"; export interface ScriptEvalResult { success: boolean; @@ -41,14 +42,15 @@ export interface ScriptEvalOptions { function resolveModelProvider( model: string, provider?: AIProvider, -): AIProviderModel { +): AIProviderModel & { reasoning?: string } { + const reasoning = evalReasoningEffort(); if (provider) { - return { provider, model }; + return { provider, model, reasoning }; } if (model.startsWith("claude")) { - return { provider: "anthropic", model }; + return { provider: "anthropic", model, reasoning }; } - return { provider: "openai", model }; + return { provider: "openai", model, reasoning }; } export async function runScriptEval( diff --git a/ai_evals/adapters/frontend/core/shared/providerConfig.ts b/ai_evals/adapters/frontend/core/shared/providerConfig.ts index 9d9e315217..49d5cb6ef8 100644 --- a/ai_evals/adapters/frontend/core/shared/providerConfig.ts +++ b/ai_evals/adapters/frontend/core/shared/providerConfig.ts @@ -12,6 +12,12 @@ export interface EvalClients { export interface ResolvedEvalModelProvider { provider: FrontendEvalProvider; model: string; + reasoning?: string; +} + +/** `run --reasoning` hands the effort to the frontend runtime through the environment. */ +export function evalReasoningEffort(): string | undefined { + return process.env.WMILL_AI_EVAL_REASONING || undefined; } export interface WindmillAiProxyClientConfig { @@ -73,6 +79,13 @@ export function createEvalClients(input: { export function resolveEvalModelProvider( model: string, provider?: FrontendEvalProvider, +): ResolvedEvalModelProvider { + return { ...resolveProvider(model, provider), reasoning: evalReasoningEffort() }; +} + +function resolveProvider( + model: string, + provider?: FrontendEvalProvider, ): ResolvedEvalModelProvider { if (provider) { return { provider, model }; diff --git a/ai_evals/bun.lock b/ai_evals/bun.lock index eaed1db99a..798022d49a 100644 --- a/ai_evals/bun.lock +++ b/ai_evals/bun.lock @@ -5,8 +5,8 @@ "": { "name": "windmill-ai-evals", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.25", - "@anthropic-ai/sdk": "^0.39.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.284", + "@anthropic-ai/sdk": "^0.129.0", "commander": "^14.0.3", "openai": "^6.9.1", "yaml": "^2.8.3", @@ -18,66 +18,44 @@ }, }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.87", "", { "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "@modelcontextprotocol/sdk": "^1.27.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-WWmgBPxPhBOvNT0ujI8vPTI2lK+w5YEkEZ/y1mH0EDkK/0kBnxVJNhCtG5vnueiAViwLoUOFn66pbkDiivijdA=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.284", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.284", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.284", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.284", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.284", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.284", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.284", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.284", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.284" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-NSoJwEq6nFSf8dtaacYx37QdGgqApI3eHFUlxclMLYi8irb6ZJwEaUnPnLUCyAyg9W/tMkgZC0GXWLRCc01I0w=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.284", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gKY9MUjY83398uCiPLHsd87kyzu7agIM7ApqWpJkpSINep6hxx4rNoR8bUbNWg49Aoe/PW/DJkQByAQuNzR9rg=="], - "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.284", "", { "os": "darwin", "cpu": "x64" }, "sha512-P+q6Z7sKeYz99uE7RJc4au1IK+4SiWe69pyHY3QXMnjZq8hSFU/5Mo8iSurLk9jRlihsMltV1rLA5naouYyCAQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.284", "", { "os": "linux", "cpu": "arm64" }, "sha512-LDpuYDaz+pCdG29iy1pw4P1D2YVtpZOb4rWOPxxSpg2fFyUeQ5WrfnuszOxhDWpNh1k1ekDiKm3A9OzGcrkdtA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.284", "", { "os": "linux", "cpu": "arm64" }, "sha512-U3/uAv1TS8sMWsgWQSxYbdND5AaRXXpP5RxHU1oYC5ZizlZE8/xAVum9dj1QX3rVZJg8EzsqZveq/AkEv5ABfQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.284", "", { "os": "linux", "cpu": "x64" }, "sha512-yGytBCCwJvWeg1FzFpgnLM9BOM2vKVExtv6pMJHMtF82J5yhKHcxodRaYlQVd/HzJJ2nruKb9EnrhRBZ//4yRw=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.284", "", { "os": "linux", "cpu": "x64" }, "sha512-4x0Q8CFkCNbeENE7pRhuUmkwh/sQfTq1yWi4ZE56stve9pgoKzu6ZDa1SEtvK1YIooGE/qM5rVS8xFLE1gbQ0w=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.284", "", { "os": "win32", "cpu": "arm64" }, "sha512-zOXkdPHhElyxyFJ6eY5R+YGUswqBDZ+ZHNaRmQGJ28RkQYgwG5imNy/nfFR+/CPGlMnH9ZzbGc2rKYApm1K4Tg=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.284", "", { "os": "win32", "cpu": "x64" }, "sha512-VGaFRDCOPloj5IjvJTyy5JsSl9sE2HR3W+A6f/cjgh23/7Y5vK7/ka+1JQD+AkoGO9tbHqP2O6god3IBrLvOaw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.129.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], "@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], - "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - - "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], - - "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], @@ -88,8 +66,6 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], @@ -106,8 +82,6 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -122,14 +96,10 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], @@ -140,16 +110,12 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - - "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], - - "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -164,16 +130,12 @@ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -208,10 +170,6 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - - "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -262,30 +220,24 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], - - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - - "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -295,19 +247,5 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - - "@anthropic-ai/claude-agent-sdk/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.74.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw=="], - - "@types/node-fetch/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - - "bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "@types/node-fetch/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], } } diff --git a/ai_evals/cli/index.ts b/ai_evals/cli/index.ts index 504b8a8d13..8a5f63c8b2 100644 --- a/ai_evals/cli/index.ts +++ b/ai_evals/cli/index.ts @@ -108,6 +108,10 @@ async function main() { "--record", "append a compact summary line to ai_evals/history/.jsonl", ) + .option( + "--reasoning ", + "reasoning effort for frontend modes (e.g. off, low, medium, high, max); default: the product's", + ) .option( "--backend-validation ", `backend smoke validation (${BACKEND_VALIDATION_MODES.join(", ")})`, @@ -126,6 +130,7 @@ async function main() { executionOnly?: boolean; record?: boolean; backendValidation?: string; + reasoning?: string; }, ) => { await handleRun({ @@ -140,6 +145,7 @@ async function main() { executionOnly: options.executionOnly ?? false, record: options.record ?? false, backendValidation: options.backendValidation, + reasoning: options.reasoning, }); }, ); @@ -190,6 +196,7 @@ async function handleRun(input: { executionOnly: boolean; record: boolean; backendValidation?: string; + reasoning?: string; }) { if (input.record && input.caseIds.length > 0) { throw new Error( @@ -217,6 +224,13 @@ async function handleRun(input: { "--backend-validation currently supports only flow and script modes", ); } + if (input.reasoning) { + if (input.mode === "cli") { + throw new Error("--reasoning only applies to frontend modes"); + } + // The frontend runtime runs in a child process, which inherits it. + process.env.WMILL_AI_EVAL_REASONING = input.reasoning; + } if (input.mode !== "cli") { await assertWindmillBackendReachable(resolveWindmillBackendSettings()); } @@ -257,6 +271,10 @@ async function handleRun(input: { backendValidation, }); + if (input.reasoning && result.runModel) { + result.runModel = `${result.runModel}@${input.reasoning}`; + } + const resolvedOutputPath = models.length === 1 ? resolveRunOutputPath(input.mode, input.outputPath) diff --git a/ai_evals/core/judge.ts b/ai_evals/core/judge.ts index 5e67510027..f1df60f286 100644 --- a/ai_evals/core/judge.ts +++ b/ai_evals/core/judge.ts @@ -1,7 +1,7 @@ import Anthropic from "@anthropic-ai/sdk"; import type { EvalMode, JudgeResult } from "./types"; -export const DEFAULT_JUDGE_MODEL = "claude-sonnet-4-6"; +export const DEFAULT_JUDGE_MODEL = "claude-sonnet-5-5"; const JUDGE_TOOL_NAME = "submit_judgement"; @@ -29,6 +29,7 @@ export async function judgeOutput(input: { const system = [ "You evaluate benchmark outputs for Windmill AI generation.", + `Always answer by calling the ${JUDGE_TOOL_NAME} tool.`, "Deterministic checks already run separately. Focus on whether the final output satisfies the user request.", "If expected state is provided, treat it as a valid example and reward semantically equivalent outputs.", "If a checklist is provided, treat it as the explicit acceptance criteria for this case.", @@ -69,8 +70,8 @@ export async function judgeOutput(input: { try { const response = await client.messages.create({ model, - max_tokens: 1024, - temperature: 0, + // The judge thinks by default, and thinking shares this budget with the verdict. + max_tokens: 16000, system, messages: [{ role: "user", content: user }], tools: [ @@ -93,11 +94,8 @@ export async function judgeOutput(input: { }, }, ], - tool_choice: { - type: "tool", - name: JUDGE_TOOL_NAME, - disable_parallel_tool_use: true, - }, + // Current models refuse a forced tool_choice ("tool"/"any"). + tool_choice: { type: "auto", disable_parallel_tool_use: true }, }); const toolUseBlock = response.content.find( diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 295cd36135..1580cf5b0b 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -78,6 +78,32 @@ export const EVAL_MODELS: EvalModelSpec[] = [ model: "opus", }, }, + { + id: "sonnet-5.5", + label: "Claude Sonnet 5.5", + aliases: ["sonnet-5.5", "claude-sonnet-5.5", "claude-sonnet-5-5"], + frontend: { + provider: "anthropic", + model: "claude-sonnet-5-5", + }, + cli: { + provider: "anthropic", + model: "claude-sonnet-5-5", + }, + }, + { + id: "opus-5.5", + label: "Claude Opus 5.5", + aliases: ["opus-5.5", "claude-opus-5.5", "claude-opus-5-5"], + frontend: { + provider: "anthropic", + model: "claude-opus-5-5", + }, + cli: { + provider: "anthropic", + model: "claude-opus-5-5", + }, + }, { id: "4o", label: "GPT-4o", @@ -96,6 +122,51 @@ export const EVAL_MODELS: EvalModelSpec[] = [ model: "gpt-5.5", }, }, + { + id: "gpt-5.6-sol", + label: "GPT-5.6 Sol", + aliases: ["gpt-5.6-sol"], + frontend: { + provider: "openai", + model: "gpt-5.6-sol", + }, + }, + { + id: "gpt-6-astra", + label: "GPT-6 Astra", + aliases: ["gpt-6-astra", "gpt-6"], + frontend: { + provider: "openai", + model: "gpt-6-astra", + }, + }, + { + id: "gpt-6-sol", + label: "GPT-6 Sol", + aliases: ["gpt-6-sol"], + frontend: { + provider: "openai", + model: "gpt-6-sol", + }, + }, + { + id: "gpt-6-luna", + label: "GPT-6 Luna", + aliases: ["gpt-6-luna"], + frontend: { + provider: "openai", + model: "gpt-6-luna", + }, + }, + { + id: "gemini-3.8-flash", + label: "Gemini 3.8 Flash", + aliases: ["gemini-3.8-flash"], + frontend: { + provider: "googleai", + model: "gemini-3.8-flash", + }, + }, { id: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview", diff --git a/ai_evals/package.json b/ai_evals/package.json index 6720c910cb..424e49ffae 100644 --- a/ai_evals/package.json +++ b/ai_evals/package.json @@ -8,8 +8,8 @@ "test:frontend-graph": "cd ../frontend && node_modules/.bin/vitest run --project server --config ../ai_evals/adapters/frontend/vitest.unit.config.ts" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.25", - "@anthropic-ai/sdk": "^0.39.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.284", + "@anthropic-ai/sdk": "^0.129.0", "commander": "^14.0.3", "openai": "^6.9.1", "yaml": "^2.8.3" diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index 908f007978..b5b6281264 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -14,6 +14,130 @@ use std::time::{Duration, Instant}; /// its `thinking` param, Gemini to a zero budget or the model's floor). pub(crate) const REASONING_OFF_SENTINEL: &str = "none"; +/// The effort to send for a model, dropping the off sentinel on a model that rejects every +/// disable: the model then reasons at its default instead of failing the request. The UI +/// never offers off on these, so this guards an agent step saved before it stopped, or an +/// effort passed in as a flow input. +pub fn effective_reasoning_effort<'a>(model: &str, effort: Option<&'a str>) -> Option<&'a str> { + match effort { + Some(effort) + if effort == REASONING_OFF_SENTINEL + && reasoning_rule(model).is_some_and(|rule| !rule.can_disable) => + { + None + } + effort => effort, + } +} + +/// Whether a request with function tools must send the off sentinel on Chat Completions: +/// the model refuses tools there while it reasons, and does accept being turned off. +pub(crate) fn completions_tools_need_reasoning_off(model: &str) -> bool { + reasoning_rule(model).is_some_and(|rule| rule.completions_tools_need_off && rule.can_disable) +} + +/// What the backend needs to know about a model's reasoning: the rows of `REASONING_RULES` +/// in the frontend's `reasoningRegistry.ts`, cut down to the two facts the wire needs. +/// `reasoningParity.json` next to that file is checked by both sides' tests. +struct ReasoningRule { + matches: fn(&str) -> bool, + /// False when the provider rejects every disable, so the off sentinel must not be sent. + can_disable: bool, + /// Chat Completions refuses function tools while the model reasons, even with the + /// effort omitted (live-verified); the Responses API has no such limit. + completions_tools_need_off: bool, +} + +/// Matched in order against the lowercased model id, first match wins. A model no row +/// matches keeps whatever effort it was given. +const REASONING_RULES: &[ReasoningRule] = &[ + // Live-verified: Fable, Mythos and the 5.x point releases reject `thinking: disabled`. + ReasoningRule { + matches: |m| m.contains("claude-fable") || m.contains("claude-mythos"), + can_disable: false, + completions_tools_need_off: false, + }, + ReasoningRule { + matches: is_claude_5_point_release, + can_disable: false, + completions_tools_need_off: false, + }, + // Live-verified: astra takes low..max only, where sol and luna also take `none`. + ReasoningRule { + matches: |m| base_id(m).starts_with("gpt-6-astra"), + can_disable: false, + completions_tools_need_off: true, + }, + ReasoningRule { + matches: |m| gpt_version(m).is_some_and(|(major, _)| major >= 6), + can_disable: true, + completions_tools_need_off: true, + }, + ReasoningRule { + matches: |m| matches!(gpt_version(m), Some((5, Some(minor))) if minor >= 5), + can_disable: true, + completions_tools_need_off: true, + }, + ReasoningRule { + matches: |m| matches!(gpt_version(m), Some((5, Some(_)))), + can_disable: true, + completions_tools_need_off: false, + }, + // gpt-5 and the o-series reject `none`. + ReasoningRule { + matches: |m| matches!(gpt_version(m), Some((5, None))), + can_disable: false, + completions_tools_need_off: false, + }, + ReasoningRule { + matches: |m| { + let base = base_id(m); + base.starts_with('o') && base[1..].starts_with(|c: char| c.is_ascii_digit()) + }, + can_disable: false, + completions_tools_need_off: false, + }, +]; + +fn reasoning_rule(model: &str) -> Option<&'static ReasoningRule> { + let model = model.to_lowercase(); + REASONING_RULES.iter().find(|rule| (rule.matches)(&model)) +} + +/// The id after a gateway's `vendor/` and before a `:variant`. +fn base_id(model: &str) -> &str { + let last = model.rsplit('/').next().unwrap_or(model); + last.split(':').next().unwrap_or(last) +} + +/// `(major, minor)` of a `gpt-` id. The major is one digit then a separator or the end, +/// since Azure names gpt-3.5 `gpt-35-turbo`. +fn gpt_version(model: &str) -> Option<(u32, Option)> { + let rest = base_id(model).strip_prefix("gpt-")?; + let mut chars = rest.chars(); + let major = chars.next()?.to_digit(10)?; + match chars.next() { + None | Some('-') => Some((major, None)), + Some('.') => { + let minor: String = chars.take_while(char::is_ascii_digit).collect(); + Some((major, minor.parse().ok())) + } + _ => None, + } +} + +/// Sonnet or Opus 5.x with x >= 1. The version match stops at one digit so a dated id +/// (`claude-sonnet-5-20260101`) stays Sonnet 5. +fn is_claude_5_point_release(model: &str) -> bool { + let model = model.replace('.', "-"); + ["claude-opus-5-", "claude-sonnet-5-"].iter().any(|prefix| { + model.split(prefix).skip(1).any(|rest| { + let mut chars = rest.chars(); + matches!(chars.next(), Some('1'..='9')) && !matches!(chars.next(), Some('0'..='9')) + }) + }) +} + /// Whether a Claude model removed the sampling params (`temperature`, `top_p`, /// `top_k`). On these, any value is a hard 400 — `temperature is deprecated for /// this model` — whatever the thinking mode, so the param has to be dropped on @@ -119,6 +243,46 @@ pub fn remember_chat_completions_only(base_url: &str, model: &str) { CHAT_COMPLETIONS_ONLY.insert((base_url.to_string(), model.to_string()), Instant::now()); } +#[cfg(test)] +mod reasoning_rule_tests { + use super::*; + + /// The frontend registry's test reads the same file. These rules see the model id + /// alone, so it holds only rows whose answer doesn't depend on the provider. + #[test] + fn agrees_with_the_frontend_registry() { + let rows: Vec = serde_json::from_str(include_str!( + "../../../../frontend/src/lib/components/copilot/reasoningParity.json" + )) + .unwrap(); + for row in rows { + let model = row["model"].as_str().unwrap(); + let can_disable = row["canDisable"].as_bool().unwrap(); + let tools_need_off = row["completionsToolsNeedOff"].as_bool().unwrap(); + let sent = effective_reasoning_effort(model, Some(REASONING_OFF_SENTINEL)); + assert_eq!(sent.is_some(), can_disable, "{model}"); + assert_eq!( + effective_reasoning_effort(model, Some("low")), + Some("low"), + "{model}" + ); + assert_eq!( + completions_tools_need_reasoning_off(model), + tools_need_off && can_disable, + "{model}" + ); + } + } + + #[test] + fn reads_ids_the_frontend_resolves_first() { + // Azure's gpt-3.5 is not major 35, and a gateway prefix is not part of the id. + assert_eq!(gpt_version("gpt-35-turbo"), None); + assert!(completions_tools_need_reasoning_off("openai/gpt-6-sol")); + assert_eq!(effective_reasoning_effort("openai/o3", Some("none")), None); + } +} + #[cfg(test)] mod chat_completions_only_tests { use super::*; diff --git a/backend/windmill-ai/src/providers/other.rs b/backend/windmill-ai/src/providers/other.rs index b6040d8d79..1ea54a7e79 100644 --- a/backend/windmill-ai/src/providers/other.rs +++ b/backend/windmill-ai/src/providers/other.rs @@ -1,3 +1,4 @@ +use super::{completions_tools_need_reasoning_off, REASONING_OFF_SENTINEL}; use crate::{ ai_providers::AIProvider, image_handler::prepare_messages_for_api, @@ -143,6 +144,15 @@ impl OtherQueryBuilder { let (reasoning_effort, thinking, temperature) = provider_reasoning_fields(&self.provider_kind, args.reasoning_effort, args.temperature); + // With tools, gpt-5.5+ only run here with reasoning off: turn it off where the model + // can, rather than failing every turn. + let reasoning_effort = if args.tools.is_some_and(|tools| !tools.is_empty()) + && completions_tools_need_reasoning_off(args.model) + { + Some(REASONING_OFF_SENTINEL) + } else { + reasoning_effort + }; // Build request with stream_options for usage tracking let request_with_usage = OpenAICompletionRequest { diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index 03476240a3..7e2de09e01 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -294,7 +294,10 @@ impl ProviderWithResource { /// The reasoning effort to thread to the provider, treating an empty string /// (e.g. a cleared flow input) as unset. pub fn get_reasoning_effort(&self) -> Option<&str> { - self.reasoning_effort.as_deref().filter(|s| !s.is_empty()) + crate::providers::effective_reasoning_effort( + &self.model, + self.reasoning_effort.as_deref().filter(|s| !s.is_empty()), + ) } pub async fn get_base_url(&self, db: &DB) -> Result { diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 655a872f96..ba718bf760 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -22,6 +22,8 @@ import { } from './modelConfig' import { applyReasoningToConfig, + completionsRejectsToolsWithReasoning, + explicitOffToken, requestsReasoning, stripLegacyThinkingSuffix, type ReasoningEffort @@ -69,6 +71,9 @@ interface AIProviderDetails { // the frontier model. The gpt-5 family is deprecated (retires 2026-12-11) but // still served, so it stays in the list below the 5.6 models. const OPENAI_MODELS = [ + 'gpt-6-sol', + 'gpt-6-astra', + 'gpt-6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol', 'gpt-5.6-luna', @@ -87,7 +92,14 @@ export const AI_PROVIDERS: Record = { }, anthropic: { label: 'Anthropic', - defaultModels: ['claude-sonnet-5', 'claude-opus-5', 'claude-opus-4-8', 'claude-haiku-4-5'] + defaultModels: [ + 'claude-sonnet-5-5', + 'claude-opus-5-5', + 'claude-sonnet-5', + 'claude-opus-5', + 'claude-opus-4-8', + 'claude-haiku-4-5' + ] }, googleai: { label: 'Google AI', @@ -1160,6 +1172,12 @@ export async function getCompletion( // Use Completions API for other providers const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient() + // gpt-5.5+ refuse function tools here unless reasoning is off, so the Responses API + // fallback turns it off where the model can rather than failing the turn. + const reasoningEffort = + tools?.length && completionsRejectsToolsWithReasoning(provider, modelProvider.model) + ? (explicitOffToken(provider, modelProvider.model) ?? options?.reasoningEffort) + : options?.reasoningEffort const completionConfig = applyReasoningToConfig( config.stream && STREAM_USAGE_PROVIDERS.has(provider) ? { @@ -1175,7 +1193,7 @@ export async function getCompletion( } : config, provider === 'deepseek' ? 'deepseek' : provider === 'mistral' ? 'mistral' : 'completions', - options?.reasoningEffort + reasoningEffort ) const completion = client.chat.completions.create(completionConfig, { signal: abortController.signal, diff --git a/frontend/src/lib/components/copilot/modelConfig.test.ts b/frontend/src/lib/components/copilot/modelConfig.test.ts index 4f54eb223a..8bcdf70f54 100644 --- a/frontend/src/lib/components/copilot/modelConfig.test.ts +++ b/frontend/src/lib/components/copilot/modelConfig.test.ts @@ -21,6 +21,13 @@ describe('workspace context window overrides', () => { expect(getConfiguredModelContextWindow('openai', 'qwen-local', overrides)).toBeUndefined() expect(getEffectiveModelContextWindow('openai', 'qwen-local', overrides)).toBe(128_000) }) + + it('knows the gpt-6 and Claude 5.5 windows, so they do not compact at the assumed one', () => { + expect(getConfiguredModelContextWindow('openai', 'gpt-6-sol', undefined)).toBe(1_050_000) + expect(getConfiguredModelContextWindow('anthropic', 'claude-sonnet-5-5', undefined)).toBe( + 1_000_000 + ) + }) }) describe('usesAnthropicMessagesApi', () => { diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index 83e7771873..f9653bff25 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -58,7 +58,7 @@ export function usesOpenRouterPromptCaching(provider: AIProvider, model: string) // so it does not catch unrelated ids like Mistral's "open-mistral-*" or "optimus-*". export function requiresMaxCompletionTokens(model: string) { const baseModel = parseModelId(model).base - return baseModel.startsWith('gpt-5') || /^o\d/.test(baseModel) + return Number(/^gpt-(\d)(?:[.-]|$)/.exec(baseModel)?.[1] ?? 0) >= 5 || /^o\d/.test(baseModel) } // Context windows of the models we know, most specific entry first — the first @@ -87,6 +87,7 @@ const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ ['claude', 200_000], // OpenAI — gpt-5 covers the base family (-mini / -nano) and the 5.1/5.2 // revisions, all 400K; 5.4/5.5 moved to 1M and 5.6 to 1.05M + ['gpt-6', 1_050_000], ['gpt-5.6', 1_050_000], ['gpt-5.5', 1_000_000], ['gpt-5.4', 1_000_000], @@ -140,6 +141,7 @@ const MODEL_MAX_OUTPUT_TOKENS: [name: string, maxOutputTokens: number][] = [ ['claude-fable', 64_000], ['claude-mythos', 64_000], // OpenAI + ['gpt-6', 128_000], ['gpt-5', 128_000], ['gpt-4.1', 32_768], ['gpt-4o', 16_384], diff --git a/frontend/src/lib/components/copilot/modelPricing.test.ts b/frontend/src/lib/components/copilot/modelPricing.test.ts index e295682187..4eded135ee 100644 --- a/frontend/src/lib/components/copilot/modelPricing.test.ts +++ b/frontend/src/lib/components/copilot/modelPricing.test.ts @@ -35,6 +35,16 @@ describe('resolveModelPrice', () => { expect(resolveModelPrice('googleai', 'gemini-3.7-flash', undefined)).toBeUndefined() }) + it('prices Claude 5.5 flat and leaves the tiered gpt-6 unpriced', () => { + expect(resolveModelPrice('anthropic', 'claude-opus-5-5', undefined)?.price).toMatchObject({ + input: 4, + output: 20, + cacheRead: 0.2 + }) + expect(resolveModelPrice('anthropic', 'claude-sonnet-5-5', undefined)?.price.input).toBe(2) + expect(resolveModelPrice('openai', 'gpt-6-sol', undefined)).toBeUndefined() + }) + it('reports an unknown model as unpriced rather than guessing', () => { expect(resolveModelPrice('customai', 'some-in-house-model', undefined)).toBeUndefined() }) diff --git a/frontend/src/lib/components/copilot/modelPricing.ts b/frontend/src/lib/components/copilot/modelPricing.ts index 1e5b63247b..b191a0bcaa 100644 --- a/frontend/src/lib/components/copilot/modelPricing.ts +++ b/frontend/src/lib/components/copilot/modelPricing.ts @@ -69,6 +69,8 @@ const MODEL_PRICES: [name: string, price: PriceEntry | null][] = [ // fallback sits below the explicit entries rather than covering them. ['claude-fable-5', { input: 10, output: 50 }], ['claude-mythos-5', { input: 10, output: 50 }], + ['claude-opus-5-5', { input: 4, output: 20, cacheRead: 0.2 }], + ['claude-sonnet-5-5', { input: 2, output: 10, cacheRead: 0.2 }], ['claude-opus-5', { input: 5, output: 25 }], ['claude-opus-4-8', { input: 5, output: 25 }], ['claude-opus-4-7', { input: 5, output: 25 }], @@ -98,6 +100,9 @@ const MODEL_PRICES: [name: string, price: PriceEntry | null][] = [ // Revisions past gpt-5 are priced separately by OpenAI and are not tracked here. // The matcher's revision guard already keeps them off the family rate; these // entries stay so a revision the guard admits still resolves to no rate. + // gpt-6 bills the whole request at a higher rate above 272K input tokens, which a + // per-model rate cannot express. + ['gpt-6', null], ['gpt-5.6', null], ['gpt-5.5', null], ['gpt-5.4', null], diff --git a/frontend/src/lib/components/copilot/reasoningParity.json b/frontend/src/lib/components/copilot/reasoningParity.json new file mode 100644 index 0000000000..93b8ac7eb2 --- /dev/null +++ b/frontend/src/lib/components/copilot/reasoningParity.json @@ -0,0 +1,84 @@ +[ + { + "provider": "anthropic", + "model": "claude-sonnet-5-5", + "canDisable": false, + "completionsToolsNeedOff": false + }, + { + "provider": "anthropic", + "model": "claude-opus-5-5", + "canDisable": false, + "completionsToolsNeedOff": false + }, + { + "provider": "anthropic", + "model": "claude-fable-5", + "canDisable": false, + "completionsToolsNeedOff": false + }, + { + "provider": "aws_bedrock", + "model": "global.anthropic.claude-opus-5-5-v1:0", + "canDisable": false, + "completionsToolsNeedOff": false + }, + { + "provider": "anthropic", + "model": "claude-sonnet-5", + "canDisable": true, + "completionsToolsNeedOff": false + }, + { + "provider": "anthropic", + "model": "claude-opus-5-20260101", + "canDisable": true, + "completionsToolsNeedOff": false + }, + { + "provider": "anthropic", + "model": "claude-opus-4-8", + "canDisable": true, + "completionsToolsNeedOff": false + }, + { + "provider": "openai", + "model": "gpt-6-astra", + "canDisable": false, + "completionsToolsNeedOff": true + }, + { + "provider": "openai", + "model": "gpt-6-sol", + "canDisable": true, + "completionsToolsNeedOff": true + }, + { + "provider": "azure_openai", + "model": "gpt-6-luna", + "canDisable": true, + "completionsToolsNeedOff": true + }, + { + "provider": "openai", + "model": "gpt-5.6-sol", + "canDisable": true, + "completionsToolsNeedOff": true + }, + { "provider": "openai", "model": "gpt-5.5", "canDisable": true, "completionsToolsNeedOff": true }, + { "provider": "openai", "model": "gpt-5.7", "canDisable": true, "completionsToolsNeedOff": true }, + { + "provider": "openai", + "model": "gpt-5.1", + "canDisable": true, + "completionsToolsNeedOff": false + }, + { "provider": "openai", "model": "gpt-5", "canDisable": false, "completionsToolsNeedOff": false }, + { + "provider": "openai", + "model": "gpt-5-mini", + "canDisable": false, + "completionsToolsNeedOff": false + }, + { "provider": "openai", "model": "o3", "canDisable": false, "completionsToolsNeedOff": false } +] diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.test.ts b/frontend/src/lib/components/copilot/reasoningRegistry.test.ts index faf6f60264..30543e6c2f 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.test.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { applyReasoningToConfig, + completionsRejectsToolsWithReasoning, + explicitOffToken, getReasoningCapability, REASONING_OFF, resolveEffectiveReasoning, @@ -8,6 +10,8 @@ import { stripLegacyThinkingSuffix, supportsReasoning } from './reasoningRegistry' +import type { AIProvider } from '$lib/gen' +import parity from './reasoningParity.json' describe('stripLegacyThinkingSuffix', () => { it('removes the deprecated /thinking suffix', () => { @@ -261,6 +265,42 @@ describe('supportsReasoning (static registry)', () => { expect(getReasoningCapability('openrouter', 'x-ai/grok-4').canDisable).toBe(false) expect(getReasoningCapability('openrouter', 'deepseek/deepseek-r1').canDisable).toBe(false) }) + it('never sends a disable the 5.5 point releases and gpt-6-astra reject', () => { + // Live-verified: Claude 5.5 rejects `thinking: disabled`, gpt-6-astra rejects `none`. + for (const [provider, model] of [ + ['anthropic', 'claude-sonnet-5-5'], + ['anthropic', 'claude-opus-5-5'], + ['aws_bedrock', 'global.anthropic.claude-opus-5-5-v1:0'], + ['openai', 'gpt-6-astra'] + ] as const) { + expect(getReasoningCapability(provider, model).canDisable, model).toBe(false) + expect(explicitOffToken(provider, model), model).toBeUndefined() + } + expect(getReasoningCapability('openrouter', 'anthropic/claude-sonnet-5.5').canDisable).toBe( + false + ) + expect(explicitOffToken('openrouter', 'anthropic/claude-sonnet-5.5')).toBeUndefined() + // A dated Claude 5 id is not a point release. + expect(explicitOffToken('anthropic', 'claude-sonnet-5-20260101')).toBe('none') + expect(explicitOffToken('openai', 'gpt-6-sol')).toBe('none') + expect(getReasoningCapability('openai', 'gpt-6-luna')).toMatchObject({ + supported: true, + canDisable: true, + levels: ['low', 'medium', 'high', 'xhigh', 'max'] + }) + }) + it("reads Azure's gpt-35-turbo as gpt-3.5, not a gpt-5+ reasoning model", () => { + expect(supportsReasoning('azure_openai', 'gpt-35-turbo')).toBe(false) + expect(supportsReasoning('openai', 'gpt-35-turbo-16k')).toBe(false) + }) + it('finds the models that refuse function tools with reasoning on Chat Completions', () => { + for (const model of ['gpt-5.5', 'gpt-5.6-sol', 'gpt-6-astra']) { + expect(completionsRejectsToolsWithReasoning('openai', model), model).toBe(true) + } + for (const model of ['gpt-5', 'gpt-5.1', 'gpt-35-turbo', 'o3']) { + expect(completionsRejectsToolsWithReasoning('azure_openai', model), model).toBe(false) + } + }) it('forwards an explicit off as effort none through OpenRouter', () => { expect( resolveRequestReasoning({ @@ -353,6 +393,23 @@ describe('Azure AI Foundry reasoning follows the model family', () => { }) }) +describe('backend parity', () => { + // windmill-ai's `providers/mod.rs` test reads the same file. The backend rules see the + // model id alone, so the file holds only rows whose answer doesn't depend on the + // provider: a Bedrock- or Gemini-Pro-specific row belongs in the tests above. + it.each(parity)( + '$provider $model', + ({ provider, model, canDisable, completionsToolsNeedOff }) => { + const capability = getReasoningCapability(provider as AIProvider, model) + expect(capability.supported).toBe(true) + expect(capability.canDisable).toBe(canDisable) + expect(completionsRejectsToolsWithReasoning(provider as AIProvider, model)).toBe( + completionsToolsNeedOff + ) + } + ) +}) + describe('resolveEffectiveReasoning', () => { it('defaults capable models to high when unset', () => { expect(resolveEffectiveReasoning({ provider: 'anthropic', model: 'claude-sonnet-4-6' })).toBe( diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 9915e9c42f..68934c22f3 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -1,5 +1,5 @@ import type { AIProvider, AIProviderModel } from '$lib/gen' -import { parseModelId, usesAnthropicMessagesApi } from './modelConfig' +import { usesAnthropicMessagesApi } from './modelConfig' /** * Reasoning effort is provider/model-specific. We never normalize a single @@ -33,11 +33,6 @@ export function stripLegacyThinkingSuffix(model: string): string { : model } -/** Bare model id without any provider/gateway prefix (e.g. OpenRouter's `openai/o3`). */ -function baseModelId(model: string): string { - return parseModelId(model).base -} - /** * Azure AI Foundry hosts multiple model families under one provider, so reasoning * support follows the underlying model rather than the provider: Claude deployments @@ -53,172 +48,218 @@ function reasoningProviderFamily(provider: AIProvider, model: string): AIProvide } /** - * Suggested effort levels per provider, sourced from each provider SDK's own - * vocabulary. + * Sentinel sent for the deepseek off case. It never reaches the wire as an + * effort: the 'deepseek' branch of `applyReasoningToConfig` translates it to + * the provider's `thinking: {type: "disabled"}` param (`reasoning_effort: + * "none"` is rejected by their API). */ -const PROVIDER_REASONING_LEVELS: Partial> = { - // DeepSeek accepts the full five-token vocabulary but only two levels are - // real: low/medium are server-mapped to high and xhigh to max — offering - // them would be a no-op knob. - deepseek: ['high', 'max'], - // Mistral's only effort token besides the 'none' disable is 'high' - // (anything else is rejected), so the knob is effectively on/off. - mistral: ['high'] -} +export const DEEPSEEK_OFF_SENTINEL: ReasoningEffort = 'none' /** - * OpenRouter validates effort against its own vocabulary - * (minimal..xhigh + none) and translates per underlying provider, so the - * real ladder depends on the model family: Anthropic gets all five as - * distinct budget ratios (minimal 10% .. xhigh 95% of max_tokens); Gemini - * maps to thinkingLevel with xhigh clamped to high (a no-op vs high); - * OpenAI gets the token passed through verbatim, so the per-model OpenAI - * scoping applies; DeepSeek server-maps low/medium to high and xhigh to max. + * Sentinel for the Anthropic off case. Like the DeepSeek one it never reaches + * the wire as an effort: the 'anthropic' branch of `applyReasoningToConfig` + * translates it to `thinking: {type: "disabled"}`, the only off that Opus and + * Sonnet 5 respect. */ -function openrouterReasoningLevels(model: string): ReasoningEffort[] { - const m = model.toLowerCase() - const base = baseModelId(model) - if (/claude-(opus|sonnet)-(4|5)/.test(m)) { - return ['minimal', 'low', 'medium', 'high', 'xhigh'] - } - if (m.includes('gemini-')) { - return geminiReasoningLevels(m) - } - if (base.startsWith('gpt-5') || /^o\d/.test(base)) { - return openaiReasoningLevels(base) - } - if (m.includes('deepseek-v4')) { - return ['high', 'xhigh'] - } - return ['low', 'medium', 'high'] -} +export const ANTHROPIC_OFF_SENTINEL: ReasoningEffort = 'none' /** - * OpenAI's effort vocabulary is model-dependent: `minimal` exists on gpt-5 but - * not on gpt-5.1+, `xhigh` arrived on gpt-5.5 and `max` on gpt-5.6; o-series - * take low/medium/high. An unsupported level is rejected, so scope the list to - * the model. (`none` is the disable token, handled by `explicitOffToken`.) + * What the registry knows about one group of models. The rows below are matched in + * order against the lowercased model id, first match wins, so a narrower row goes above + * the row it overrides. A model no row matches does not reason, as far as we know. */ -function openaiReasoningLevels(model: string): ReasoningEffort[] { - const base = baseModelId(model) - if (/^gpt-5\.6/.test(base)) { - return ['low', 'medium', 'high', 'xhigh', 'max'] - } - if (/^gpt-5\.5/.test(base)) { - return ['low', 'medium', 'high', 'xhigh'] - } - if (/^gpt-5\./.test(base)) { - return ['low', 'medium', 'high'] - } - if (/^gpt-5/.test(base)) { - return ['minimal', 'low', 'medium', 'high'] - } - return ['low', 'medium', 'high'] +type ReasoningRule = { + match: RegExp + /** The levels the UI offers. An unsupported level is a 400, so this is per model. */ + levels: readonly ReasoningEffort[] + /** + * Whether "off" really stops the model reasoning. When false the UI offers no off: + * the provider would reject it, or coerce it to the lowest level. + */ + canDisable: boolean + /** + * The effort to send for off on a model that reasons when the field is omitted. + * Unset where omission is already off. Always `'none'`: `requestsReasoning` reads + * that as off, and each wire format translates it (see `applyReasoningToConfig`). + */ + offToken?: ReasoningEffort + /** + * gpt-5.5 and later refuse function tools on Chat Completions while they reason, + * even with the effort omitted (live-verified); the Responses API has no such limit. + */ + completionsToolsNeedOff?: boolean } +const LOW_TO_HIGH = ['low', 'medium', 'high'] +const LOW_TO_XHIGH = ['low', 'medium', 'high', 'xhigh'] +const LOW_TO_MAX = ['low', 'medium', 'high', 'xhigh', 'max'] +const MINIMAL_TO_HIGH = ['minimal', 'low', 'medium', 'high'] + /** - * Gemini's level ladder is model-dependent: Gemini 3+ Flash / Flash-Lite accept - * `minimal`, while 3.x Pro does not (and cannot disable thinking). Gemini 2.5 - * uses numeric budgets — the proxy maps the three tiers to budget values, so - * `minimal` is not offered there. + * Claude models whose thinking cannot be turned off: an explicit disable 400s. Fable, + * Mythos, and the 5.x point releases (Sonnet 5.5, Opus 5.5), whose lowest setting is + * adaptive thinking at `low` (live-verified). The version match stops at one digit so a + * dated id (`claude-sonnet-5-20260101`) stays Sonnet 5. */ -function geminiReasoningLevels(model: string): ReasoningEffort[] { - const m = model.toLowerCase() - const isGemini3Plus = !m.includes('gemini-2.5') - if (isGemini3Plus && (m.includes('flash') || m.includes('lite'))) { - return ['minimal', 'low', 'medium', 'high'] - } - return ['low', 'medium', 'high'] +const CLAUDE_ALWAYS_THINKING = /fable|mythos|claude-(opus|sonnet)-5[-.][1-9](?!\d)/ + +// Anthropic ids are matched anywhere in the id: Bedrock prefixes them +// (`us.anthropic.claude-opus-4-6-v1`). Opus 4.5 and older reject adaptive thinking. +const ANTHROPIC_RULES: ReasoningRule[] = [ + { match: CLAUDE_ALWAYS_THINKING, levels: LOW_TO_MAX, canDisable: false }, + // The 5 family thinks when the field is absent, so off is an explicit disable. + { + match: /claude-(opus|sonnet)-5/, + levels: LOW_TO_MAX, + canDisable: true, + offToken: ANTHROPIC_OFF_SENTINEL + }, + // 4.6-4.8 only think when asked, so omission is already off. + { match: /claude-opus-4-[78]/, levels: LOW_TO_MAX, canDisable: true }, + { match: /claude-(opus|sonnet)-4-6/, levels: ['low', 'medium', 'high', 'max'], canDisable: true } +] + +const BEDROCK_RULES: ReasoningRule[] = [ + ANTHROPIC_RULES[0], + // AWS documents Sonnet 5 on Bedrock as always thinking, where the native API + // accepts a disable for it. + { match: /claude-sonnet-5/, levels: LOW_TO_MAX, canDisable: false }, + ...ANTHROPIC_RULES.slice(1) +] + +// Anchored at the start or after a gateway's `vendor/`, and the major is one digit: +// Azure names gpt-3.5 `gpt-35-turbo`. `minimal` exists on gpt-5 only, `xhigh` from +// gpt-5.5, `max` from gpt-5.6. +const OPENAI_RULES: ReasoningRule[] = [ + // Live-verified: astra takes low..max only, where sol and luna also take `none`. + { + match: /(?:^|\/)gpt-6-astra/, + levels: LOW_TO_MAX, + canDisable: false, + completionsToolsNeedOff: true + }, + { + match: /(?:^|\/)gpt-[6-9](?:[.:-]|$)/, + levels: LOW_TO_MAX, + canDisable: true, + offToken: 'none', + completionsToolsNeedOff: true + }, + { + match: /(?:^|\/)gpt-5\.6/, + levels: LOW_TO_MAX, + canDisable: true, + offToken: 'none', + completionsToolsNeedOff: true + }, + { + match: /(?:^|\/)gpt-5\.5/, + levels: LOW_TO_XHIGH, + canDisable: true, + offToken: 'none', + completionsToolsNeedOff: true + }, + // A later gpt-5 minor keeps the tools limit, which holds for every version from 5.5, + // but only the levels every gpt-5.x takes until it has a row of its own. + { + match: /(?:^|\/)gpt-5\.(?:[5-9]|\d{2,})/, + levels: LOW_TO_HIGH, + canDisable: true, + offToken: 'none', + completionsToolsNeedOff: true + }, + // gpt-5.1+ are off only through `none`: omitted, they reason at medium. + { match: /(?:^|\/)gpt-5\./, levels: LOW_TO_HIGH, canDisable: true, offToken: 'none' }, + // gpt-5 and the o-series reject `none` and reason when it is omitted. + { match: /(?:^|\/)gpt-5(?:[:-]|$)/, levels: MINIMAL_TO_HIGH, canDisable: false }, + { match: /(?:^|\/)o\d/, levels: LOW_TO_HIGH, canDisable: false } +] + +// Gemini 2.5/3 think by default; the backend proxy maps `none` to off on Flash, or to +// the floor on Pro, which enforces one (level `low` on 3.x, 128 tokens on 2.5). +// Gemini 3+ Flash / Flash-Lite accept `minimal`; 2.5 takes numeric budgets the proxy +// maps from three tiers. +const GEMINI_RULES: ReasoningRule[] = [ + { match: /gemini-2\.5.*pro/, levels: LOW_TO_HIGH, canDisable: false, offToken: 'none' }, + { match: /gemini-2\.5/, levels: LOW_TO_HIGH, canDisable: true, offToken: 'none' }, + { match: /gemini-3.*pro/, levels: LOW_TO_HIGH, canDisable: false, offToken: 'none' }, + { match: /gemini-3.*(flash|lite)/, levels: MINIMAL_TO_HIGH, canDisable: true, offToken: 'none' }, + { match: /gemini-3/, levels: LOW_TO_HIGH, canDisable: true, offToken: 'none' } +] + +const REASONING_RULES: Partial> = { + anthropic: ANTHROPIC_RULES, + aws_bedrock: BEDROCK_RULES, + openai: OPENAI_RULES, + azure_openai: OPENAI_RULES, + googleai: GEMINI_RULES, + deepseek: [ + // Every current API model takes reasoning_effort, but only two levels are real: + // low/medium are server-mapped to high, xhigh to max. The retired + // `deepseek-chat` alias means "non-thinking mode", so a saved selection on it + // must not silently become a thinking request. Off is a separate `thinking` param. + { + match: /(?:^|\/)deepseek(?!-chat(?::|$))/, + levels: ['high', 'max'], + canDisable: true, + offToken: DEEPSEEK_OFF_SENTINEL + } + ], + mistral: [ + // Only the ids verified to accept reasoning_effort (large, magistral, ministral + // and pinned versions reject it), whose only effort besides off is `high`. + { + match: /(?:^|\/)mistral-(?:(?:small|medium)-latest(?::|$)|medium-3[-.]5)/, + levels: ['high'], + canDisable: true + } + ], + // OpenRouter validates effort against its own vocabulary (minimal..xhigh + none) and + // translates it per underlying provider, which scopes the ladder: Anthropic gets all + // five as budget ratios, OpenAI gets the token verbatim, DeepSeek server-maps. `none` + // is its documented off, more reliable than omission; it can only disable a model + // whose upstream can. + openrouter: [ + { + match: /claude-(opus|sonnet)-5[-.][1-9](?!\d)/, + levels: ['minimal', ...LOW_TO_XHIGH], + canDisable: false + }, + { + match: /claude-(opus|sonnet)-(4|5)/, + levels: ['minimal', ...LOW_TO_XHIGH], + canDisable: true, + offToken: 'none' + }, + ...GEMINI_RULES, + ...OPENAI_RULES.map(({ completionsToolsNeedOff: _, ...rule }) => ({ + ...rule, + offToken: 'none' + })), + { match: /deepseek-v4/, levels: LOW_TO_XHIGH.slice(2), canDisable: true, offToken: 'none' }, + // deepseek-r1, grok-4 and :thinking variants reason unconditionally. + { + match: /deepseek-r|grok-4|:thinking/, + levels: LOW_TO_HIGH, + canDisable: false, + offToken: 'none' + } + ] } -/** - * Gemini Pro models cannot turn thinking off — the API enforces a floor - * (level `low` on 3.x Pro, a 128-token budget on 2.5 Pro), so an off option - * would silently mean "lowest". Flash / Flash-Lite can truly disable - * (budget 0 / level `minimal`). - */ -function geminiCanDisable(model: string): boolean { - return !model.toLowerCase().includes('pro') +/** The row that describes a model, and whether its provider family has rows at all. */ +function findReasoningRule( + provider: AIProvider, + model: string +): { rule: ReasoningRule | undefined; known: boolean } { + const rules = REASONING_RULES[reasoningProviderFamily(provider, model)] + const id = stripLegacyThinkingSuffix(model).toLowerCase() + return { rule: rules?.find((rule) => rule.match.test(id)), known: rules !== undefined } } -/** - * Anthropic's effort ladder is model-dependent: `xhigh` exists on Opus 4.7/4.8, - * the 5 family and Fable/Mythos; `max` also on Opus 4.6 and Sonnet 4.6. - * Offering an unsupported level would 400, so scope the list to the model. - */ -function anthropicReasoningLevels(model: string): ReasoningEffort[] { - const m = model.toLowerCase() - if ( - /claude-(opus|sonnet)-5/.test(m) || - /claude-opus-4-(7|8)/.test(m) || - m.includes('fable') || - m.includes('mythos') - ) { - return ['low', 'medium', 'high', 'xhigh', 'max'] - } - return ['low', 'medium', 'high', 'max'] -} - -/** Mistral writes both `mistral-medium-3.5` and `mistral-medium-3-5`. */ -function normalizeMistralId(model: string): string { - return baseModelId(model).replace(/\./g, '-') -} - -/** - * Conservative static predicate for whether a model accepts an effort knob. - * Kept tight to avoid 400s on models that reject reasoning params. - */ -function supportsReasoningStatic(provider: AIProvider, model: string): boolean { - const m = model.toLowerCase() - const base = baseModelId(model) - switch (reasoningProviderFamily(provider, model)) { - case 'anthropic': - // Bedrock serves the same Claude models under prefixed ids - // (e.g. `us.anthropic.claude-opus-4-6-v1`), so match on the full string. - case 'aws_bedrock': - // 4.6+ only: Opus 4.5 rejects adaptive thinking (and, on Bedrock, - // the whole output_config surface) — live-verified hard 400. - return ( - /claude-opus-(4-(6|7|8)|5)/.test(m) || - /claude-sonnet-(4-6|5)/.test(m) || - m.includes('fable') || - m.includes('mythos') - ) - case 'openai': - case 'azure_openai': - return base.startsWith('gpt-5') || /^o\d/.test(base) - case 'openrouter': - // Best-effort markers for models whose `supported_parameters` include - // `reasoning` in OpenRouter's catalog; OpenRouter translates the effort - // per underlying provider. - return ( - base.startsWith('gpt-5') || - /^o\d/.test(base) || - /claude-(opus|sonnet)-(4|5)/.test(m) || - /gemini-(2\.5|3)/.test(m) || - m.includes('deepseek-r') || - m.includes('deepseek-v4') || - m.includes('grok-4') || - m.includes(':thinking') - ) - case 'googleai': - return /gemini-(2\.5|3)/.test(m) - case 'deepseek': - // All current API models take reasoning_effort (live-verified). The - // retired `deepseek-chat` alias stays excluded: its documented meaning - // is "non-thinking mode", so a saved selection on it must not silently - // become a thinking request. - return base.startsWith('deepseek') && base !== 'deepseek-chat' - case 'mistral': - // Only the ids verified to accept reasoning_effort; other models - // (large, magistral, ministral, pinned versions) reject the param. - return ( - /^mistral-(small|medium)-latest$/.test(base) || - normalizeMistralId(model).startsWith('mistral-medium-3-5') - ) - default: - return false - } +/** Whether Chat Completions needs reasoning off for this model to take function tools. */ +export function completionsRejectsToolsWithReasoning(provider: AIProvider, model: string): boolean { + return findReasoningRule(provider, model).rule?.completionsToolsNeedOff ?? false } export type ReasoningCapability = { @@ -241,89 +282,12 @@ export type ReasoningCapability = { known: boolean } -/** Provider families the registry has real rules for; everything else is a shrug. */ -const KNOWN_REASONING_FAMILIES: ReadonlySet = new Set([ - 'anthropic', - 'aws_bedrock', - 'openai', - 'azure_openai', - 'openrouter', - 'googleai', - 'deepseek', - 'mistral' -]) - /** Resolve the reasoning capability of a model from the static registry. */ export function getReasoningCapability(provider: AIProvider, model: string): ReasoningCapability { - const bareModel = stripLegacyThinkingSuffix(model) - const known = KNOWN_REASONING_FAMILIES.has(reasoningProviderFamily(provider, bareModel)) - const supported = supportsReasoningStatic(provider, bareModel) - if (!supported) { - return { supported: false, levels: [], canDisable: false, known } - } - const family = reasoningProviderFamily(provider, bareModel) - const levels = - family === 'anthropic' || family === 'aws_bedrock' - ? anthropicReasoningLevels(bareModel) - : family === 'googleai' - ? geminiReasoningLevels(bareModel) - : family === 'openai' || family === 'azure_openai' - ? openaiReasoningLevels(bareModel) - : family === 'openrouter' - ? openrouterReasoningLevels(bareModel) - : (PROVIDER_REASONING_LEVELS[family] ?? ['low', 'medium', 'high']) - return { supported, levels, canDisable: canDisableReasoning(provider, bareModel), known } -} - -/** - * Whether selecting "off" truly disables reasoning for the model. Off is - * sent either as an explicit provider disable (see `explicitOffToken`) or by - * omitting the effort — which only works where the model doesn't reason by - * default. - */ -function canDisableReasoning(provider: AIProvider, model: string): boolean { - const m = model.toLowerCase() - const base = baseModelId(model) - switch (reasoningProviderFamily(provider, model)) { - case 'anthropic': - // Every Claude but Fable and Mythos can stop thinking: 4.6-4.8 by - // omission, and the 5 family through the explicit disable that - // `explicitOffToken` sends. - return !ANTHROPIC_ALWAYS_THINKING.test(m) - case 'aws_bedrock': - // Same models, different answer: AWS documents Sonnet 5 on Bedrock as - // always thinking, where the native API accepts a disable for it. - return !(ANTHROPIC_ALWAYS_THINKING.test(m) || m.includes('claude-sonnet-5')) - case 'googleai': - return geminiCanDisable(model) - case 'openai': - case 'azure_openai': - // gpt-5.1+ accept effort 'none'; gpt-5 and o-series reject it and - // reason at `medium` by default, so omission isn't off either. - return /^gpt-5\./.test(base) - case 'openrouter': - // 'none' is in OpenRouter's vocabulary, but the gateway can't - // disable a model whose upstream can't — scope off per underlying - // family, like the levels. - // The 5 family thinks by default, but its upstream takes an explicit - // disable, so the gateway's 'none' has something to translate to. - if (/claude-(opus|sonnet)-(4|5)/.test(m)) { - return true - } - if (m.includes('gemini-')) { - return geminiCanDisable(m) - } - if (base.startsWith('gpt-5') || /^o\d/.test(base)) { - return /^gpt-5\./.test(base) - } - if (m.includes('deepseek-v4')) { - return true - } - // grok-4, deepseek-r1 and :thinking variants reason unconditionally. - return false - default: - return true - } + const { rule, known } = findReasoningRule(provider, model) + return rule + ? { supported: true, levels: [...rule.levels], canDisable: rule.canDisable, known } + : { supported: false, levels: [], canDisable: false, known } } export function supportsReasoning(provider: AIProvider, model: string): boolean { @@ -352,64 +316,14 @@ export function resolveEffectiveReasoning( : undefined } -/** - * Sentinel sent for the deepseek off case. It never reaches the wire as an - * effort: the 'deepseek' branch of `applyReasoningToConfig` translates it to - * the provider's `thinking: {type: "disabled"}` param (`reasoning_effort: - * "none"` is rejected by their API). - */ -export const DEEPSEEK_OFF_SENTINEL: ReasoningEffort = 'none' - -/** - * Sentinel for the Anthropic off case. Like the DeepSeek one it never reaches - * the wire as an effort: the 'anthropic' branch of `applyReasoningToConfig` - * translates it to `thinking: {type: "disabled"}`, which is the only off the - * always-on 5 family respects. - */ -export const ANTHROPIC_OFF_SENTINEL: ReasoningEffort = 'none' - -/** Claude models whose thinking cannot be turned off — an explicit disable 400s. */ -const ANTHROPIC_ALWAYS_THINKING = /fable|mythos/ - /** * Disable token to forward when the user explicitly turns reasoning off on a * model that reasons *by default* — omitting the field would silently keep - * the default-on behavior. Undefined means omission is the correct off. Every - * token is `'none'`: `requestsReasoning` reads that value as off. + * the default-on behavior. Undefined means omission is the correct off, or that + * the model cannot be turned off at all. */ export function explicitOffToken(provider: AIProvider, model: string): ReasoningEffort | undefined { - switch (reasoningProviderFamily(provider, model)) { - case 'anthropic': - // Claude 4.6-4.8 only think when asked, so omission is already a - // real off there and stays the wire form. Only the 5 family, which - // thinks when the field is absent, needs the explicit disable — - // Fable and Mythos reject it outright and get no off token at all. - return /claude-(opus|sonnet)-5/.test(model.toLowerCase()) ? ANTHROPIC_OFF_SENTINEL : undefined - case 'aws_bedrock': - // Bedrock's Sonnet 5 cannot be disabled at all, so only Opus 5 gets - // the sentinel; the rest keep omission. - return model.toLowerCase().includes('claude-opus-5') ? ANTHROPIC_OFF_SENTINEL : undefined - case 'googleai': - // Gemini 2.5/3 think by default (dynamic budget / level). The backend - // proxy maps 'none' to off on Flash, or the floor on Pro (only - // reachable via a stale persisted preference — see canDisableReasoning). - return 'none' - case 'deepseek': - return DEEPSEEK_OFF_SENTINEL - case 'openai': - case 'azure_openai': - // gpt-5.1+ reasoning is off only via the explicit 'none' effort - // (gpt-5.5 defaults to medium when the field is omitted). - return /^gpt-5\./.test(baseModelId(model)) ? 'none' : undefined - case 'openrouter': - // OpenRouter validates effort against xhigh..minimal|none and - // documents 'none' as disabling reasoning, translated per the - // underlying provider — more reliable than omission, which keeps - // reasoning-by-default models thinking. - return 'none' - default: - return undefined - } + return findReasoningRule(provider, model).rule?.offToken } /** From 077709b914b8b58cbd8c99f1048c7fecc2978c6b Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 30 Sep 2026 13:59:16 +0200 Subject: [PATCH 03/31] feat: rename agent memory options to On and Legacy, explain each (#11442) * feat: rename agent memory options to On and Legacy, explain each Co-Authored-By: Claude Opus 5.5 * fix: default the chat agent example to On memory and align the schema copy Co-Authored-By: Claude Opus 5.5 * feat: offer Legacy memory only to steps that hold it, explain under the toggle Co-Authored-By: Claude Opus 5.5 * fix: keep the off memory hint true on the saved-agent editor Co-Authored-By: Claude Opus 5.5 * feat: state the 128k fallback in the context window description Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cli/src/guidance/skills.gen.ts | 2 +- frontend/src/lib/common.ts | 2 + frontend/src/lib/components/ArgInput.svelte | 4 ++ .../components/flows/agentFormFields.test.ts | 17 +++---- .../lib/components/flows/agentFormFields.ts | 2 +- .../src/lib/components/flows/flowInfers.ts | 46 +++++++++++-------- system_prompts/auto-generated/flow.md | 2 +- system_prompts/auto-generated/prompts.ts | 2 +- .../auto-generated/skills/write-flow/SKILL.md | 2 +- system_prompts/base/flow-base.md | 2 +- 10 files changed, 44 insertions(+), 37 deletions(-) diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index c4658e6fef..e0b19a6287 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5788,7 +5788,7 @@ needs becomes unreachable. }, "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, - "memory": { "type": "static", "value": { "kind": "window", "context_length": 10 } }, + "memory": { "type": "static", "value": { "kind": "compaction" } }, "streaming": { "type": "static", "value": true }, "output_type": { "type": "static", "value": "text" } }, diff --git a/frontend/src/lib/common.ts b/frontend/src/lib/common.ts index 9eb019e838..daba01ab73 100644 --- a/frontend/src/lib/common.ts +++ b/frontend/src/lib/common.ts @@ -49,6 +49,8 @@ export interface SchemaProperty { hideWhenChatEnabled?: boolean /** Why the oneOf variant is chat mode's to pick. Set = selector disabled, reason shown. */ lockOneOfWhenChatEnabled?: string + /** One line under the oneOf selector explaining the selected variant, keyed by its title. */ + oneOfHints?: Record password?: boolean order?: string[] nullable?: boolean diff --git a/frontend/src/lib/components/ArgInput.svelte b/frontend/src/lib/components/ArgInput.svelte index 62165bc758..49972d3b59 100644 --- a/frontend/src/lib/components/ArgInput.svelte +++ b/frontend/src/lib/components/ArgInput.svelte @@ -1162,6 +1162,10 @@ {/each} {/snippet} + {@const oneOfHint = extra?.['oneOfHints']?.[effectiveOneOfSelected ?? '']} + {#if oneOfHint} +
{oneOfHint}
+ {/if} {#if effectiveOneOfSelected} {@const objIdx = oneOf.findIndex((o) => o.title === effectiveOneOfSelected)} {@const obj = oneOf[objIdx]} diff --git a/frontend/src/lib/components/flows/agentFormFields.test.ts b/frontend/src/lib/components/flows/agentFormFields.test.ts index c02ca23f3d..9dcf11fba0 100644 --- a/frontend/src/lib/components/flows/agentFormFields.test.ts +++ b/frontend/src/lib/components/flows/agentFormFields.test.ts @@ -110,9 +110,9 @@ describe('memoryOptionLabel', () => { // The ignored-input note names the setting by the same label its own button carries. it('names each memory option the way the field renders it', () => { expect(memoryOptionLabel({ kind: 'manual', messages: [] })).toBe('Previous messages (legacy)') - expect(memoryOptionLabel({ kind: 'auto', context_length: 4 })).toBe('Last messages (legacy)') - expect(memoryOptionLabel({ kind: 'window', context_length: 10 })).toBe('Last messages') - expect(memoryOptionLabel({ kind: 'compaction', context_window: 128000 })).toBe('Compaction') + expect(memoryOptionLabel({ kind: 'auto', context_length: 4 })).toBe('Legacy (older)') + expect(memoryOptionLabel({ kind: 'window', context_length: 10 })).toBe('Legacy') + expect(memoryOptionLabel({ kind: 'compaction', context_window: 128000 })).toBe('On') // Keeping no messages runs as off, whichever kind says so. expect(memoryOptionLabel({ kind: 'window', context_length: 0 })).toBe('Off') expect(memoryOptionLabel({ kind: 'auto' })).toBe('Off') @@ -126,20 +126,15 @@ describe('memoryPropertyFor', () => { memoryPropertyFor(property, value).oneOf.map((variant: { title: string }) => variant.title) it('adds a legacy kind as an option only while the value holds it', () => { - expect(memoryPropertyFor(property, { kind: 'window', context_length: 10 })).toBe(property) + expect(memoryPropertyFor(property, { kind: 'compaction' })).toBe(property) expect(memoryPropertyFor(property, undefined)).toBe(property) + expect(kinds({ kind: 'window', context_length: 10 })).toEqual(['off', 'compaction', 'window']) expect(kinds({ kind: 'auto', context_length: 4, memory_id: 'x' })).toEqual([ 'off', - 'window', 'compaction', 'auto' ]) - expect(kinds({ kind: 'manual', messages: [] })).toEqual([ - 'off', - 'window', - 'compaction', - 'manual' - ]) + expect(kinds({ kind: 'manual', messages: [] })).toEqual(['off', 'compaction', 'manual']) const autoVariant = (value: unknown) => memoryPropertyFor(property, value).oneOf.at(-1) expect(autoVariant({ kind: 'auto', context_length: 4 }).properties.memory_id).toBeUndefined() expect( diff --git a/frontend/src/lib/components/flows/agentFormFields.ts b/frontend/src/lib/components/flows/agentFormFields.ts index c5aaa11538..e8c5584407 100644 --- a/frontend/src/lib/components/flows/agentFormFields.ts +++ b/frontend/src/lib/components/flows/agentFormFields.ts @@ -175,7 +175,7 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [ group: 'messages', label: 'Managed memory', tooltip: - 'Windmill stores the conversation and sends it with each request: its last messages, or a summary of the older ones with the recent ones verbatim. Without instance object storage, saved memory is limited to 100KB. Compaction tries a summary, then keeps the newest complete conversation that fits, starting with a user message. If none fits, memory is not updated.', + 'Windmill stores the conversation and sends it with each request. On keeps all of it, summarizing the older part once it nears the context window. Legacy keeps only a fixed number of the last messages. Without instance object storage, saved memory is limited to 100KB: On tries a summary, then keeps the newest complete conversation that fits, starting with a user message. If none fits, memory is not updated.', implicit: { kind: 'off' }, defaultHint: 'Default: off', textOnly: true diff --git a/frontend/src/lib/components/flows/flowInfers.ts b/frontend/src/lib/components/flows/flowInfers.ts index 5f539f83cf..5f01bfc5c2 100644 --- a/frontend/src/lib/components/flows/flowInfers.ts +++ b/frontend/src/lib/components/flows/flowInfers.ts @@ -10,9 +10,9 @@ import { AGENT_HISTORY_KEYS } from './agentFormFields' * picked cannot drift from the button they see. */ export const MEMORY_OPTION_LABELS: Record = { off: 'Off', - window: 'Last messages', - compaction: 'Compaction', - auto: 'Last messages (legacy)', + compaction: 'On', + window: 'Legacy', + auto: 'Legacy (older)', manual: 'Previous messages (legacy)' } @@ -60,7 +60,7 @@ export const AI_AGENT_SCHEMA: Schema = { memory: { type: 'object', description: - 'Windmill stores the conversation and sends it with each request, keeping either its last messages or a summary of the older ones.', + 'Windmill stores the conversation and sends it with each request. On keeps all of it, summarizing the older part as it nears the context window. Legacy keeps only the last messages.', enumLabels: MEMORY_OPTION_LABELS, // Chat mode keys memory on the conversation, so a chat whose agent has memory off // forgets every turn. Enabling chat mode turns it on; this keeps it there. A step @@ -68,6 +68,13 @@ export const AI_AGENT_SCHEMA: Schema = { // an agent added to an already-chat-enabled flow — would have no way out of it. lockOneOfWhenChatEnabled: "Chat mode keys this agent's history on the conversation, so memory stays on while it is enabled.", + oneOfHints: { + off: 'Nothing is stored between runs.', + compaction: + 'Keeps the whole conversation, summarizing older messages as it nears the context window.', + window: 'Keeps only the most recent messages and drops older ones. Prefer On.', + auto: 'Keeps only the most recent messages and drops older ones. Prefer On.' + }, oneOf: [ { type: 'object', @@ -76,20 +83,6 @@ export const AI_AGENT_SCHEMA: Schema = { kind: { type: 'string', enum: ['off'] } } }, - { - type: 'object', - title: 'window', - properties: { - kind: { type: 'string', enum: ['window'] }, - context_length: { - type: 'number', - title: 'Messages to keep', - description: 'Number of most recent messages to load and store. 0 turns memory off.', - default: 10 - } - }, - required: ['kind', 'context_length'] - }, { type: 'object', title: 'compaction', @@ -99,7 +92,7 @@ export const AI_AGENT_SCHEMA: Schema = { type: 'number', title: 'Context window', description: - "Leave empty to use the model's context window. Set it for custom models." + "Leave empty to use the model's context window. Unknown models use 128k tokens, so set it for custom models." } }, required: ['kind'] @@ -219,10 +212,23 @@ export const AI_AGENT_SCHEMA: Schema = { ] } -/** Memory shapes older editors wrote. The step form offers one only to a step that still holds it, +/** Memory shapes the editor no longer offers for new steps. The step form offers one only to a step that still holds it, * since the one-of field rewrites a value that matches none of its options. No field carries a * default: the form writes one into a missing field on open, and a missing count runs as off. */ export const LEGACY_MEMORY_VARIANTS: Record = { + window: { + type: 'object', + title: 'window', + properties: { + kind: { type: 'string', enum: ['window'] }, + context_length: { + type: 'number', + title: 'Messages to keep', + description: 'Number of most recent messages to load and store. 0 turns memory off.' + } + }, + required: ['kind', 'context_length'] + }, auto: { type: 'object', title: 'auto', diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index d356a3fdfc..c45089c723 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -135,7 +135,7 @@ needs becomes unreachable. }, "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, - "memory": { "type": "static", "value": { "kind": "window", "context_length": 10 } }, + "memory": { "type": "static", "value": { "kind": "compaction" } }, "streaming": { "type": "static", "value": true }, "output_type": { "type": "static", "value": "text" } }, diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 3a278c9b23..9c6f8823e0 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -161,7 +161,7 @@ needs becomes unreachable. }, "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, - "memory": { "type": "static", "value": { "kind": "window", "context_length": 10 } }, + "memory": { "type": "static", "value": { "kind": "compaction" } }, "streaming": { "type": "static", "value": true }, "output_type": { "type": "static", "value": "text" } }, diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index e2ec5f62ef..d259948e56 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -222,7 +222,7 @@ needs becomes unreachable. }, "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, - "memory": { "type": "static", "value": { "kind": "window", "context_length": 10 } }, + "memory": { "type": "static", "value": { "kind": "compaction" } }, "streaming": { "type": "static", "value": true }, "output_type": { "type": "static", "value": "text" } }, diff --git a/system_prompts/base/flow-base.md b/system_prompts/base/flow-base.md index ee25a5ce3d..b0a8998604 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -135,7 +135,7 @@ needs becomes unreachable. }, "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, - "memory": { "type": "static", "value": { "kind": "window", "context_length": 10 } }, + "memory": { "type": "static", "value": { "kind": "compaction" } }, "streaming": { "type": "static", "value": true }, "output_type": { "type": "static", "value": "text" } }, From 52c31881fc433a03ef13f7a8cde1410da8634e19 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 30 Sep 2026 14:01:47 +0200 Subject: [PATCH 04/31] feat: add alert actions and reduce alert padding (#11439) * style: reduce alert padding * feat: add alert actions rendered as buttons in the alert's colors * fix: address review nits on alert actions and button tone * fix: color split-button outline and divider by tone * fix: apply tone hover and disabled states to the split-button chevron * refactor: ignore button tone on split buttons --- .../lib/components/DraftConflictAlert.svelte | 27 +++++------- .../lib/components/common/alert/Alert.svelte | 27 +++++++++++- .../src/lib/components/common/alert/model.ts | 13 +++++- .../components/common/button/Button.svelte | 20 +++++++-- .../src/lib/components/common/button/model.ts | 41 +++++++++++++++++++ 5 files changed, 105 insertions(+), 23 deletions(-) diff --git a/frontend/src/lib/components/DraftConflictAlert.svelte b/frontend/src/lib/components/DraftConflictAlert.svelte index 0e0fd1fb97..f364c4fd9b 100644 --- a/frontend/src/lib/components/DraftConflictAlert.svelte +++ b/frontend/src/lib/components/DraftConflictAlert.svelte @@ -1,5 +1,5 @@ - -
-
- It was saved from another tab or session since this one read it, so your changes here are no - longer being saved. -
-
- - -
-
+ + It was saved from another tab or session since this one read it, so your changes here are no longer + being saved. diff --git a/frontend/src/lib/components/common/alert/Alert.svelte b/frontend/src/lib/components/common/alert/Alert.svelte index e4cdd7bd7d..7d22320867 100644 --- a/frontend/src/lib/components/common/alert/Alert.svelte +++ b/frontend/src/lib/components/common/alert/Alert.svelte @@ -1,5 +1,6 @@ -{#if allowedHref} - {#if wmKind} - - - - - - - - - - - {#if previewAction && !modifier.held} - - {:else} - - {/if} - - - {@render children?.()} - - {#if drawerAction} - + + + {/snippet} + + {:else if wmKind} + {@render pill()} {:else} {@render children?.()} diff --git a/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts b/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts index 1e02573a32..16f13e8c25 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts @@ -51,6 +51,9 @@ export interface WorkspaceItemEntry { /** Apps only. Both kinds share the `app` kind and the same `/apps/get/` route, * so this flag is all that tells them apart downstream. */ rawApp?: boolean + /** Scripts, flows and apps that exist only as the viewer's draft: no deployed version, + * so only their editor can open them. */ + draftOnly?: boolean } export type WorkspaceItemTargetKind = 'script' | 'flow' @@ -86,7 +89,8 @@ function workspaceItemTriggerKind(kind: WindmillItemKind): CreatedResourceAction * it; the hash fragment is preserved for destination pages that consume it. */ export function itemHref(entry: WorkspaceItemEntry, workspace?: string): string { - const raw = offboardingItemHref(entry.kind, entry.path) ?? '#' + const raw = + (entry.draftOnly && draftEditHref(entry)) || offboardingItemHref(entry.kind, entry.path) || '#' if (!workspace) return raw const hashIdx = raw.indexOf('#') if (hashIdx === -1) { @@ -98,10 +102,25 @@ export function itemHref(entry: WorkspaceItemEntry, workspace?: string): string return `${pathPart}${sep}workspace=${workspace}${hashPart}` } +function draftEditHref(entry: WorkspaceItemEntry): string | undefined { + switch (entry.kind) { + case 'script': + return `/scripts/edit/${entry.path}` + case 'flow': + return `/flows/edit/${entry.path}` + case 'app': + return `/apps${entry.rawApp ? '_raw' : ''}/edit/${entry.path}` + default: + return undefined + } +} + type WorkspaceItemListResult = Array<{ path: string is_flow?: boolean | null raw_app?: boolean | null + draft_only?: boolean | null + draft_path?: string | null }> const workspaceItemLoaders: Array<{ @@ -110,9 +129,15 @@ const workspaceItemLoaders: Array<{ }> = [ // First writer wins on path collisions. Keep resources before variables because // Windmill creates a companion variable for each resource at the same path. - { kind: 'script', list: (workspace) => ScriptService.listScripts({ workspace }) }, - { kind: 'flow', list: (workspace) => FlowService.listFlows({ workspace }) }, - { kind: 'app', list: (workspace) => AppService.listApps({ workspace }) }, + { + kind: 'script', + list: (workspace) => ScriptService.listScripts({ workspace, includeDraftOnly: true }) + }, + { + kind: 'flow', + list: (workspace) => FlowService.listFlows({ workspace, includeDraftOnly: true }) + }, + { kind: 'app', list: (workspace) => AppService.listApps({ workspace, includeDraftOnly: true }) }, { kind: 'resource', list: (workspace) => ResourceService.listResource({ workspace }) }, { kind: 'variable', list: (workspace) => VariableService.listVariable({ workspace }) }, { kind: 'schedule', list: (workspace) => ScheduleService.listSchedules({ workspace }) }, @@ -172,11 +197,23 @@ class WorkspaceItemRegistry { path: it.path, targetKind: typeof it.is_flow === 'boolean' ? (it.is_flow ? 'flow' : 'script') : undefined, - rawApp: kind === 'app' ? it.raw_app === true : undefined + rawApp: kind === 'app' ? it.raw_app === true : undefined, + draftOnly: it.draft_only === true || undefined }) } } } + // A draft is often stored at an autogenerated `u//draft_` path while its + // author — and the chat — call it by `draft_path`. Resolve that name to the stored item, + // after every real path so an item actually living there keeps it. + for (const { kind, items } of loadedItems) { + for (const it of items) { + const entry = map.get(it.path) + if (it.draft_path && entry?.kind === kind && !map.has(it.draft_path)) { + map.set(it.draft_path, entry) + } + } + } // Build a new outer map to trigger reactivity on consumers using $derived. const next = new Map(this.#byWorkspace) diff --git a/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts b/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts index 70810dad9a..764177bff5 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts @@ -4,34 +4,56 @@ import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import type { Root as MdastRoot, Link, Text } from 'mdast' -vi.mock('$lib/gen', () => ({ - ScriptService: { listScripts: vi.fn() }, - FlowService: { listFlows: vi.fn() }, - AppService: { listApps: vi.fn() }, - VariableService: { listVariable: vi.fn() }, - ResourceService: { listResource: vi.fn() }, - ScheduleService: { listSchedules: vi.fn() }, - HttpTriggerService: { listHttpTriggers: vi.fn() }, - WebsocketTriggerService: { listWebsocketTriggers: vi.fn() }, - KafkaTriggerService: { listKafkaTriggers: vi.fn() }, - NatsTriggerService: { listNatsTriggers: vi.fn() }, - PostgresTriggerService: { listPostgresTriggers: vi.fn() }, - MqttTriggerService: { listMqttTriggers: vi.fn() }, - SqsTriggerService: { listSqsTriggers: vi.fn() }, - GcpTriggerService: { listGcpTriggers: vi.fn() }, - AzureTriggerService: { listAzureTriggers: vi.fn() }, - EmailTriggerService: { listEmailTriggers: vi.fn() } -})) +vi.mock('$lib/gen', () => { + const list = () => vi.fn(async () => []) + return { + ScriptService: { listScripts: list() }, + FlowService: { listFlows: list() }, + AppService: { listApps: list() }, + VariableService: { listVariable: list() }, + ResourceService: { listResource: list() }, + ScheduleService: { listSchedules: list() }, + HttpTriggerService: { listHttpTriggers: list() }, + WebsocketTriggerService: { listWebsocketTriggers: list() }, + KafkaTriggerService: { listKafkaTriggers: list() }, + NatsTriggerService: { listNatsTriggers: list() }, + PostgresTriggerService: { listPostgresTriggers: list() }, + MqttTriggerService: { listMqttTriggers: list() }, + AmqpTriggerService: { listAmqpTriggers: list() }, + SqsTriggerService: { listSqsTriggers: list() }, + GcpTriggerService: { listGcpTriggers: list() }, + AzureTriggerService: { listAzureTriggers: list() }, + EmailTriggerService: { listEmailTriggers: list() } + } +}) +import { FlowService } from '$lib/gen' import { extractCandidatePaths, itemHref, remarkWindmillPaths, WINDMILL_PATH_REGEX, workspaceItemAction, + workspaceItemRegistry, type WorkspaceItemEntry } from './workspaceItems.svelte' +describe('workspaceItemRegistry', () => { + it('resolves a draft stored at a synthetic path by the name its author gave it', async () => { + vi.mocked(FlowService.listFlows).mockResolvedValueOnce([ + { + path: 'u/me/draft_1234', + draft_path: 'u/me/report', + draft_only: true + } as any + ]) + await workspaceItemRegistry.ensureLoaded('ws-draft') + const entry = workspaceItemRegistry.resolve('ws-draft', 'u/me/report') + expect(entry).toMatchObject({ kind: 'flow', path: 'u/me/draft_1234', draftOnly: true }) + expect(itemHref(entry!, 'ws-draft')).toBe('/flows/edit/u/me/draft_1234?workspace=ws-draft') + }) +}) + describe('WINDMILL_PATH_REGEX', () => { it('matches simple folder and user paths', () => { expect(extractCandidatePaths('Use f/marketing/send_email today')).toEqual([ @@ -86,6 +108,16 @@ describe('itemHref', () => { ) }) + it('routes a draft-only script/flow/app to its editor, which is all that can open it', () => { + expect(itemHref({ kind: 'script', path: 'u/me/s', draftOnly: true }, 'ws1')).toBe( + '/scripts/edit/u/me/s?workspace=ws1' + ) + expect(itemHref({ kind: 'flow', path: 'u/me/f', draftOnly: true })).toBe('/flows/edit/u/me/f') + expect(itemHref({ kind: 'app', path: 'u/me/a', rawApp: true, draftOnly: true })).toBe( + '/apps_raw/edit/u/me/a' + ) + }) + it('routes variable / resource / schedule to list page with hash fragment', () => { expect(itemHref({ kind: 'variable', path: 'u/me/secret' })).toBe('/variables#u/me/secret') expect(itemHref({ kind: 'resource', path: 'u/me/db' }, 'ws1')).toBe( From 9613549abc1c1d63abb252e8a675c6a73142b26f Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 30 Sep 2026 14:05:12 +0200 Subject: [PATCH 06/31] feat: group consecutive tool calls in the AI chat (#11329) * feat: group consecutive flow edits into one collapsible chat row Co-Authored-By: Claude Opus 5.5 (1M context) * feat: group lookups, name flow steps and fade tool label changes Co-Authored-By: Claude Opus 5.5 (1M context) * fix: count rejected draft saves as failed in tool groups Co-Authored-By: Claude Opus 5.5 (1M context) * fix: address review nits on chat tool grouping Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hold a tool group's live step line like its header Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hold a tool group's live line in the same value as its header Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep main's new tool cards and held calls out of tool groups Co-Authored-By: Claude Opus 5.5 (1M context) * feat: group app edits and mark single-server MCP groups with the server icon Co-Authored-By: Claude Opus 5.5 (1M context) * fix: tell MCP servers apart by full path in tool group headers Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep a streaming edit in its group and stop rekeying unsettled labels Co-Authored-By: Claude Opus 5.5 (1M context) * fix: word a queued tool group as settled until a call starts Co-Authored-By: Claude Opus 5.5 (1M context) * fix: give queued tool groups their own wording and leave unnamed calls ungrouped Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep a tool group in progress between two of its calls Co-Authored-By: Claude Opus 5.5 (1M context) * fix: treat an edit without a path as unknown unless it is a flow-mode tool Co-Authored-By: Claude Opus 5.5 (1M context) * fix: let flow mode's pathless edits group again, keep unnamed app edits apart Co-Authored-By: Claude Opus 5.5 (1M context) * fix: only flow mode's pathless tools default to the open flow Co-Authored-By: Claude Opus 5.5 (1M context) * fix: key tool rows by their call so a loaded chat never shows a held label Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Ruben Fiszel --- .../copilot/chat/AIChatDisplay.svelte | 37 +- .../copilot/chat/AIChatMessage.svelte | 13 +- .../copilot/chat/ChatCollapsibleCard.svelte | 59 +++- .../copilot/chat/ToolExecutionDisplay.svelte | 10 +- .../copilot/chat/ToolGroupDisplay.svelte | 87 +++++ .../copilot/chat/draftWriteResults.ts | 4 + .../components/copilot/chat/global/core.ts | 22 +- .../copilot/chat/heldValue.svelte.ts | 43 +++ .../src/lib/components/copilot/chat/shared.ts | 83 +---- .../copilot/chat/toolGroups.test.ts | 226 ++++++++++++ .../lib/components/copilot/chat/toolGroups.ts | 330 ++++++++++++++++++ .../copilot/chat/webSearchResult.ts | 76 ++++ 12 files changed, 891 insertions(+), 99 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/ToolGroupDisplay.svelte create mode 100644 frontend/src/lib/components/copilot/chat/draftWriteResults.ts create mode 100644 frontend/src/lib/components/copilot/chat/heldValue.svelte.ts create mode 100644 frontend/src/lib/components/copilot/chat/toolGroups.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/toolGroups.ts create mode 100644 frontend/src/lib/components/copilot/chat/webSearchResult.ts diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 387eea15d0..d5406aeca2 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -50,6 +50,8 @@ import { getChatViewHost } from './chatViewHost' import { getAiChatManager } from './aiChatManagerContext' import ChatTypingIndicator from './ChatTypingIndicator.svelte' + import ToolGroupDisplay from './ToolGroupDisplay.svelte' + import { chatItemKey, groupToolRuns } from './toolGroups' import AIChatInput from './AIChatInput.svelte' import AttachedFilesBar from './files/AttachedFilesBar.svelte' import QueuedMessageChip from './QueuedMessageChip.svelte' @@ -237,6 +239,7 @@ const mcpMenu = new McpMenu(aiChatManager, () => assistantSettings?.open('mcp')) let plusMenuOpen = $state(false) let editingMessageIndex = $state(null) + const chatItems = $derived(groupToolRuns(messages)) // Escape stops the generation when focus is on the chat (or parked on // body), but stays with other widgets (e.g. the session's Monaco editor). @@ -872,15 +875,45 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> onscroll={onScroll} >
- {#each messages as message, messageIndex (messageIndex)} + {#snippet messageRow( + message: DisplayMessage, + messageIndex: number, + isLast: boolean, + inGroup: boolean + )} + {/snippet} + {#snippet groupRow(message: DisplayMessage, messageIndex: number)} + {@render messageRow(message, messageIndex, false, true)} + {/snippet} + {#each chatItems as item (chatItemKey(item))} + {#if item.kind === 'group'} +
+
+ +
+
+ {:else} + {@render messageRow( + item.message, + item.index, + item.index === messages.length - 1, + false + )} + {/if} {/each} {#if freeTierExhausted} {@render freeTierExhaustedBanner()} diff --git a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte index d639255c93..d006307967 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte @@ -47,6 +47,8 @@ editingMessageIndex: number | null isLast?: boolean showAnswerActions?: boolean + /** Rendered inside a tool group, which owns the horizontal padding and the preview chip. */ + inGroup?: boolean } let { @@ -55,7 +57,8 @@ availableContext, editingMessageIndex = $bindable(null), isLast = false, - showAnswerActions = true + showAnswerActions = true, + inGroup = false }: Props = $props() // The edit box edits a copy of THIS message's original context, not the live @@ -146,7 +149,8 @@ class={twMerge( 'text-sm px-2', message.role === 'user' && 'py-1', - message.role === 'tool' && 'text-primary' + message.role === 'tool' && 'text-primary', + inGroup && 'px-0' )} > {#if message.role === 'assistant'} @@ -159,7 +163,10 @@ > {:else if message.role === 'tool'}
{:else} {#if message.role === 'user' && message.images && message.images.length > 0} diff --git a/frontend/src/lib/components/copilot/chat/ChatCollapsibleCard.svelte b/frontend/src/lib/components/copilot/chat/ChatCollapsibleCard.svelte index f9a5950e1e..9bb2aacf2d 100644 --- a/frontend/src/lib/components/copilot/chat/ChatCollapsibleCard.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatCollapsibleCard.svelte @@ -1,8 +1,9 @@
@@ -59,8 +80,8 @@ highlight && 'text-emphasis' )} > - {#if labelPrefix}{labelPrefix} {/if}{label} + {#if shown.prefix}{shown.prefix} {/if}{shown.label} {/snippet} @@ -75,16 +96,27 @@ aria-expanded={toggleable ? expanded : undefined} > {@render headerLeft?.()} - {#if shimmer} - - {@render labelText(false)} - + + {#key settleLabel ? `${shown.prefix ?? ''}\n${shown.label}` : ''} + + {#if shown.shimmer} + + {@render labelText(false)} + + + {:else} + {@render labelText(false)} + {/if} - {:else} - {@render labelText(false)} - {/if} + {/key} {#if toggleable} {shown.liveLine}
+ {/if} {@render belowHeader?.()} {#if expanded && children} diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index aa8dc47cec..e9eac7e8f4 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -51,9 +51,10 @@ interface Props { message: ToolDisplayMessage + hidePreviewChip?: boolean } - let { message }: Props = $props() + let { message, hidePreviewChip = false }: Props = $props() // Recorded by the call itself, from the connected-server list rather than from the // model's arguments — which is what lets a reloaded transcript still resolve it, and @@ -142,7 +143,11 @@ // shown once the tool settled, never while loading/erroring/awaiting confirmation. const showPreviewChip = $derived( Boolean( - message.previewCard && !message.isLoading && !message.error && !message.needsConfirmation + !hidePreviewChip && + message.previewCard && + !message.isLoading && + !message.error && + !message.needsConfirmation ) ) @@ -317,6 +322,7 @@ onToggle={() => (isExpanded = !isExpanded)} toggleable={detailsAvailable || message.isStreamingArguments === true} shimmer={isRunning} + settleLabel class={message.isQueued && !message.error ? 'opacity-60 hover:opacity-100 transition-opacity' : ''} diff --git a/frontend/src/lib/components/copilot/chat/ToolGroupDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolGroupDisplay.svelte new file mode 100644 index 0000000000..3754467214 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ToolGroupDisplay.svelte @@ -0,0 +1,87 @@ + + +{#snippet serverMark()} + {#if mcpServer} + {#await resolveMcpServerMark(mcpServer.workspace, mcpServer.path) then mark} + + {/await} + {/if} +{/snippet} + +{#snippet status()} +
+ {#if failedCount > 0} + {failedCount} failed + {/if} + {#if previewCard} + + {/if} +
+{/snippet} + + (expanded = !expanded)} + shimmer={running} + settleLabel + {liveLine} + class={queued ? 'opacity-60 hover:opacity-100 transition-opacity' : ''} + labelClass="truncate" + headerRight={failedCount > 0 || previewCard ? status : undefined} + headerLeft={mcpServer ? serverMark : undefined} + contentClass="border-0 border-l rounded-none bg-transparent p-0 pl-2 ml-1.5 mt-0.5" +> + {#each group.entries as { message, index } (index)} + {@render entry(message, index)} + {/each} + diff --git a/frontend/src/lib/components/copilot/chat/draftWriteResults.ts b/frontend/src/lib/components/copilot/chat/draftWriteResults.ts new file mode 100644 index 0000000000..2b49c86859 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/draftWriteResults.ts @@ -0,0 +1,4 @@ +/** `result` of a draft write that did not land. The write reports it through `result` alone, not + * `error`, so a tool group reads these to count the call as failed. */ +export const DRAFT_CONFLICT_RESULT = 'Conflict' +export const DRAFT_SAVE_FAILED_RESULT = 'Save failed' diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index a69a2debca..e0d1cf683c 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -250,6 +250,8 @@ import { saveGlobalAppDraft, type DraftPersistResult } from './userDraftAdapter' +import { findModuleInFlow } from '$lib/components/flows/flowTree' +import { DRAFT_CONFLICT_RESULT, DRAFT_SAVE_FAILED_RESULT } from '../draftWriteResults' import { computeDiffParts, expireWorkspaceDiffList, @@ -5125,7 +5127,7 @@ function draftWriteFailure(result: DraftPersistResult, ctx: WriteDraftCtx): stri if (result.status === 'conflict') { ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: `Draft ${stored.type} "${stored.path}" changed externally`, - result: `Conflict` + result: DRAFT_CONFLICT_RESULT }) return JSON.stringify( { @@ -5140,7 +5142,7 @@ function draftWriteFailure(result: DraftPersistResult, ctx: WriteDraftCtx): stri if (result.status === 'error') { ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: `Failed to save ${stored.type} "${stored.path}"`, - result: `Save failed` + result: DRAFT_SAVE_FAILED_RESULT }) return JSON.stringify( { @@ -5710,7 +5712,7 @@ async function readFlowModuleCode( ) } toolCallbacks.setToolStatus(toolId, { - content: `Read inline script for "${args.module_id}"` + content: `Read code of step ${flowStepName(base.flow.value, args.module_id)}` }) return content } @@ -5733,7 +5735,7 @@ async function setFlowModuleCode( } session.set(args.module_id, args.code) const newFlowValue = applyEditableFlowJsonToFlow(base.flow.value, editable, session) - return writeFlowDraft( + const result = await writeFlowDraft( { path: args.path, summary: base.summary, @@ -5741,6 +5743,18 @@ async function setFlowModuleCode( }, ctx ) + // Several code edits of one flow read as identical rows under the generic flow label. + if (JSON.parse(result).success) { + toolCallbacks.setToolStatus(toolId, { + content: `Updated code of step ${flowStepName(base.flow.value, args.module_id)}` + }) + } + return result +} + +function flowStepName(flow: FlowValue, moduleId: string): string { + const summary = findModuleInFlow(flow, moduleId)?.summary + return summary ? `${moduleId} "${summary}"` : moduleId } function normalizeTestRunArgs(args: Record | null | undefined): Record { diff --git a/frontend/src/lib/components/copilot/chat/heldValue.svelte.ts b/frontend/src/lib/components/copilot/chat/heldValue.svelte.ts new file mode 100644 index 0000000000..2e9c49c370 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/heldValue.svelte.ts @@ -0,0 +1,43 @@ +import { watch } from 'runed' + +/** How long a tool-call status label stays up before a newer one replaces it. */ +export const LABEL_MIN_MS = 700 + +/** + * A value that, once shown, stays shown for at least `minMs`. A change arriving sooner waits + * for the rest of that time, and only the latest of the changes made meanwhile is shown. The + * first value starts the window too, so a change right after mount is held like any other. + * Must be constructed during component initialisation. + */ +export class HeldValue { + #current = $state() as T + #shownAt = Date.now() + + constructor(getter: () => T, minMs: () => number) { + this.#current = getter() + watch( + getter, + (value) => { + const wait = this.#shownAt + minMs() - Date.now() + if (wait <= 0) { + this.#show(value) + return + } + // Re-run on every change, so the cleanup drops the pending value in favour of the + // newer one, and also clears the timer on unmount. + const timer = setTimeout(() => this.#show(value), wait) + return () => clearTimeout(timer) + }, + { lazy: true } + ) + } + + #show(value: T) { + this.#current = value + this.#shownAt = Date.now() + } + + get current(): T { + return this.#current + } +} diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 6df8b68518..af386b8617 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -62,6 +62,7 @@ import { scriptLangToEditorLang } from '$lib/scripts' import { getCurrentModel } from '$lib/aiStore' import type { editor as meditor } from 'monaco-editor' import { pendingFolderInstructions, type FolderInstructionsContext } from './folderInstructions' +import type { WebSearchSource } from './webSearchResult' // Prettify function for code arguments - extracts and formats code from JSON function prettifyCodeArguments(content: string): string { @@ -607,11 +608,12 @@ export type RunFormDraft = { schema: Record } -/** One page hit from a provider-side web search (OpenAI sources carry no title). */ -export type WebSearchSource = { - url: string - title?: string -} +export { + isRenderableSourceUrl, + webSearchResultOf, + type WebSearchResult, + type WebSearchSource +} from './webSearchResult' export type ToolCodeDiff = { before: string @@ -620,77 +622,6 @@ export type ToolCodeDiff = { lang: string } -/** The result shape any tool returns to have it rendered as a web search card. */ -export type WebSearchResult = { - sources: WebSearchSource[] - query?: string -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -/** A link the card can render: the source list drops everything else, so a result whose - * urls are relative or `javascript:` would show as an empty list in place of its own output. */ -export function isRenderableSourceUrl(url: string): boolean { - try { - return ['http:', 'https:'].includes(new URL(url).protocol) - } catch { - return false - } -} - -/** An absent optional field reaches JSON as `null` from a Python tool and as nothing from a - * TypeScript one, so both read as absent. */ -function isOptionalString(value: unknown): boolean { - return value === null || typeof value === 'string' -} - -function isWebSearchSource(value: unknown): value is { url: string; title?: unknown } { - return ( - isRecord(value) && - typeof value.url === 'string' && - isRenderableSourceUrl(value.url) && - Object.entries(value).every( - ([key, v]) => key === 'url' || (key === 'title' && isOptionalString(v)) - ) - ) -} - -/** - * A tool result as a web search, or undefined when it is anything else. The keys must be - * exactly `sources` and an optional `query`: the card renders only urls and titles, so a - * result carrying more would lose it. Accepts the result as its JSON text too. - */ -export function webSearchResultOf(result: unknown): WebSearchResult | undefined { - if (typeof result === 'string') { - try { - result = JSON.parse(result) - } catch { - return undefined - } - } - if (!isRecord(result) || !Array.isArray(result.sources) || result.sources.length === 0) { - return undefined - } - const sources = result.sources - if ( - !sources.every(isWebSearchSource) || - !Object.entries(result).every( - ([key, v]) => key === 'sources' || (key === 'query' && isOptionalString(v)) - ) - ) { - return undefined - } - return { - sources: sources.map((source) => ({ - url: source.url, - title: typeof source.title === 'string' ? source.title : undefined - })), - query: typeof result.query === 'string' ? result.query : undefined - } -} - export type ToolDisplayMessage = { role: 'tool' tool_call_id: string diff --git a/frontend/src/lib/components/copilot/chat/toolGroups.test.ts b/frontend/src/lib/components/copilot/chat/toolGroups.test.ts new file mode 100644 index 0000000000..f75332c87d --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolGroups.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest' +import type { DisplayMessage } from './shared' +import { groupHeader, groupToolRuns, type GroupState, type ToolGroup } from './toolGroups' + +let nextId = 0 +function tool( + toolName: string, + parameters: Record = {}, + extra = {} +): DisplayMessage { + return { + role: 'tool', + tool_call_id: `call_${nextId++}`, + content: toolName, + toolName, + parameters, + ...extra + } +} +function assistant(content: string, extra = {}): DisplayMessage { + return { role: 'assistant', content, ...extra } as DisplayMessage +} + +function shape(messages: DisplayMessage[]) { + return groupToolRuns(messages).map((item) => + item.kind === 'group' ? { [item.groupKind]: item.entries.map((e) => e.index) } : item.index + ) +} + +function header(messages: DisplayMessage[], state: GroupState = 'settled') { + const group = groupToolRuns(messages).find((item) => item.kind === 'group') as ToolGroup + const { prefix, label } = groupHeader(group, state) + return prefix ? `${prefix} ${label}` : label +} + +describe('groupToolRuns', () => { + it('folds edits and reads of one flow, with thinking in between, into one group', () => { + const flow = { path: 'f/a/flow' } + expect( + shape([ + assistant('Let me edit the flow.'), + tool('read_flow_module_code', flow), + tool('patch_flow_json', flow), + assistant('', { reasoning: 'next step' }), + tool('set_flow_module_code', flow), + assistant('', { reasoning: 'done' }), + assistant('All steps updated.') + ]) + ).toEqual([0, { edit: [1, 2, 3, 4] }, 5, 6]) + }) + + it('splits on a different flow, on visible text, and leaves a lone edit ungrouped', () => { + const a = { path: 'f/a/flow' } + const b = { path: 'f/b/flow' } + expect( + shape([ + tool('patch_flow_json', a), + tool('patch_flow_json', a), + tool('patch_flow_json', b), + assistant('Now the other one.'), + tool('patch_flow_json', b) + ]) + ).toEqual([{ edit: [0, 1] }, 2, 3, 4]) + }) + + it('never folds a call waiting for confirmation, and folds reads without an edit as exploring', () => { + const flow = { path: 'f/a/flow' } + expect( + shape([ + tool('patch_flow_json', flow), + tool('patch_flow_json', flow, { needsConfirmation: true, isLoading: true }), + tool('read_flow_module_code', flow), + tool('read_flow_module_code', flow) + ]) + ).toEqual([0, 1, { explore: [2, 3] }]) + expect( + shape([ + tool('patch_flow_json', flow), + tool('patch_flow_json', flow, { declinedByUser: true, error: 'Cancelled by user' }), + tool('patch_flow_json', flow) + ]) + ).toEqual([0, 1, 2]) + const sources = JSON.stringify({ sources: [{ url: 'https://example.com', title: 'x' }] }) + expect( + shape([ + tool('search_docs'), + tool('call_mcp_read_tool', { server: 'u/a/s', tool: 'search' }, { result: sources }), + tool('patch_flow_json', flow, { heldForFolderInstructions: true }), + tool('patch_flow_json', flow) + ]) + ).toEqual([0, 1, 2, 3]) + }) + + it('keeps an edit whose arguments are still streaming in the group it follows', () => { + const flow = { path: 'f/a/flow' } + expect( + shape([ + tool('patch_flow_json', flow), + tool('patch_flow_json', '{"path":"f/a/flow","old_str' as never, { + isStreamingArguments: true + }), + tool('set_flow_module_code', '{"pa' as never, { isStreamingArguments: true }) + ]) + ).toEqual([{ edit: [0, 1, 2] }]) + expect( + shape([ + tool('patch_flow_json', flow), + tool('patch_flow_json', '{"path":"f/b/flow","old_str' as never, { + isStreamingArguments: true + }) + ]) + ).toEqual([0, 1]) + }) + + it('lets a call with no arguments yet join a group but not start one', () => { + const queued = (toolName: string) => + tool(toolName, {}, { parameters: undefined, isQueued: true }) + expect(shape([queued('delete_app_file'), queued('delete_app_runnable')])).toEqual([0, 1]) + expect(shape([tool('patch_app_file', { path: 'f/a/x' }), queued('delete_app_file')])).toEqual([ + { edit: [0, 1] } + ]) + // Arguments without a path are a call the tool will reject, not an edit of the open + // flow; only flow-mode tools, which never take a path, share the open flow. + expect( + shape([tool('write_app_file', { file_path: '/a' }), tool('patch_app_file', {})]) + ).toEqual([0, 1]) + expect( + shape([ + tool('set_module_code', { moduleId: 'a' }), + tool('patch_flow_json', { old_string: 'a', new_string: 'b' }), + tool('set_module_code', { moduleId: 'b' }) + ]) + ).toEqual([{ edit: [0, 1, 2] }]) + // A global-only flow tool always takes a path, so without one its item is unknown. + expect(shape([tool('write_flow', { summary: 'x' }), tool('set_flow_module_code', {})])).toEqual( + [0, 1] + ) + }) + + it('folds edits and reads of one app, apart from a flow at the same path', () => { + const app = { path: 'f/a/x', file_path: '/src/App.tsx' } + expect( + shape([ + tool('read_app_file', app), + tool('patch_app_file', app), + tool('write_app_runnable', { path: 'f/a/x', key: 'fetch' }), + tool('patch_flow_json', { path: 'f/a/x' }), + tool('patch_flow_json', { path: 'f/a/x' }) + ]) + ).toEqual([{ edit: [0, 1, 2] }, { edit: [3, 4] }]) + }) + + it('leaves a flow read that prepares an edit to the edit group, not the lookups before it', () => { + const flow = { type: 'flow', path: 'f/a/flow' } + expect( + shape([ + tool('search_workspace'), + tool('list_workspace_items'), + tool('read_workspace_item', flow), + tool('patch_flow_json', flow) + ]) + ).toEqual([{ explore: [0, 1] }, { edit: [2, 3] }]) + }) + + it('folds consecutive lookups of any tool, split by a write or a row with its own card', () => { + expect( + shape([ + tool('search_workspace'), + assistant('', { reasoning: 'look closer' }), + tool('read_workspace_item', { type: 'script', path: 'f/a/s' }), + tool('get_run', {}, { inspectedRun: { jobId: 'x' } }), + tool('list_runs'), + tool('search_docs'), + tool('write_script', { path: 'f/a/s' }), + tool('search_workspace') + ]) + ).toEqual([{ explore: [0, 1, 2] }, 3, { explore: [4, 5] }, 6, 7]) + }) + + it('names what a group did', () => { + const flow = { path: 'f/a/flow' } + expect( + header([ + tool('read_flow_module_code', flow), + tool('patch_flow_json', flow), + tool('patch_flow_json', flow) + ]) + ).toBe('Edited f/a/flow · 2 changes') + // A save rejected as a conflict reports it in `result`, not `error`: not a change. + expect( + header([ + tool('patch_flow_json', flow), + tool('patch_flow_json', flow, { result: 'Conflict' }), + tool('set_flow_module_code', flow, { result: 'Save failed' }) + ]) + ).toBe('Edited f/a/flow · 1 change') + // Before any call runs, the header says what will happen, not what did. + expect( + header( + [ + tool('patch_flow_json', flow, { isQueued: true }), + tool('patch_flow_json', flow, { isQueued: true }) + ], + 'queued' + ) + ).toBe('Edit f/a/flow · 2 changes') + expect( + header([ + tool('search_workspace'), + tool('read_workspace_item'), + tool('search_workspace'), + tool('read_workspace_item'), + tool('list_runs') + ]) + ).toBe('Search workspace 2 times, read 2 workspace item, list runs') + const mcp = (t: string) => tool('call_mcp_read_tool', { server: 'u/admin/github', tool: t }) + expect(header([mcp('list_issues'), mcp('get_issue'), mcp('get_issue')])).toBe( + 'github list issues, get issue 2 times' + ) + expect(header([tool('search_mcp_tools'), mcp('list_issues'), mcp('get_issue')])).toBe( + 'github search mcp tools, list issues, get issue' + ) + const other = tool('call_mcp_read_tool', { server: 'f/team/github', tool: 'get_issue' }) + expect(header([mcp('list_issues'), other])).toBe('List issues, get issue') + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/toolGroups.ts b/frontend/src/lib/components/copilot/chat/toolGroups.ts new file mode 100644 index 0000000000..fd4cbc28ee --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolGroups.ts @@ -0,0 +1,330 @@ +import type { DisplayMessage, ToolDisplayMessage } from './shared' +import { DRAFT_CONFLICT_RESULT, DRAFT_SAVE_FAILED_RESULT } from './draftWriteResults' +import { webSearchResultOf } from './webSearchResult' + +// A tool missing from these lists always renders as its own row, so a new write never gets +// hidden by default. + +type EditedItemKind = 'flow' | 'app' + +// Tools that fold into an edit group, by the kind of item they edit; the item is the call's +// `path` ('' for flow mode's tools, which edit the open flow). Scripts are left out on purpose: each script edit +// renders its own diff card, and the diff is what the user reads. +const EDIT_TOOLS: Record = { + patch_flow_json: 'flow', + set_flow_module_code: 'flow', + write_flow: 'flow', + set_flow_json: 'flow', + set_module_code: 'flow', + set_preprocessor_module: 'flow', + set_failure_module: 'flow', + init_app: 'app', + write_app_file: 'app', + patch_app_file: 'app', + delete_app_file: 'app', + write_app_runnable: 'app', + delete_app_runnable: 'app' +} +// Reads of the item being edited: the model reads a step or file before changing it, and +// splitting the group on every read would leave one group per edit. +const ITEM_READ_TOOLS: Record = { + read_flow_module_code: 'flow', + inspect_inline_script: 'flow', + get_lint_errors: 'flow', + read_app_file: 'app', + search_app: 'app' +} +// The grouped tools flow mode (flow/core.ts) offers without a `path`: they act on the flow open +// in the editor. patch_flow_json shares its name with the global tool, which takes a path. +const PATHLESS_FLOW_MODE_TOOLS = new Set([ + 'set_flow_json', + 'patch_flow_json', + 'set_module_code', + 'set_preprocessor_module', + 'set_failure_module', + 'inspect_inline_script', + 'get_lint_errors' +]) +// Calls that only look things up. Not derived from `planModeSafe`, which also admits test +// runs and plan-document writes. +const READ_TOOLS = new Set([ + ...Object.keys(ITEM_READ_TOOLS), + 'list_workspace_items', + 'read_workspace_item', + 'search_workspace', + 'get_runnable_details', + 'search_hub_scripts', + 'search_resource_types', + 'resource_type', + 'search_docs', + 'read_docs_page', + 'get_db_schema', + 'search_npm_packages', + 'get_instructions', + 'get_instructions_for_code_generation', + 'read_skill', + 'get_trigger_schema', + 'get_schedule_schema', + 'list_runs', + 'list_workers', + 'list_app_runs', + 'get_app_runtime_logs', + 'get_preview_status', + 'get_current_page_name', + 'search_dom', + 'read_dom', + 'read_file', + 'search_files', + 'list_data_metrics', + 'list_ducklakes', + 'get_pipeline_graph', + 'read_pipeline_node', + 'list_artifacts', + 'read_artifact', + 'list_artifact_versions', + 'search_mcp_tools', + 'call_mcp_read_tool' +]) + +export type ToolGroup = { + kind: 'group' + /** 'edit': edits of one flow or app. 'explore': consecutive lookups of anything. */ + groupKind: 'edit' | 'explore' + /** First call's id, so the group keeps its identity (and expand state) as it grows. */ + key: string + /** Edit groups: the item's path, or '' for the flow open in the editor. */ + target: string + entries: { message: DisplayMessage; index: number }[] +} + +export type ChatItem = { kind: 'message'; message: DisplayMessage; index: number } | ToolGroup + +/** A tool row is keyed by its call, not its position: loading another chat puts a different + * call at the same index, and a reused row would carry the old call's held label over. */ +export function chatItemKey(item: ChatItem): string { + if (item.kind === 'group') return `g:${item.key}` + if (item.message.role === 'tool') return `t:${item.message.tool_call_id}` + return `m:${item.index}` +} + +function groupableCall(message: DisplayMessage): ToolDisplayMessage | undefined { + if (message.role !== 'tool' || !message.toolName) return undefined + // A row waiting on the user, or refused by plan mode, is a decision the user must see; a + // row with its own card (run, question, diff, image, sources) is the content itself; a call + // held for folder instructions never ran, so it is not a change or a lookup. + if (message.needsConfirmation || message.blockedByPlanMode) return undefined + if (message.heldForFolderInstructions) return undefined + if (message.runForm || message.inspectedRun || message.userQuestion) return undefined + if (message.codeDiff || message.imageUrl || message.webSearchSources) return undefined + if (!message.error && webSearchResultOf(message.result)) return undefined + // The user's own refusal is a decision, not a failure to fold away. + if (message.declinedByUser) return undefined + return message +} + +/** `path` undefined: the call's arguments have not named its item yet (still streaming, or + * not on the row yet). */ +type Membership = { kind: EditedItemKind; path: string | undefined; edit: boolean } + +// While arguments stream, `parameters` is the partial JSON text. The path usually lands well +// before the large code or content field, so read it out once its closing quote has arrived. +function streamedPath(text: string): string | undefined { + const match = /"path"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(text) + if (!match) return undefined + try { + return JSON.parse(`"${match[1]}"`) + } catch { + return undefined + } +} + +function itemMembership(message: DisplayMessage): Membership | undefined { + const call = groupableCall(message) + if (!call) return undefined + const streaming = typeof call.parameters === 'string' + const params = streaming ? {} : (call.parameters ?? {}) + const tool = call.toolName! + // No `parameters` at all: a call queued before its arguments reached the row (tools that do + // not stream them), so its item is not known yet. Arguments without a path mean the open + // flow only for a tool flow mode offers without one; any other tool requires a path, so + // its item is unknown. + const path = streaming + ? streamedPath(call.parameters) + : call.parameters === undefined + ? undefined + : typeof params.path === 'string' + ? params.path + : PATHLESS_FLOW_MODE_TOOLS.has(tool) + ? '' + : undefined + if (Object.hasOwn(EDIT_TOOLS, tool)) return { kind: EDIT_TOOLS[tool], path, edit: true } + if (Object.hasOwn(ITEM_READ_TOOLS, tool)) { + return { kind: ITEM_READ_TOOLS[tool], path, edit: false } + } + if (tool === 'read_workspace_item' && (params.type === 'flow' || params.type === 'app') && path) { + return { kind: params.type, path, edit: false } + } + return undefined +} + +// A flow and an app can share a path, so the kind is part of what makes two calls one item. A +// call whose path has not arrived yet stays with the group it follows, or the edit in progress +// would leave its own group and the header would read as settled. +function sameItem(a: Membership | undefined, b: Membership): boolean { + return a !== undefined && a.kind === b.kind && (a.path === undefined || a.path === b.path) +} + +function isReadCall(message: DisplayMessage): boolean { + const call = groupableCall(message) + return call !== undefined && READ_TOOLS.has(call.toolName!) +} + +// Thinking between two calls stays inside the group; visible text ends it. The live +// streaming message is never absorbed, or the reasoning in progress would be hidden. +function isSilentAssistant(message: DisplayMessage): boolean { + return message.role === 'assistant' && !message.streaming && message.content.trim() === '' +} + +/** Index of the last call in the run starting at `start`, crossing silent assistant + * messages; trailing silent messages stay outside. */ +function runEnd( + messages: DisplayMessage[], + start: number, + accepts: (m: DisplayMessage, index: number) => boolean +) { + let end = start + for (let j = start + 1; j < messages.length; j++) { + if (accepts(messages[j], j)) end = j + else if (!isSilentAssistant(messages[j])) break + } + return end +} + +function toolGroup( + messages: DisplayMessage[], + start: number, + end: number, + groupKind: ToolGroup['groupKind'], + target: string +): ToolGroup | undefined { + const run = messages.slice(start, end + 1) + const calls = run.filter((m) => m.role === 'tool') + if (calls.length < 2) return undefined + if (groupKind === 'edit' && !calls.some((m) => itemMembership(m)?.edit)) return undefined + return { + kind: 'group', + groupKind, + key: (messages[start] as ToolDisplayMessage).tool_call_id, + target, + entries: run.map((message, k) => ({ message, index: start + k })) + } +} + +function editGroupAt(messages: DisplayMessage[], start: number): ToolGroup | undefined { + const item = itemMembership(messages[start]) + // A call whose item is not known yet may join a group but not start one: two such calls + // could be edits of different items. + if (!item || item.path === undefined) return undefined + const end = runEnd(messages, start, (m) => sameItem(itemMembership(m), item)) + return toolGroup(messages, start, end, 'edit', item.path) +} + +export function groupToolRuns(messages: DisplayMessage[]): ChatItem[] { + const items: ChatItem[] = [] + let i = 0 + while (i < messages.length) { + // An edit group wins over an explore group: its reads belong to the edits they prepare, + // so an explore run also stops before a read that starts one. + const explores = (m: DisplayMessage, j: number) => isReadCall(m) && !editGroupAt(messages, j) + const group = + editGroupAt(messages, i) ?? + (isReadCall(messages[i]) + ? toolGroup(messages, i, runEnd(messages, i, explores), 'explore', '') + : undefined) + if (group) { + items.push(group) + i = group.entries.at(-1)!.index + 1 + } else { + items.push({ kind: 'message', message: messages[i], index: i }) + i++ + } + } + return items +} + +// The server's full resource path: two servers can share a last segment (u/alice/github, +// f/team/github), so only the display shortens it. +function mcpServerPath(call: ToolDisplayMessage): string | undefined { + if (call.toolName !== 'call_mcp_read_tool') return undefined + const server = call.mcpServer?.path ?? call.parameters?.server + return typeof server === 'string' ? server : undefined +} + +function callName(call: ToolDisplayMessage): string { + const mcpTool = call.parameters?.tool + const name = + call.toolName === 'call_mcp_read_tool' && typeof mcpTool === 'string' + ? mcpTool + : (call.toolName ?? '') + return name.replaceAll('_', ' ') +} + +// A collapsed group would otherwise hide a draft save that did not land. +const FAILED_SAVE_RESULTS = new Set([DRAFT_CONFLICT_RESULT, DRAFT_SAVE_FAILED_RESULT]) +export function callFailed(call: ToolDisplayMessage): boolean { + return ( + call.error !== undefined || + (typeof call.result === 'string' && FAILED_SAVE_RESULTS.has(call.result)) + ) +} + +function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? '' : 's'}` +} + +// Each read reads one thing, so the count goes on the object ("read 3 workspace item"). On +// other verbs it would miscount what came back ("list 2 runs" after listing twice). +const COUNTED_VERBS = new Set(['read', 'inspect']) +function repeated(name: string, n: number): string { + if (n === 1) return name + const [verb, ...rest] = name.split(' ') + return COUNTED_VERBS.has(verb) && rest.length > 0 + ? `${verb} ${n} ${rest.join(' ')}` + : `${name} ${n} times` +} + +/** `queued`: every call is still waiting its turn. Every call in a turn is queued before the + * first one runs, so this state shows on each edit run; like a single row's queued label, it + * says what will happen rather than what did. */ +export type GroupState = 'queued' | 'running' | 'settled' + +/** The group's header, e.g. `Edited` + `f/a/flow · 3 changes`, + * `search workspace 2 times, read 2 workspace item`, `github` + `list issues, get issue 2 times`. */ +export function groupHeader( + group: ToolGroup, + state: GroupState +): { prefix: string; label: string } { + const calls = group.entries + .map((e) => e.message) + .filter((m): m is ToolDisplayMessage => m.role === 'tool') + if (group.groupKind === 'edit') { + const edits = calls.filter( + (m) => Object.hasOwn(EDIT_TOOLS, m.toolName ?? '') && !callFailed(m) + ).length + return { + prefix: { queued: 'Edit', running: 'Editing', settled: 'Edited' }[state], + label: `${group.target || 'the flow'} · ${plural(edits, 'change')}` + } + } + const counts = new Map() + for (const call of calls) counts.set(callName(call), (counts.get(callName(call)) ?? 0) + 1) + const list = [...counts].map(([name, n]) => repeated(name, n)).join(', ') + // A tool search spans every server and usually comes right before the calls it found, so it + // does not stop the group from reading as one server's. + const servers = new Set( + calls.filter((call) => call.toolName !== 'search_mcp_tools').map(mcpServerPath) + ) + const [server] = servers + if (servers.size === 1 && server) return { prefix: server.split('/').at(-1)!, label: list } + return { prefix: '', label: list.charAt(0).toUpperCase() + list.slice(1) } +} diff --git a/frontend/src/lib/components/copilot/chat/webSearchResult.ts b/frontend/src/lib/components/copilot/chat/webSearchResult.ts new file mode 100644 index 0000000000..279813ad9d --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/webSearchResult.ts @@ -0,0 +1,76 @@ +/** One page hit from a provider-side web search (OpenAI sources carry no title). */ +export type WebSearchSource = { + url: string + title?: string +} + +/** The result shape any tool returns to have it rendered as a web search card. */ +export type WebSearchResult = { + sources: WebSearchSource[] + query?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** A link the card can render: the source list drops everything else, so a result whose + * urls are relative or `javascript:` would show as an empty list in place of its own output. */ +export function isRenderableSourceUrl(url: string): boolean { + try { + return ['http:', 'https:'].includes(new URL(url).protocol) + } catch { + return false + } +} + +/** An absent optional field reaches JSON as `null` from a Python tool and as nothing from a + * TypeScript one, so both read as absent. */ +function isOptionalString(value: unknown): boolean { + return value === null || typeof value === 'string' +} + +function isWebSearchSource(value: unknown): value is { url: string; title?: unknown } { + return ( + isRecord(value) && + typeof value.url === 'string' && + isRenderableSourceUrl(value.url) && + Object.entries(value).every( + ([key, v]) => key === 'url' || (key === 'title' && isOptionalString(v)) + ) + ) +} + +/** + * A tool result as a web search, or undefined when it is anything else. The keys must be + * exactly `sources` and an optional `query`: the card renders only urls and titles, so a + * result carrying more would lose it. Accepts the result as its JSON text too. + */ +export function webSearchResultOf(result: unknown): WebSearchResult | undefined { + if (typeof result === 'string') { + try { + result = JSON.parse(result) + } catch { + return undefined + } + } + if (!isRecord(result) || !Array.isArray(result.sources) || result.sources.length === 0) { + return undefined + } + const sources = result.sources + if ( + !sources.every(isWebSearchSource) || + !Object.entries(result).every( + ([key, v]) => key === 'sources' || (key === 'query' && isOptionalString(v)) + ) + ) { + return undefined + } + return { + sources: sources.map((source) => ({ + url: source.url, + title: typeof source.title === 'string' ? source.title : undefined + })), + query: typeof result.query === 'string' ? result.query : undefined + } +} From ca44043e12d4713d67ca13eb546eef9d00cc965e Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:09:14 +0200 Subject: [PATCH 07/31] feat: let operators compose flows when the workspace grants the right (#11228) * feat: let a workspace withdraw operator schedule and trigger writes Operators can create, edit and delete schedules and triggers today through the API, CLI and MCP, while the operator_settings flags beside them only hide those pages. An admin who wants operators to see what is scheduled without letting them change it cannot express that. Add manage_schedules and manage_triggers as enforced settings, gated at the schedule handlers and at the generic TriggerCrud routes so every trigger kind is covered by one check. They name capabilities operators already hold, so they are granted unless withdrawn, and absence has to mean "never configured" rather than a value. The read coalesces to true; the update endpoint merges into the stored jsonb with the two fields as Option, so an omitted key keeps what is stored. operator_settings is git-synced as a whole object, so a settings file written before these keys existed reaches the endpoint on every pull, and a serde or SQL default of either polarity would turn that pull into a silent withdrawal or restoration. The rights are read through a per-process cache, so withdrawing one publishes a notify_event that drops the entry on every replica. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dsf6VC4MVLisiEoeQkgbr4 * feat: let operators compose flows when the workspace grants the right Adds operator_settings.builder_flows: a workspace setting that lets every operator compose flows out of runnables that already exist. It does not make them authors. The boundary the operator role draws is authoring code and running arbitrary code, and this does not move it: check_flow_is_composition_only walks the value and refuses anything carrying code, including the shapes an obvious walk misses (code hoisted into a flow_node, an AI agent step's tools, and a linked ai_agent resource whose tool list is resolved at run time). What the walk cannot settle it returns for the caller to authorize under RLS: the worker tags the steps pin, every runnable they reference, and the (path, hash) of every version-pinned step. Composing a path is enough to run it and to run it as whoever it runs as, since the worker resolves a step's path with the root DB handle and adopts that runnable's on_behalf_of. A pinned hash needs its own check because dispatch ignores the path beside it. The gate runs on every write and on both request-supplied-value paths, flow preview and flow dependencies, or either becomes the way to run what the write path refuses. Operators of a builder workspace consume a full author seat; the EE companion carries the counting. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dsf6VC4MVLisiEoeQkgbr4 * feat: enforce operator write rights on the router and in the UI Co-Authored-By: Claude Opus 5 (1M context) * fix: close the capture gap and gate the trigger editors' write actions Co-Authored-By: Claude Opus 5 (1M context) * fix: gate acl writes and the native trigger drawer behind manage rights Co-Authored-By: Claude Opus 5 (1M context) * fix: refuse operator writes with 403 and gate sharing at the drawer Co-Authored-By: Claude Opus 5 (1M context) * perf: resolve identity in the operator write gate only for writes Co-Authored-By: Claude Opus 5 (1M context) * fix: gate the suspended-jobs actions and stop the route check refusing reads Co-Authored-By: Claude Opus 5 (1M context) * fix: explain the empty-state create button when operator writes are withdrawn Co-Authored-By: Claude Opus 5 (1M context) * fix: audit operator settings changes and fold path writes into native rows Co-Authored-By: Claude Opus 5.5 (1M context) * fix: open locked editors read-only and group the operator settings Co-Authored-By: Claude Opus 5.5 (1M context) * fix: skip email and azure lookups on editor open while triggers are locked Co-Authored-By: Claude Opus 5.5 (1M context) * docs: state each operator-rights rationale once in comments Co-Authored-By: Claude Opus 5.5 (1M context) * fix: address CI review findings on operator write rights Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep capture move gated and skip it in the builders while locked Co-Authored-By: Claude Opus 5.5 (1M context) * fix: refuse builder-rights violations with 403 so operators stay logged in Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: trim duplication in the operator builder gates Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hide build app from builder operators on the flow page Co-Authored-By: Claude Opus 5.5 (1M context) * chore: point at the companion EE PR merged with EE main Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep a builder's drafts list loading past drafts they cannot write Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hide saved agents from builder operators in the step picker Co-Authored-By: Claude Opus 5.5 (1M context) * chore: note the inlined seat rule and drop orphaned sqlx entries Co-Authored-By: Claude Opus 5.5 (1M context) * fix: stop a builder's step test from logging them out Co-Authored-By: Claude Opus 5.5 (1M context) * fix: refuse a builder's dependency job on a path it cannot write Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep builders from adding dynamic dropdown code to a flow A flow's dropdown code runs as whoever loads its form, so a builder may keep or drop the code stored on the flow it updates, never add or change it. The builder's editor hides the dropdown types and code, and previews options through the deployed flow; the inline dropdown refusal is a 403 so it no longer logs operators out. Also trims rationale comments repeated across sites. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: show why saving operator settings failed The seat-cap refusal on granting builder rights explains what to do; the toast now carries the server's message instead of a generic failure. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: check builder flow drafts like deploys and treat dropdown code as code A developer who loads a builder's flow draft in the editor runs its dynamic dropdown code as themselves, so a builder's draft now passes the same checks as a deploy. Dropdown code is refused like step code rather than kept or dropped, which also removes the exact-match comparison that refused builders over whitespace. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: bill a builder workspace's operators as developers on cloud The cloud seat count behind the Premium page, the sidebar usage and the fork cap still weighed every operator at half a seat, while the builder right makes them authors. The out-of-repo invoicing job must follow the same rule. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: check a builder's flow draft as it will be stored Draft storage strips NUL escapes after the builder check, so a key ending in one (value\u0000, x-windmill-dyn-select-code\u0000) passed the check as an unknown field and was stored under its plain name. The check now reads the sanitized text. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: list a builder's flow drafts and hide hub imports from builders A builder's undeployed flows now appear in the home list, the flow list and the folder counts. Hub project imports and templates bring scripts and apps along, so builders are no longer offered them. The docs record builders' JavaScript expressions as an accepted risk. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the stored builder right when a settings payload omits it A git-synced settings file written before the key existed withdrew the right on every push. builder_flows now follows the manage_* rights: an omitted key leaves the stored value, and the CLI does not count it as a difference. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: word builder refusals by what the flow contains, test the tag refusal A builder refused on a developer's flow never changed its code, so the refusals now describe the flow ("has inline code, so only a developer can edit this flow") rather than an authoring attempt. The grant confirmation uses the neutral dialog: granting changes billing but destroys nothing. The integration test pins the refusal of a worker tag the workspace cannot use. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: read builder rights from the operating workspace, gate the flow page's audit logs entry Builder rights now come from useOperatorBuilderFlows(), next to the schedule and trigger locks, so an editor embedded for another workspace answers about that workspace; the legacy AI chat, one instance for the whole app, reads the navigation workspace. The flow page's Audit logs entry follows the operator audit_logs setting now that builders open that menu. Operator settings reset every value on load, null settings included, so nothing carries over from the previous workspace. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: pick a dynamic dropdown's code source by the operating user's role The flow input editor, the flow test panel and the flow chat send the dropdown request to the operating workspace, so they now also choose inline versus deployed code by the role held there, through useOperatingUser(), instead of the navigation workspace's. Co-Authored-By: Claude Opus 5.5 (1M context) * chore: update ee-repo-ref to 40ac1c5f8cbce3843b582d9b392d3f3cc7eca3e6 This commit updates the EE repository reference after PR #815 was merged in windmill-ee-private. Previous ee-repo-ref: 31c9e66884b8ca805b20bbfad41fc428fbedbc0e New ee-repo-ref: 40ac1c5f8cbce3843b582d9b392d3f3cc7eca3e6 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 Co-authored-by: windmill-internal-app[bot] --- AGENTS.md | 2 + ...0ea539e84c33de23476e042a4a1a7cb529dac.json | 34 ++ ...f5dd1616a62c6ecbaaea312c686038d21e6fd.json | 24 ++ ...72e74af6828c1ab52cf7a6fd10b305deade85.json | 23 ++ ...8b2af07e995de71564d867b171f97f719fbde.json | 23 ++ ...af063c36ac17e9d331cd9980b91b28d713c72.json | 22 -- ...999e6d0c732da83ebc74ef7678cacd22ad6b.json} | 4 +- backend/ee-repo-ref.txt | 2 +- backend/tests/inline_preview_auth.rs | 18 +- backend/windmill-api-flows/src/flows.rs | 178 ++++++++-- .../tests/operator_builder_flows.rs | 317 ++++++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 45 ++- backend/windmill-api/openapi.yaml | 3 + backend/windmill-api/src/drafts.rs | 72 +++- backend/windmill-api/src/jobs.rs | 62 +++- backend/windmill-api/src/runnables.rs | 19 +- backend/windmill-common/src/ee_oss.rs | 8 + backend/windmill-common/src/flows.rs | 240 +++++++++++++ backend/windmill-common/src/workspaces.rs | 75 ++++- .../tests/billing_workspace.rs | 15 + cli/src/core/settings.ts | 2 +- docs/operator-builder-rights.md | 76 +++++ docs/operator-write-rights.md | 2 +- .../lib/components/EditableSchemaForm.svelte | 28 +- .../src/lib/components/FlowBuilder.svelte | 13 +- .../lib/components/FlowPreviewContent.svelte | 24 +- .../components/common/table/FlowRow.svelte | 20 +- .../copilot/chat/AIChatManager.svelte.ts | 14 +- .../flows/common/FlowCardHeader.svelte | 6 +- .../components/flows/content/FlowInput.svelte | 2 + .../flows/content/FlowInputsQuick.svelte | 17 +- .../flows/content/FlowModuleComponent.svelte | 13 +- .../conversations/FlowChatInterface.svelte | 20 +- .../flows/map/FlowModuleSchemaItem.svelte | 13 +- .../flows/map/InsertModuleInner.svelte | 21 +- .../components/home/CreateActionsMenu.svelte | 17 +- .../src/lib/components/home/ItemsList.svelte | 17 +- .../home/WorkspaceEmptyState.svelte | 12 +- .../settings/WorkspaceOperatorSettings.svelte | 98 ++++-- frontend/src/lib/operatorWriteRights.ts | 26 +- frontend/src/lib/utils.ts | 14 + .../(logged)/flows/get/[...path]/+page.svelte | 58 ++-- 42 files changed, 1464 insertions(+), 235 deletions(-) create mode 100644 backend/.sqlx/query-094a07eaa5714ec739786717f110ea539e84c33de23476e042a4a1a7cb529dac.json create mode 100644 backend/.sqlx/query-298e0e30afdc973c517a9b4ea99f5dd1616a62c6ecbaaea312c686038d21e6fd.json create mode 100644 backend/.sqlx/query-3dccc8e745f4a0973541088f66172e74af6828c1ab52cf7a6fd10b305deade85.json create mode 100644 backend/.sqlx/query-4bc47050c74a02ab3169c3165898b2af07e995de71564d867b171f97f719fbde.json delete mode 100644 backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json rename backend/.sqlx/{query-4afdc63af51e599b9d6fe6cedcd7980fd761a29df0f2a6820f3c8d7a29c3c20a.json => query-cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b.json} (53%) create mode 100644 backend/windmill-api-integration-tests/tests/operator_builder_flows.rs create mode 100644 docs/operator-builder-rights.md diff --git a/AGENTS.md b/AGENTS.md index f4bbfec6ce..54b5953748 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,8 @@ Open-source platform for internal tools, workflows, API integrations, background - **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option` fields and a jsonb merge rather than a serde default +- **Operator builder rights**: `docs/operator-builder-rights.md` — the workspace setting that lets + operators compose flows, what the composition check must cover, and why it costs a full seat - **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and that every superadmin route refuses `$WM_TOKEN`. Read before designing anything that creates diff --git a/backend/.sqlx/query-094a07eaa5714ec739786717f110ea539e84c33de23476e042a4a1a7cb529dac.json b/backend/.sqlx/query-094a07eaa5714ec739786717f110ea539e84c33de23476e042a4a1a7cb529dac.json new file mode 100644 index 0000000000..e0174943d6 --- /dev/null +++ b/backend/.sqlx/query-094a07eaa5714ec739786717f110ea539e84c33de23476e042a4a1a7cb529dac.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE((operator_settings->>'builder_flows')::boolean, false) AS \"flows!\",\n COALESCE((operator_settings->>'manage_schedules')::boolean, true) AS \"schedules!\",\n COALESCE((operator_settings->>'manage_triggers')::boolean, true) AS \"triggers!\"\n FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "flows!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "schedules!", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "triggers!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "094a07eaa5714ec739786717f110ea539e84c33de23476e042a4a1a7cb529dac" +} diff --git a/backend/.sqlx/query-298e0e30afdc973c517a9b4ea99f5dd1616a62c6ecbaaea312c686038d21e6fd.json b/backend/.sqlx/query-298e0e30afdc973c517a9b4ea99f5dd1616a62c6ecbaaea312c686038d21e6fd.json new file mode 100644 index 0000000000..9d396e3a33 --- /dev/null +++ b/backend/.sqlx/query-298e0e30afdc973c517a9b4ea99f5dd1616a62c6ecbaaea312c686038d21e6fd.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND hash = $3)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "298e0e30afdc973c517a9b4ea99f5dd1616a62c6ecbaaea312c686038d21e6fd" +} diff --git a/backend/.sqlx/query-3dccc8e745f4a0973541088f66172e74af6828c1ab52cf7a6fd10b305deade85.json b/backend/.sqlx/query-3dccc8e745f4a0973541088f66172e74af6828c1ab52cf7a6fd10b305deade85.json new file mode 100644 index 0000000000..c6e572863a --- /dev/null +++ b/backend/.sqlx/query-3dccc8e745f4a0973541088f66172e74af6828c1ab52cf7a6fd10b305deade85.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3dccc8e745f4a0973541088f66172e74af6828c1ab52cf7a6fd10b305deade85" +} diff --git a/backend/.sqlx/query-4bc47050c74a02ab3169c3165898b2af07e995de71564d867b171f97f719fbde.json b/backend/.sqlx/query-4bc47050c74a02ab3169c3165898b2af07e995de71564d867b171f97f719fbde.json new file mode 100644 index 0000000000..9b2b79808d --- /dev/null +++ b/backend/.sqlx/query-4bc47050c74a02ab3169c3165898b2af07e995de71564d867b171f97f719fbde.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4bc47050c74a02ab3169c3165898b2af07e995de71564d867b171f97f719fbde" +} diff --git a/backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json b/backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json deleted file mode 100644 index 35b70a239c..0000000000 --- a/backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT bool_and(operator) FROM (\n SELECT operator FROM usr WHERE email = $1 AND is_service_account IS false\n UNION ALL\n SELECT operator FROM workspace_invite WHERE email = $1\n ) t", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "bool_and", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72" -} diff --git a/backend/.sqlx/query-4afdc63af51e599b9d6fe6cedcd7980fd761a29df0f2a6820f3c8d7a29c3c20a.json b/backend/.sqlx/query-cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b.json similarity index 53% rename from backend/.sqlx/query-4afdc63af51e599b9d6fe6cedcd7980fd761a29df0f2a6820f3c8d7a29c3c20a.json rename to backend/.sqlx/query-cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b.json index 02a2cb9973..a2a87b680d 100644 --- a/backend/.sqlx/query-4afdc63af51e599b9d6fe6cedcd7980fd761a29df0f2a6820f3c8d7a29c3c20a.json +++ b/backend/.sqlx/query-cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh') AND username NOT IN (SELECT email FROM usr WHERE is_service_account)),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg", + "query": "WITH active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh') AND username NOT IN (SELECT email FROM usr WHERE is_service_account)),\n active_authors as (SELECT distinct t.email FROM usr t LEFT JOIN workspace_settings ws ON ws.workspace_id = t.workspace_id WHERE NOT (t.operator AND NOT COALESCE((ws.operator_settings->>'builder_flows')::boolean, false)) AND t.email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg", "describe": { "columns": [ { @@ -34,5 +34,5 @@ null ] }, - "hash": "4afdc63af51e599b9d6fe6cedcd7980fd761a29df0f2a6820f3c8d7a29c3c20a" + "hash": "cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 4cfbcb8447..076b313eb6 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -5a2b6b8527250bd12cf856d8a667a9ef3106ec60 +40ac1c5f8cbce3843b582d9b392d3f3cc7eca3e6 diff --git a/backend/tests/inline_preview_auth.rs b/backend/tests/inline_preview_auth.rs index 8841719962..acf511c26c 100644 --- a/backend/tests/inline_preview_auth.rs +++ b/backend/tests/inline_preview_auth.rs @@ -18,7 +18,7 @@ //! //! This test pins down: //! - an Operator's own token is rejected by the operator guard (the core fix; -//! pre-fix this reached the inline executor instead of returning 401), +//! pre-fix this reached the inline executor instead of returning 403), //! - a regular non-operator passes the guard (the fix must not over-block the //! legitimate inline preview flow): in the test harness the worker inline //! utils are not registered, so a caller past the guard gets the distinct @@ -121,10 +121,10 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result // 1. CORE REGRESSION: an Operator must be rejected by the operator guard. // Pre-fix this fell through to the inline executor (arbitrary code - // execution); post-fix it returns 401 with the operator guard message. + // execution); post-fix it returns 403 with the operator guard message. let (status, body) = post(&url, "OPERATOR_TOKEN", &inline_preview_body()).await; assert_eq!( - status, 401, + status, 403, "Operator must be rejected from inline preview (got {status}): {body}" ); assert!( @@ -139,7 +139,7 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result // the operator guard did not reject it. let (status, body) = post(&url, "SECRET_TOKEN_2", &inline_preview_body()).await; assert_ne!( - status, 401, + status, 403, "non-operator must not be blocked by the operator guard (got {status}): {body}" ); assert!( @@ -155,7 +155,7 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result operator_job_token(uuid::Uuid::parse_str(RUNNING_JOB_ID).unwrap()).await; let (status, body) = post(&url, &running_job_token, &datatable_query_body()).await; assert_ne!( - status, 401, + status, 403, "operator job token of a running job must pass the guard for a datatable query (got {status}): {body}" ); assert!( @@ -188,7 +188,7 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result ] { let (status, body) = post(&url, &running_job_token, &payload).await; assert_eq!( - status, 401, + status, 403, "operator job token must be rejected for a {label} payload (got {status}): {body}" ); assert!( @@ -208,7 +208,7 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result let token = operator_job_token(job_id).await; let (status, body) = post(&url, &token, &datatable_query_body()).await; assert_eq!( - status, 401, + status, 403, "operator job token of a {label} job must be rejected (got {status}): {body}" ); assert!( @@ -229,7 +229,7 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result ); let (status, body) = post(&fallback_url, "OPERATOR_TOKEN", &datatable_query_body()).await; assert_eq!( - status, 401, + status, 403, "Operator must be rejected from the preview fallback (got {status}): {body}" ); assert!( @@ -246,7 +246,7 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result let deferred_url = format!("{fallback_url}?{deferral}"); let (status, body) = post(&deferred_url, &running_job_token, &datatable_query_body()).await; assert_eq!( - status, 401, + status, 403, "operator job token must not schedule a deferred preview with {deferral} (got {status}): {body}" ); assert!( diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index c7df69879e..aa86a7ff07 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -16,10 +16,12 @@ use axum::{ }; use windmill_api_auth::{ auth::{list_tokens_internal, TruncatedTokenWithEmail}, - build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path, - ApiAuthed, + build_scope_path_predicate, check_scopes, get_scope_tags, maybe_refresh_folders, + require_owner_of_path, ApiAuthed, +}; +use windmill_common::workspaces::{ + check_deploy_rules, check_operator_can_build_flows, operator_can_build_flows, RuleCheckResult, }; -use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use windmill_common::{ user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, utils::HTTP_CLIENT, @@ -35,7 +37,7 @@ use sqlx::{FromRow, Postgres, Transaction}; use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::assets::{clear_static_asset_usage, AssetUsageKind}; -use windmill_common::flows::FlowModule; +use windmill_common::flows::{FlowModule, FlowValue}; use windmill_common::min_version::{ MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2, MIN_VERSION_SUPPORTS_NODE_DEBOUNCING, @@ -241,7 +243,7 @@ async fn list_flows( // Append the authed user's drafts at paths with no deployed flow; see scripts.rs. if lq.include_draft_only.unwrap_or(false) - && !authed.is_operator + && (!authed.is_operator || operator_can_build_flows(&db, &w_id).await?) && offset == 0 && lq.path_start.is_none() && lq.path_exact.is_none() @@ -570,7 +572,13 @@ async fn list_paths_linking_agent( Ok(Json(flows)) } -async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> { +async fn validate_flow( + new_flow: &NewFlow, + authed: &ApiAuthed, + db: &DB, + user_db: &UserDB, + w_id: &str, +) -> error::Result<()> { #[cfg(not(feature = "enterprise"))] if new_flow.ws_error_handler_muted.is_some_and(|val| val) { return Err(Error::BadRequest( @@ -581,9 +589,139 @@ async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> { guard_flow_from_debounce_data(new_flow).await?; + if authed.is_operator { + validate_operator_flow( + &new_flow.parse_flow_value()?, + &new_flow.tag, + new_flow.schema.as_ref().map(|s| s.0.get()), + authed, + db, + user_db, + w_id, + ) + .await?; + } + return Ok(()); } +/// What an operator with builder rights must pass to store a flow, deployed or as a draft: a +/// developer who loads a builder's draft in the editor runs its code as themselves. +pub async fn validate_operator_flow( + value: &FlowValue, + flow_tag: &Option, + schema: Option<&str>, + authed: &ApiAuthed, + db: &DB, + user_db: &UserDB, + w_id: &str, +) -> error::Result<()> { + // Dynamic dropdown code runs as whoever loads the flow's form: it is code like a step's. + if let Some(schema) = schema { + let schema: serde_json::Value = serde_json::from_str(schema)?; + if schema.get("x-windmill-dyn-select-code").is_some() { + return Err(Error::PermissionDenied( + "This flow has dynamic dropdown code, so only a developer can edit it".to_string(), + )); + } + } + validate_operator_composed_flow(value, flow_tag, authed, db, user_db, w_id).await +} + +/// Runs on every write and every preview of a flow authored by an operator with builder rights. +/// The walk in `check_flow_is_composition_only` only sees the value; what it collects is +/// authorized here against the caller's own permissions. +pub async fn validate_operator_composed_flow( + value: &FlowValue, + flow_tag: &Option, + authed: &ApiAuthed, + db: &DB, + user_db: &UserDB, + w_id: &str, +) -> error::Result<()> { + let mut refs = windmill_common::flows::check_flow_is_composition_only(value)?; + + // A tag is how a step picks the worker group it runs on: unauthorized, a builder could route + // a job onto a privileged one. + refs.tags.extend(flow_tag.clone().filter(|t| !t.is_empty())); + if !refs.tags.is_empty() { + // Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags. + let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?; + for tag in &refs.tags { + windmill_common::jobs::check_tag_available_for_workspace_internal( + db, + w_id, + tag, + None, + std::future::ready(w_id.to_string()), + is_super_admin, + get_scope_tags(authed), + ) + .await?; + } + } + + if refs.runnables.is_empty() && refs.pinned_scripts.is_empty() { + return Ok(()); + } + // A flow can step through the same script thirty times; this runs on every write, preview and + // dependency job. + refs.runnables.sort(); + refs.runnables.dedup(); + refs.pinned_scripts + .sort_by_key(|(path, hash)| (path.clone(), hash.0)); + refs.pinned_scripts + .dedup_by_key(|(path, hash)| (path.clone(), hash.0)); + // The worker resolves a step's path with the root DB handle and runs it as that runnable's + // `on_behalf_of`, so composing an unreadable path would run code the builder cannot see. RLS + // on this transaction is the check. + let mut tx = user_db.clone().begin(authed).await?; + for (is_flow, path) in &refs.runnables { + let readable = if *is_flow { + sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)", + w_id, + path, + ) + } else { + sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2)", + w_id, + path, + ) + } + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + if !readable { + return Err(Error::PermissionDenied(format!( + "{} {path} does not exist or is not readable by you", + if *is_flow { "Flow" } else { "Script" } + ))); + } + } + // A pinned step is dispatched by its hash alone, ignoring the path beside it, so a readable + // path paired with another script's hash would still run that other script. + for (path, hash) in &refs.pinned_scripts { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND hash = $3)", + w_id, + path, + hash.0, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + if !exists { + return Err(Error::PermissionDenied(format!( + "Version {hash} is not a readable version of {path}" + ))); + } + } + tx.commit().await?; + Ok(()) +} + async fn create_flow( authed: ApiAuthed, Extension(db): Extension, @@ -592,11 +730,7 @@ async fn create_flow( Path(w_id): Path, Json(mut nf): Json, ) -> Result<(StatusCode, String)> { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot create flows for security reasons".to_string(), - )); - } + check_operator_can_build_flows(&db, &w_id, authed.is_operator, "create flows").await?; check_scopes(&authed, || format!("flows:write:{}", nf.path))?; // A `<= 0` flow timeout is "unset", not a 0-second limit that kills every run instantly. @@ -616,7 +750,7 @@ async fn create_flow( return Err(Error::PermissionDenied(msg)); } - validate_flow(&nf).await?; + validate_flow(&nf, &authed, &db, &user_db, &w_id).await?; if *CLOUD_HOSTED { let nb_flows = sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id) @@ -1187,11 +1321,7 @@ async fn update_flow( Path((w_id, flow_path)): Path<(String, StripPath)>, Json(ef): Json, ) -> Result { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot update flows for security reasons".to_string(), - )); - } + check_operator_can_build_flows(&db, &w_id, authed.is_operator, "update flows").await?; let flow_path = flow_path.to_path(); // The URL identifies the flow being updated; the body path is only needed to rename. let mut nf = ef.into_new_flow(flow_path); @@ -1218,7 +1348,7 @@ async fn update_flow( return Err(Error::PermissionDenied(msg)); } - validate_flow(&nf).await?; + validate_flow(&nf, &authed, &db, &user_db, &w_id).await?; let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await; let mut tx = user_db.clone().begin(&authed).await?; @@ -1855,11 +1985,7 @@ async fn archive_flow_by_path( Path((w_id, path)): Path<(String, StripPath)>, Json(archived): Json, ) -> Result { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot archive flows for security reasons".to_string(), - )); - } + check_operator_can_build_flows(&db, &w_id, authed.is_operator, "archive flows").await?; let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( @@ -2000,11 +2126,7 @@ async fn delete_flow_by_path( Path((w_id, path)): Path<(String, StripPath)>, Query(query): Query, ) -> Result { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot delete flows for security reasons".to_string(), - )); - } + check_operator_can_build_flows(&db, &w_id, authed.is_operator, "delete flows").await?; let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( diff --git a/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs b/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs new file mode 100644 index 0000000000..7be3920124 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs @@ -0,0 +1,317 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::workspaces::invalidate_operator_rights_cache; +use windmill_test_utils::*; + +const WS: &str = "test-workspace"; + +fn operator_client() -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str("Bearer OPERATOR_TOKEN_1").unwrap(), + ); + reqwest::ClientBuilder::new() + .default_headers(headers) + .build() + .unwrap() +} + +async fn set_builder(db: &Pool, flows: bool) -> anyhow::Result<()> { + sqlx::query( + "UPDATE workspace_settings SET operator_settings = $1::text::jsonb WHERE workspace_id = $2", + ) + .bind(format!(r#"{{"builder_flows": {flows}}}"#)) + .bind(WS) + .execute(db) + .await?; + // The right is read through a process-global 60s cache keyed by workspace id. + invalidate_operator_rights_cache(WS); + Ok(()) +} + +async fn add_script(db: &Pool, hash: i64, path: &str, owner: &str) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, + summary, description, lock, extra_perms) + VALUES ($1, $2, $3, 'x', 'bun', 'script', $4, '{}', '', '', '', '{}')", + ) + .bind(WS) + .bind(hash) + .bind(path) + .bind(owner) + .execute(db) + .await?; + Ok(()) +} + +fn composition_flow_at(path: &str, step_path: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "", + "description": "", + "schema": {}, + "value": {"modules": [{ + "id": "a", + "value": {"type": "script", "path": step_path, "input_transforms": {}} + }]} + }) +} + +fn inline_code_flow(path: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "", + "description": "", + "schema": {}, + "value": {"modules": [{ + "id": "a", + "value": { + "type": "rawscript", + "content": "export async function main() { return 1 }", + "language": "bun", + "input_transforms": {} + } + }]} + }) +} + +/// The whole boundary in one pass: the builder right lets an operator compose runnables that are +/// already deployed and nothing more, and the endpoints that author code stay shut whether or not +/// it is granted. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +async fn test_operator_builder_flows_boundary(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let api = format!("http://localhost:{port}/api/w/{WS}"); + let c = operator_client(); + + // A composition-only flow references a runnable that exists and the builder can read, so the + // fixture needs one. + add_script(&db, 4241, "u/operator/some_script", "operator").await?; + + set_builder(&db, false).await?; + let resp = c + .post(format!("{api}/flows/create")) + .json(&composition_flow_at( + "u/operator/f1", + "u/operator/some_script", + )) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "an operator without the builder right must not create a flow" + ); + + set_builder(&db, true).await?; + // A payload that omits the key, like a git-sync file that predates it, keeps the right. + let resp = reqwest::Client::new() + .post(format!("{api}/workspaces/operator_settings")) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({"runs": true})) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + let resp = c + .post(format!("{api}/flows/create")) + .json(&composition_flow_at( + "u/operator/f1", + "u/operator/some_script", + )) + .send() + .await?; + assert!( + resp.status().is_success(), + "a builder must be able to create a composition-only flow: {}", + resp.text().await? + ); + + let mut tagged = composition_flow_at("u/operator/f5", "u/operator/some_script"); + tagged["tag"] = json!("privileged_group"); + let resp = c + .post(format!("{api}/flows/create")) + .json(&tagged) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "a builder must not route a flow onto a worker tag the workspace cannot use" + ); + + let mut with_dyn_code = composition_flow_at("u/operator/f1", "u/operator/some_script"); + with_dyn_code["schema"] = + json!({"x-windmill-dyn-select-code": "x", "x-windmill-dyn-select-lang": "bun"}); + let resp = c + .post(format!("{api}/flows/update/u/operator/f1")) + .json(&with_dyn_code) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not save a flow carrying dropdown code" + ); + let resp = c + .post(format!("{api}/jobs/run/dynamic_select")) + .json( + &json!({"entrypoint_function": "f", "runnable_ref": {"source": "inline", "code": "x"}}), + ) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "an operator's inline dropdown refusal must not be a 401" + ); + + // A dependency job rewrites bookkeeping stored under the path it names, so a builder must not + // aim one at a path it cannot write. + let resp = c + .post(format!("{api}/jobs/run/flow_dependencies")) + .json(&json!({"path": "u/alice/private_flow", "flow_value": {"modules": []}})) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not run a dependency job on a path it cannot write" + ); + + // A legacy draft has no owner, so it lists as the builder's own, and a script draft is one + // the builder cannot write: that row must read as not writable, not fail the whole list. + sqlx::query( + "INSERT INTO draft (workspace_id, path, typ, value) VALUES ($1, 'u/operator/some_script', 'script', '{}')", + ) + .bind(WS) + .execute(&db) + .await?; + let resp = c.get(format!("{api}/drafts/list")).send().await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let drafts: serde_json::Value = resp.json().await?; + assert_eq!(drafts[0]["can_write"], false, "{drafts}"); + + let resp = c + .post(format!("{api}/flows/create")) + .json(&inline_code_flow("u/operator/f2")) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not deploy a flow carrying inline code" + ); + + // Draft storage strips a NUL, turning these keys into the plain ones the check reads. + let mut nul_value = inline_code_flow("u/operator/f1"); + let v = nul_value.as_object_mut().unwrap().remove("value").unwrap(); + nul_value["value\0"] = v; + let mut nul_dyn = composition_flow_at("u/operator/f1", "u/operator/some_script"); + nul_dyn["schema"] = json!({"x-windmill-dyn-select-code\0": "x"}); + for (draft, expected) in [ + ( + composition_flow_at("u/operator/f1", "u/operator/some_script"), + 200, + ), + (inline_code_flow("u/operator/f1"), 403), + (with_dyn_code.clone(), 403), + (nul_value, 403), + (nul_dyn, 403), + ] { + let resp = c + .post(format!("{api}/drafts/update/flow/u/operator/f1")) + .json(&json!({ "value": draft, "force": true })) + .send() + .await?; + let status = resp.status(); + assert_eq!( + status, + expected, + "flow draft {draft}: {}", + resp.text().await? + ); + } + + // Same for the preview path, which runs a request-supplied flow value rather than a stored one. + let resp = c + .post(format!("{api}/jobs/run/preview_flow")) + .json(&json!({"value": inline_code_flow("u/operator/f2")["value"], "args": {}})) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not preview a flow carrying inline code" + ); + + // Authoring code directly stays shut with the right granted. + let resp = c + .post(format!("{api}/scripts/create")) + .json(&json!({ + "path": "u/operator/s1", + "summary": "", + "description": "", + "content": "export async function main() { return 1 }", + "language": "bun", + "is_template": false + })) + .send() + .await?; + assert!( + !resp.status().is_success(), + "a builder must not create a script" + ); + + // `permissions_test` gives the operator fixture no rights on `u/alice/**`. + add_script(&db, 4243, "u/alice/private", "alice").await?; + let resp = c + .post(format!("{api}/flows/create")) + .json(&composition_flow_at("u/operator/f4", "u/alice/private")) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not compose a runnable it cannot read" + ); + + add_script(&db, 4242, "u/operator/pinned", "operator").await?; + let pinned = |hash: &str| { + json!({ + "path": "u/operator/f3", "summary": "", "description": "", "schema": {}, + "value": {"modules": [{ + "id": "a", + "value": { + "type": "script", "path": "u/operator/pinned", "hash": hash, + "input_transforms": {} + } + }]} + }) + }; + let resp = c + .post(format!("{api}/flows/create")) + .json(&pinned("0000000000000000")) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not pin a hash that is not a version of the step's path" + ); + let resp = c + .post(format!("{api}/flows/create")) + .json(&pinned("0000000000001092")) + .send() + .await?; + assert!( + resp.status().is_success(), + "a builder must be able to pin the real version of a readable script: {}", + resp.text().await? + ); + + Ok(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 77c8e1701f..8c891bf0cf 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3471,7 +3471,6 @@ fn apply_pg_tls_env( Ok(None) } - #[cfg(test)] mod pg_tls_env_tests { use super::apply_pg_tls_env; @@ -4242,7 +4241,9 @@ async fn edit_datatable_config( // the other managed cluster, one whose role ids name nothing in that cluster's catalog. // Refuse it instead: turning roles off first is one step, and it keeps discarding an access // decision something somebody chose rather than a side effect of moving a database. - let old_kind = old.and_then(|old| old.database.as_ref()).map(|d| d.resource_type); + let old_kind = old + .and_then(|old| old.database.as_ref()) + .map(|d| d.resource_type); if dt.permissions.is_some() && dt .database @@ -10871,8 +10872,12 @@ async fn invite_user( nu.email = nu.email.to_lowercase(); #[cfg(feature = "enterprise")] - if let Some(msg) = - windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await? + if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user( + &db, + &nu.email, + windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.operator).await?, + ) + .await? { return Err(Error::BadRequest(msg)); } @@ -11025,8 +11030,12 @@ async fn add_user( }; #[cfg(feature = "enterprise")] - if let Some(msg) = - windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await? + if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user( + &db, + &nu.email, + windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.operator).await?, + ) + .await? { return Err(Error::BadRequest(msg)); } @@ -11589,10 +11598,15 @@ struct ChangeOperatorSettings { folders: bool, #[serde(default)] workers: bool, - /// Writes operators may perform unless withdrawn, so `None` (key absent) must mean "leave as - /// stored" rather than a value: the row is merged, not overwritten, and this endpoint takes - /// whole-object payloads from git-sync files that predate the key. Defaulting either way here - /// would make an older file silently withdraw or restore the right on every pull. + /// Write rights, so `None` (key absent) must mean "leave as stored" rather than a value: the + /// row is merged, not overwritten, and this endpoint takes whole-object payloads from git-sync + /// files that predate the key. Defaulting either way here would make an older file silently + /// withdraw or grant the right on every push. + /// + /// `builder_flows` lets every operator of this workspace compose flows out of already-deployed + /// runnables, and makes each of them consume a full author seat instead of half of one. + #[serde(default, skip_serializing_if = "Option::is_none")] + builder_flows: Option, #[serde(default, skip_serializing_if = "Option::is_none")] manage_schedules: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -11607,6 +11621,17 @@ async fn update_operator_settings( ) -> Result { require_admin(authed.is_admin, &authed.username)?; + // Every operator of the workspace turns into a full seat, which an offline license may not + // cover. It is a no-op delta when the right is already on. + #[cfg(feature = "enterprise")] + if settings.builder_flows == Some(true) { + if let Some(msg) = + windmill_common::ee_oss::check_seat_cap_for_operator_builder(&db, &w_id).await? + { + return Err(Error::BadRequest(msg)); + } + } + let mut tx = db.begin().await?; let settings_json = serde_json::json!(settings); diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index f3c4c24232..3cdd07b280 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -37549,6 +37549,9 @@ components: workers: type: boolean description: Whether operators can view workers page + builder_flows: + type: boolean + description: Whether operators can compose flows out of existing runnables (consumes a full seat). Omitting the field leaves the stored value unchanged. manage_schedules: type: boolean description: Whether operators can create, edit and delete schedules. Granted unless withdrawn; omitting the field leaves the stored value unchanged. diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 8bcbb56384..7b2268e38f 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -14,13 +14,16 @@ use axum::{ Json, Router, }; use serde::{Deserialize, Serialize}; +use windmill_api_flows::flows::validate_operator_flow; use windmill_common::{ db::UserDB, error::{Error, Result}, + flows::FlowValue, user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX}, users::resolve_username_to_email, utils::{check_proper_path, strip_json_nul}, variables::{build_crypt, encrypt}, + workspaces::operator_can_build_flows, }; pub fn workspaced_service() -> Router { @@ -101,10 +104,10 @@ async fn list_drafts( Path(w_id): Path, Query(query): Query, ) -> Result>> { - // Operators have no drafts of their own (they can't write any, see - // `require_can_write_path`), so this list is always empty for them. They - // can still READ some collaborators' drafts via `/drafts/get`. - if authed.is_operator { + // Without builder rights an operator has no drafts of their own (they can't write any, see + // `require_can_write_path`), so this list is always empty for them. They can still READ some + // collaborators' drafts via `/drafts/get`. + if authed.is_operator && !operator_can_build_flows(&db, &w_id).await? { return Ok(Json(vec![])); } let all_users = query.all_users.unwrap_or(false); @@ -154,10 +157,13 @@ async fn list_drafts( { Ok(()) => true, // A stored draft can sit at an unwritable path — unauthorized, - // or malformed (`BadRequest`; the `draft` table has no path - // constraint). Either way it's not writable, and one bad row - // must not 400 the whole listing. - Err(Error::NotAuthorized(_)) | Err(Error::BadRequest(_)) => false, + // refused to an operator (`PermissionDenied`), or malformed + // (`BadRequest`; the `draft` table has no path constraint). + // Either way it's not writable, and one bad row must not fail + // the whole listing. + Err(Error::NotAuthorized(_)) + | Err(Error::PermissionDenied(_)) + | Err(Error::BadRequest(_)) => false, Err(e) => return Err(e), }; out.push(row); @@ -464,6 +470,32 @@ async fn update_draft( } } + if authed.is_operator && kind == UserDraftItemKind::Flow { + if let Some(value) = &req.value { + #[derive(Deserialize)] + struct FlowDraft { + #[serde(default)] + value: FlowValue, + schema: Option>, + tag: Option, + } + // The text stored below, NULs stripped: a stripped NUL can rename a key into one + // this check reads. + let draft: FlowDraft = serde_json::from_str(&strip_json_nul(value.0.get())) + .map_err(|e| Error::BadRequest(format!("Invalid flow draft: {e}")))?; + validate_operator_flow( + &draft.value, + &draft.tag, + draft.schema.as_deref().map(|s| s.get()), + &authed, + &db, + &user_db, + &w_id, + ) + .await?; + } + } + let applied = if let Some(value) = &req.value { // Secret variable values must never sit in `draft.value` in plaintext // (see `encrypt_secret_variable_value`). @@ -1111,13 +1143,14 @@ fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> { } /// Resolves to `Ok(())` if `authed` may SAVE a draft at `path`. Operators are -/// rejected outright. Two layers: +/// rejected, except for flow drafts in a workspace that granted them builder +/// rights. Two layers: /// 1. Claim-based namespace rules (admin, own `u/`, member `g/`, writable /// `f/`) — mirror what RLS reads from the same JWT claims, and are the /// ENTIRE check for draft-only paths (no deployed row for RLS to use). /// 2. An RLS write-probe on the deployed row (`SELECT ... FOR UPDATE`) for /// what the path can't answer, above all item-level extra_perms grants. -async fn require_can_write_path( +pub(crate) async fn require_can_write_path( authed: &ApiAuthed, db: &DB, user_db: &UserDB, @@ -1128,14 +1161,19 @@ async fn require_can_write_path( if authed.is_admin { return Ok(()); } - // Operators are read-only and never WRITE drafts. Read access is - // deliberately asymmetric: `require_can_read_path` has no operator block, - // so an operator can still READ a draft they can read via `/drafts/get`, - // mirroring their read access to deployed content. Intended. + // Operators are read-only and never WRITE drafts, except a flow draft where the workspace + // granted the builder right: the kind has to be checked, or the right would open drafts of + // kinds it says nothing about. Read access is deliberately asymmetric: + // `require_can_read_path` has no operator block, so an operator can still READ a draft they + // can read via `/drafts/get`, mirroring their read access to deployed content. Intended. if authed.is_operator { - return Err(Error::NotAuthorized( - "operators cannot save drafts".to_string(), - )); + let granted = + matches!(kind, UserDraftItemKind::Flow) && operator_can_build_flows(db, w_id).await?; + if !granted { + return Err(Error::PermissionDenied( + "operators cannot save drafts".to_string(), + )); + } } // Cheap claim-based namespace checks first: they evaluate the same JWT // claims RLS reads, so the outcome matches the policies while sparing the diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 8b63289175..5115148f83 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -26,6 +26,7 @@ use std::time::Instant; use tokio::io::AsyncReadExt; use tower::ServiceBuilder; use url::Url; +use windmill_api_flows::flows::validate_operator_composed_flow; use windmill_common::assets::AssetUsageAccessType; use windmill_common::auth::TOKEN_PREFIX_LEN; #[cfg(feature = "run_inline")] @@ -53,7 +54,9 @@ use windmill_common::worker::{Connection, CLOUD_HOSTED, WINDMILL_DIR}; use windmill_common::workspace_dependencies::{ RawWorkspaceDependencies, MIN_VERSION_WORKSPACE_DEPENDENCIES, }; -use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}; +use windmill_common::workspaces::{ + check_operator_can_build_flows, check_user_against_rule, ProtectionRuleKind, RuleCheckResult, +}; use windmill_common::DYNAMIC_INPUT_CACHE; #[cfg(all(feature = "enterprise", feature = "instance_smtp"))] use windmill_common::{email_oss::send_email_html, server::load_smtp_config}; @@ -8600,7 +8603,9 @@ fn operator_preview_refusal(job_id: Option) -> error::Error { } else { "Operators cannot run preview jobs for security reasons" }; - error::Error::NotAuthorized(reason.to_string()) + // 403, not 401: the frontend reads a 401 as a dead session and logs the user out, and the + // flow editor builders open reaches this route. + error::Error::PermissionDenied(reason.to_string()) } async fn run_preview_script( @@ -9282,14 +9287,32 @@ pub struct RunFlowDependenciesResponse { async fn push_flow_dependencies_job( authed: &ApiAuthed, db: &DB, + user_db: &UserDB, w_id: &str, req: RunFlowDependenciesRequest, ) -> error::Result { check_scopes(authed, || format!("jobs:run"))?; + check_operator_can_build_flows(db, w_id, authed.is_operator, "run dependencies jobs").await?; + // The dependency job locks whatever inline code this request carries, on a worker. A + // composition-only flow has none, so validating here costs a builder nothing and keeps the + // lock step from becoming the way to run code the write path refuses. if authed.is_operator { - return Err(error::Error::NotAuthorized( - "Operators cannot run dependencies jobs for security reasons".to_string(), - )); + validate_operator_composed_flow(&req.flow_value, &None, authed, db, user_db, w_id).await?; + // The job rewrites bookkeeping stored under `req.path` (dependency map, asset usages, + // lock error) even with `skip_flow_update`, so a builder must be able to write there. + crate::drafts::require_can_write_path( + authed, + db, + user_db, + w_id, + windmill_common::user_drafts::UserDraftItemKind::Flow, + &req.path, + ) + .await + .map_err(|e| match e { + error::Error::NotAuthorized(msg) => error::Error::PermissionDenied(msg), + e => e, + })?; } if req.raw_deps.is_some() { @@ -9355,20 +9378,22 @@ async fn push_flow_dependencies_job( async fn run_flow_dependencies_job( authed: ApiAuthed, Extension(db): Extension, + Extension(user_db): Extension, Path(w_id): Path, Json(req): Json, ) -> error::Result { - let uuid = push_flow_dependencies_job(&authed, &db, &w_id, req).await?; + let uuid = push_flow_dependencies_job(&authed, &db, &user_db, &w_id, req).await?; run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await } async fn run_flow_dependencies_job_async( authed: ApiAuthed, Extension(db): Extension, + Extension(user_db): Extension, Path(w_id): Path, Json(req): Json, ) -> error::Result<(StatusCode, String)> { - let uuid = push_flow_dependencies_job(&authed, &db, &w_id, req).await?; + let uuid = push_flow_dependencies_job(&authed, &db, &user_db, &w_id, req).await?; Ok((StatusCode::CREATED, uuid.to_string())) } @@ -9669,15 +9694,24 @@ async fn run_preview_flow_job( Query(run_query): Query, Json(raw_flow): Json, ) -> error::Result<(StatusCode, String)> { - if authed.is_operator { - return Err(error::Error::NotAuthorized( - "Operators cannot run preview jobs for security reasons".to_string(), - )); - } + check_operator_can_build_flows(&db, &w_id, authed.is_operator, "run preview jobs").await?; // Flow preview runs an arbitrary, request-supplied flow definition; require the broad // jobs:run scope so a narrowly-scoped token cannot escape its scope. See run_preview_script. check_scopes(&authed, || format!("jobs:run"))?; require_path_read_access_for_preview(&authed, &raw_flow.path)?; + // A builder must be able to test what it composes, but the submitted value is not the stored + // one: without this the preview is a way to run inline code the write path refuses. + if authed.is_operator { + validate_operator_composed_flow( + &raw_flow.value, + &raw_flow.tag, + &authed, + &db, + &user_db, + &w_id, + ) + .await?; + } // Restarting copies the source runs' step results into the new run, and the queue resolves // them with the service pool, so every run the request names must be readable as the caller. let mut level = raw_flow.restarted_from.as_ref(); @@ -9811,9 +9845,7 @@ async fn run_dynamic_select( DynamicSelectRunnableRef::Inline { .. } ) && authed.is_operator { - return Err(error::Error::NotAuthorized( - "Operators cannot run preview jobs for security reasons".to_string(), - )); + return Err(operator_preview_refusal(None)); } if !is_valid_entrypoint_name(&request.entrypoint_function) { diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index 43ef23aff2..2cee0efe33 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -34,6 +34,7 @@ use std::collections::HashMap; use windmill_common::{ db::UserDB, error::{Error, JsonResult}, + workspaces::operator_can_build_flows, }; use windmill_types::scripts::ScriptHash; use windmill_types::user_drafts::DraftUserRef; @@ -397,7 +398,7 @@ fn draft_branch_sql(kind: &str) -> String { async fn list_runnables( authed: ApiAuthed, Extension(user_db): Extension, - Extension(_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(q): Query, ) -> JsonResult { @@ -571,9 +572,9 @@ async fn list_runnables( // Draft-only rows are the caller's own work in progress: never archived, so they // have no place in the archived view, and carrying no labels of their own they are // out of scope of a label filter (as in the per-kind endpoints). Operators don't - // see other people's drafts and have none of their own to see. + // see other people's drafts and have none of their own, except a builder's flows. let include_drafts = q.include_draft_only.unwrap_or(false) - && !authed.is_operator + && (!authed.is_operator || operator_can_build_flows(&db, &w_id).await?) && !show_archived && q.label.as_ref().filter(|s| !s.is_empty()).is_none(); let draft_extras_for = |kind: &str| -> Vec { @@ -659,7 +660,7 @@ async fn list_runnables( keyset: Option<&str>, limit: Option| -> Option { - if !include_drafts || !kinds.contains(&kind) { + if !include_drafts || !kinds.contains(&kind) || (authed.is_operator && kind != "flow") { return None; } // `fav` is ignored: with no favorite join there is nothing to filter on, and the @@ -1004,9 +1005,17 @@ async fn add_draft_counts( q: &CountRunnablesQuery, counts: &mut HashMap, ) -> Result<(), Error> { - if !q.include_draft_only.unwrap_or(false) || authed.is_operator { + if !q.include_draft_only.unwrap_or(false) { return Ok(()); } + // An operator has no drafts of their own, except a builder's flows. + let kinds: &[&str] = if !authed.is_operator { + kinds + } else if kinds.contains(&"flow") && operator_can_build_flows(db, w_id).await? { + &["flow"] + } else { + return Ok(()); + }; // $1 = workspace, $2 = the caller's email. let mut binds: Vec = vec![]; let branches: Vec = kinds diff --git a/backend/windmill-common/src/ee_oss.rs b/backend/windmill-common/src/ee_oss.rs index 2510df0960..82f99c54fb 100644 --- a/backend/windmill-common/src/ee_oss.rs +++ b/backend/windmill-common/src/ee_oss.rs @@ -71,6 +71,14 @@ pub async fn check_seat_cap_for_reactivation( Ok(None) } +#[cfg(all(feature = "enterprise", not(feature = "private")))] +pub async fn check_seat_cap_for_operator_builder( + _db: &DB, + _w_id: &str, +) -> anyhow::Result> { + Ok(None) +} + #[cfg(all(feature = "enterprise", not(feature = "private")))] pub async fn compute_instance_hash(_db: &DB) -> anyhow::Result> { // Implementation is not open source diff --git a/backend/windmill-common/src/flows.rs b/backend/windmill-common/src/flows.rs index 11ffd5da76..b392dda89a 100644 --- a/backend/windmill-common/src/flows.rs +++ b/backend/windmill-common/src/flows.rs @@ -17,6 +17,7 @@ use crate::{ cache::{self, FlowExtras}, db::DB, error::{to_anyhow, Error}, + scripts::ScriptHash, utils::{http_get_from_hub, StripPath}, worker::{to_raw_value, Connection}, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION, @@ -246,6 +247,156 @@ pub async fn resolve_modules( Ok(()) } +/// Checks a flow value contains nothing but composition of runnables that already exist, which is +/// all an operator with builder rights may author. Walks the modules, the preprocessor and failure +/// modules, every branch, and the `tools` of an AI agent step. +/// +/// Returns what the caller still has to authorize against its own permissions, which this +/// value-only walk cannot: every runnable the steps reference, the worker tags they pin, and the +/// `(path, hash)` pairs of version-pinned steps. See [`ComposedFlowRefs`] for why each one is not +/// already settled by the walk. +pub fn check_flow_is_composition_only(value: &FlowValue) -> Result { + let mut refs = ComposedFlowRefs::default(); + for module in value + .modules + .iter() + .chain(value.preprocessor_module.as_deref()) + .chain(value.failure_module.as_deref()) + { + check_module_is_composition_only(module, &mut refs)?; + } + Ok(refs) +} + +/// What [`check_flow_is_composition_only`] collects for the caller to authorize. +#[derive(Default)] +pub struct ComposedFlowRefs { + pub tags: Vec, + /// Every workspace runnable a step references, as `(is_flow, path)`. + pub runnables: Vec<(bool, String)>, + /// Version-pinned script steps, as `(path, hash)`. + pub pinned_scripts: Vec<(String, ScriptHash)>, +} + +fn check_module_is_composition_only( + module: &FlowModule, + refs: &mut ComposedFlowRefs, +) -> Result<(), Error> { + let value = module + .get_value() + .map_err(|e| Error::BadRequest(format!("Step {} could not be read: {e}", module.id)))?; + check_module_value_is_composition_only(&value, &module.id, refs) +} + +fn check_module_value_is_composition_only( + value: &FlowModuleValue, + id: &str, + refs: &mut ComposedFlowRefs, +) -> Result<(), Error> { + let refuse = |what: &str| Err(refused(id, what)); + // A node id points at code in a `flow_node` row, which only the dependency job produces by + // hoisting a step's code, so an authored value carrying one names another flow's code and slips + // past this walk. Editor payloads come from the un-hoisted `flow_version.value`, so refusing + // them costs nothing legitimate. + let refuse_node = |node: &Option| match node { + Some(_) => refuse("references code stored outside the flow"), + None => Ok(()), + }; + let mut push_tag = |tag: &Option| { + if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) { + refs.tags.push(tag.to_string()); + } + }; + + match value { + FlowModuleValue::RawScript { .. } => return refuse("has inline code"), + FlowModuleValue::FlowScript { .. } => { + return refuse("references code stored outside the flow") + } + FlowModuleValue::Identity => {} + FlowModuleValue::Script { path, hash, tag_override, .. } => { + check_composable_path(path, id)?; + push_tag(tag_override); + refs.runnables.push((false, path.clone())); + if let Some(hash) = hash { + refs.pinned_scripts.push((path.clone(), *hash)); + } + } + FlowModuleValue::Flow { path, .. } => { + check_composable_path(path, id)?; + refs.runnables.push((true, path.clone())); + } + FlowModuleValue::ForloopFlow { modules, modules_node, .. } + | FlowModuleValue::WhileloopFlow { modules, modules_node, .. } => { + refuse_node(modules_node)?; + for module in modules { + check_module_is_composition_only(module, refs)?; + } + } + FlowModuleValue::BranchOne { branches, default, default_node } => { + refuse_node(default_node)?; + for module in default { + check_module_is_composition_only(module, refs)?; + } + check_branches_are_composition_only(branches, id, refs)?; + } + FlowModuleValue::BranchAll { branches, .. } => { + check_branches_are_composition_only(branches, id, refs)? + } + FlowModuleValue::AIAgent { tools, tag, agent, .. } => { + // A linked agent resolves its tools from an `ai_agent` resource at run time, and + // operators may write resources, so those tools are outside this check: the list can + // be swapped for a raw script after the flow is deployed. + if agent.is_some() { + return refuse("links an AI agent resource, whose tools live outside the flow"); + } + push_tag(tag); + for tool in tools { + if let ToolValue::FlowModule(value) = &tool.value { + check_module_value_is_composition_only(value, &tool.id, refs)?; + } + } + } + } + Ok(()) +} + +fn check_branches_are_composition_only( + branches: &[Branch], + id: &str, + refs: &mut ComposedFlowRefs, +) -> Result<(), Error> { + for branch in branches { + if branch.modules_node.is_some() { + return Err(refused( + id, + "has a branch that references code stored outside the flow", + )); + } + for module in &branch.modules { + check_module_is_composition_only(module, refs)?; + } + } + Ok(()) +} + +fn refused(id: &str, what: &str) -> Error { + Error::PermissionDenied(format!( + "Step {id} {what}, so only a developer can edit this flow. Builders can compose scripts \ + and flows that are already deployed." + )) +} + +fn check_composable_path(path: &str, id: &str) -> Result<(), Error> { + if path.starts_with("hub/") { + return Err(Error::PermissionDenied(format!( + "Step {id}: hub runnables are not available to operators with builder rights. Deploy \ + it to the workspace first." + ))); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -281,4 +432,93 @@ mod tests { let err = extract_hub_flow_id_from_path("hub/flows/0").unwrap_err(); assert!(matches!(err, Error::BadRequest(_))); } + + fn flow(value: serde_json::Value) -> FlowValue { + serde_json::from_value(value).unwrap() + } + + #[test] + fn composition_check_accepts_a_composed_flow_and_collects_its_tags() { + let refs = check_flow_is_composition_only(&flow(serde_json::json!({"modules": [{ + "id": "a", + "value": {"type": "forloopflow", "iterator": {"type": "static", "value": []}, + "parallel": false, "modules": [ + {"id": "b", "value": {"type": "script", "path": "f/x/s", "tag_override": "gpu"}}, + {"id": "c", "value": {"type": "flow", "path": "f/x/f"}}, + {"id": "d", "value": {"type": "aiagent", "input_transforms": {}, "tag": "ai", + "tools": [{"id": "t", "value": {"tool_type": "flowmodule", + "type": "script", "path": "f/x/tool"}}]}} + ]} + }]}))) + .unwrap(); + assert_eq!(refs.tags, vec!["gpu".to_string(), "ai".to_string()]); + } + + /// The walk covers `modules`, so a node reference is a way past it: it names code hoisted + /// into a `flow_node` row, possibly another flow's. + #[test] + fn composition_check_rejects_node_references() { + for value in [ + serde_json::json!({"modules": [{"id": "a", "value": {"type": "forloopflow", + "iterator": {"type": "static", "value": []}, "parallel": false, + "modules": [], "modules_node": 7}}]}), + serde_json::json!({"modules": [{"id": "a", "value": {"type": "branchone", + "branches": [], "default": [], "default_node": 7}}]}), + serde_json::json!({"modules": [{"id": "a", "value": {"type": "branchall", + "branches": [{"expr": "true", "modules": [], "modules_node": 7}]}}]}), + serde_json::json!({"modules": [{"id": "a", "value": {"type": "flowscript", + "id": 7, "language": "bun"}}]}), + ] { + assert!(check_flow_is_composition_only(&flow(value)).is_err()); + } + } + + #[test] + fn composition_check_rejects_code_reachable_through_an_ai_agent() { + for value in [ + serde_json::json!({"modules": [{"id": "a", "value": {"type": "aiagent", + "input_transforms": {}, "tools": [{"id": "t", "value": {"tool_type": "flowmodule", + "type": "rawscript", "content": "x", "language": "bun"}}]}}]}), + serde_json::json!({"modules": [{"id": "a", "value": {"type": "aiagent", + "input_transforms": {}, "tools": [], "agent": "$res:f/x/agent"}}]}), + ] { + assert!(check_flow_is_composition_only(&flow(value)).is_err()); + } + } + + #[test] + fn composition_check_rejects_code_in_every_module_slot() { + let inline = serde_json::json!({"type": "rawscript", "content": "x", "language": "bun"}); + for value in [ + serde_json::json!({"modules": [{"id": "a", "value": inline}]}), + serde_json::json!({"modules": [], "failure_module": {"id": "f", "value": inline}}), + serde_json::json!({"modules": [], "preprocessor_module": {"id": "p", "value": inline}}), + ] { + assert!(check_flow_is_composition_only(&flow(value)).is_err()); + } + } + + /// A step carrying a `hash` dispatches on that hash alone: the caller must verify the pair + /// exists and is readable, so the walk has to surface it rather than pass it through. + #[test] + fn composition_check_reports_version_pinned_steps() { + let refs = check_flow_is_composition_only(&flow(serde_json::json!({"modules": [ + {"id": "a", "value": {"type": "script", "path": "f/x/s", "hash": "000000000000007b"}}, + {"id": "b", "value": {"type": "script", "path": "f/x/t"}} + ]}))) + .unwrap(); + assert_eq!( + refs.pinned_scripts, + vec![("f/x/s".to_string(), ScriptHash(123))] + ); + } + + #[test] + fn composition_check_rejects_hub_runnables() { + for kind in ["script", "flow"] { + let value = serde_json::json!({"modules": [{"id": "a", + "value": {"type": kind, "path": "hub/1234/thing"}}]}); + assert!(check_flow_is_composition_only(&flow(value)).is_err()); + } + } } diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 448e13af85..4751cb57be 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1044,7 +1044,8 @@ pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgre } /// Billable members of `w_id` and the seats they cost, as `ceil(developers + operators/2)`. Service -/// accounts cannot log in and do not take a seat; a disabled member is not billed either. +/// accounts cannot log in and do not take a seat; a disabled member is not billed either. In a +/// workspace that granted operators builder rights, every operator counts as a developer. /// /// The workspace is invoiced by a job outside this codebase that counts the same rows with its own /// SQL. The two must be changed together: this rule disagreeing with that one is what bills a @@ -1063,10 +1064,15 @@ pub async fn billable_seats(db: &crate::DB, w_id: &str) -> Result .fetch_one(db) .await .map_err(|e| Error::internal_err(format!("counting billable seats of {w_id}: {e:#}")))?; + let (developers, operators) = if operator_can_build_flows(db, w_id).await? { + (row.developers + row.operators, 0) + } else { + (row.developers, row.operators) + }; Ok(BillableSeats { - developers: row.developers, - operators: row.operators, - seats: ((row.developers as f64) + 0.5 * (row.operators as f64)).ceil() as i64, + developers, + operators, + seats: ((developers as f64) + 0.5 * (operators as f64)).ceil() as i64, }) } @@ -1189,7 +1195,17 @@ pub fn invalidate_protection_rules_cache(workspace_id: &str) { // Operator rights cache lazy_static::lazy_static! { - static ref OPERATOR_RIGHTS_CACHE: Cache = Cache::new(1000); + static ref OPERATOR_RIGHTS_CACHE: Cache = Cache::new(1000); +} + +/// Every operator right of a workspace, read and cached together because they share one +/// `operator_settings` column and one invalidation. The two groups have opposite polarity: +/// `builder_flows` is granted on request and costs a seat, the `manage` rights are held by +/// default and cost nothing. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub struct OperatorRights { + pub builder_flows: bool, + pub manage: OperatorManageRights, } /// Writes an operator may perform unless the workspace withdraws them. Unlike the visibility @@ -1256,7 +1272,7 @@ impl OperatorManageRights { /// /// Call it before opening an RLS transaction: it takes a connection from the root pool, and a /// second pooled connection held alongside a transaction self-deadlocks on a one-connection pool. -async fn operator_manage_rights(db: &DB, workspace_id: &str) -> Result { +async fn operator_rights(db: &DB, workspace_id: &str) -> Result { let now = chrono::Utc::now().timestamp(); if let Some((rights, expiry)) = OPERATOR_RIGHTS_CACHE.get(workspace_id) { @@ -1265,10 +1281,12 @@ async fn operator_manage_rights(db: &DB, workspace_id: &str) -> Result>'manage_schedules')::boolean, true) AS \"schedules!\", + "SELECT COALESCE((operator_settings->>'builder_flows')::boolean, false) AS \"flows!\", + COALESCE((operator_settings->>'manage_schedules')::boolean, true) AS \"schedules!\", COALESCE((operator_settings->>'manage_triggers')::boolean, true) AS \"triggers!\" FROM workspace_settings WHERE workspace_id = $1", workspace_id @@ -1282,7 +1300,10 @@ async fn operator_manage_rights(db: &DB, workspace_id: &str) -> Result Result Result { + Ok(operator_rights(db, workspace_id).await?.builder_flows) +} + +/// Gate for a write only a workspace that granted builder rights lets operators perform. `action` +/// completes "Operators cannot {action} for security reasons". +pub async fn check_operator_can_build_flows( + db: &DB, + workspace_id: &str, + is_operator: bool, + action: &str, +) -> Result<()> { + if is_operator && !operator_can_build_flows(db, workspace_id).await? { + return Err(Error::PermissionDenied(format!( + "Operators cannot {action} for security reasons" + ))); + } + Ok(()) +} + +/// Whether a membership consumes an operator (half) seat rather than an author seat. A builder +/// right makes an operator an author of deployable artifacts, so it weighs a full seat. +pub async fn consumes_operator_seat( + db: &DB, + workspace_id: &str, + is_operator: bool, +) -> Result { + Ok(is_operator && !operator_can_build_flows(db, workspace_id).await?) +} + /// Invalidate the operator rights cache for a workspace pub fn invalidate_operator_rights_cache(workspace_id: &str) { OPERATOR_RIGHTS_CACHE.remove(workspace_id); @@ -1306,7 +1359,7 @@ pub async fn check_operator_can_manage( is_operator: bool, kind: ManageKind, ) -> Result<()> { - if is_operator && !operator_manage_rights(db, workspace_id).await?.has(kind) { + if is_operator && !operator_rights(db, workspace_id).await?.manage.has(kind) { // 403, not 401: the caller is authenticated and simply lacks the right. The frontend reads // an uncaught 401 as a dead session and logs the user out, so `NotAuthorized` here would // eject an operator from the app instead of telling them why. diff --git a/backend/windmill-common/tests/billing_workspace.rs b/backend/windmill-common/tests/billing_workspace.rs index 3025061249..04455318a6 100644 --- a/backend/windmill-common/tests/billing_workspace.rs +++ b/backend/windmill-common/tests/billing_workspace.rs @@ -118,6 +118,21 @@ async fn paid_seats_and_fork_count(db: Pool) { (2, 2, 3) ); + sqlx::query( + "INSERT INTO workspace_settings (workspace_id, operator_settings) + VALUES ('seat-root', '{\"builder_flows\": true}')", + ) + .execute(&db) + .await + .unwrap(); + windmill_common::workspaces::invalidate_operator_rights_cache("seat-root"); + let breakdown = billable_seats(&db, "seat-root").await.unwrap(); + assert_eq!( + (breakdown.developers, breakdown.operators, breakdown.seats), + (4, 0, 4), + "builder rights bill every operator as a developer" + ); + insert_ws(&db, "seat-fork1", Some("seat-root"), false).await; insert_ws(&db, "seat-fork2", Some("seat-root"), false).await; // A deleted fork itself is not counted... diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index f065877244..57e89f5ded 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -413,7 +413,7 @@ export async function pushWorkspaceSettings( const localOperatorSettings = localSettings.operator_settings && { ...localSettings.operator_settings, }; - for (const key of ["manage_schedules", "manage_triggers"] as const) { + for (const key of ["builder_flows", "manage_schedules", "manage_triggers"] as const) { const remote = settings.operator_settings?.[key]; if (localOperatorSettings && localOperatorSettings[key] === undefined && remote !== undefined) { localOperatorSettings[key] = remote; diff --git a/docs/operator-builder-rights.md b/docs/operator-builder-rights.md new file mode 100644 index 0000000000..4db2a69511 --- /dev/null +++ b/docs/operator-builder-rights.md @@ -0,0 +1,76 @@ +# Operator builder rights + +`operator_settings.builder_flows` lets every operator of a workspace compose flows out of runnables +that already exist. It does not make them authors: the boundary the operator role draws is +**authoring code and running arbitrary code**, and this does not move it. + +It is a write right, unlike the visibility flags beside it, and unlike the withdrawable rights in +`docs/operator-write-rights.md` it is granted on request and costs a seat. Read it with +`windmill_common::workspaces::operator_can_build_flows` (60s cache, shared with the withdrawable +rights) and gate a write with `check_operator_can_build_flows`. + +## What the check has to cover + +`check_flow_is_composition_only` (`windmill-common/src/flows.rs`) walks a `FlowValue` and refuses +anything that carries code. Three of its rules exist because the obvious walk misses them: + +- **`FlowScript` and any populated `modules_node` / `default_node`.** These name code hoisted into + a `flow_node` row. Only the dependency job produces them, so an authored value carrying one + names code stored under some other flow. The walk covers `modules`, so a node reference is a way + past it. +- **An AI agent step's `tools`.** `ToolValue::FlowModule` wraps a whole `FlowModuleValue`, so a + tool can be a raw script. +- **An AI agent step's `agent` link.** A linked agent resolves its tools from an `ai_agent` + resource at run time, and operators may write resources, so the tool list is outside this check + and can be swapped for a raw script after the flow is approved. + +It also returns what a value-only walk cannot authorize, for the caller to check against its own +permissions: + +- **the worker tags the steps pin**, or a builder routes a job onto a privileged worker group; +- **every runnable a step references**. `script_to_payload` resolves a step's path with the root DB + handle (`db_authed = None`) and returns the referenced runnable's `on_behalf_of`, which + `worker_flow` then applies to the step job. So composing a path is enough to run it, and to run + it as whoever it runs as: `validate_operator_composed_flow` re-checks each path under the + caller's RLS. This is the general case; the one below is on top of it, not instead of it. +- **the `(path, hash)` of every version-pinned step**. A step carrying a `hash` is dispatched by + that hash alone, with the path beside it never consulted, so a readable path paired with another + script's hash still runs that other script. + +The value is not the only code a flow carries: the schema's `x-windmill-dyn-select-code` fills its +dynamic dropdowns, run as whoever loads the form, so `validate_operator_flow` refuses it like a +step's code. A builder therefore cannot save a developer's flow that has dropdowns; its editor +still previews them through the deployed flow rather than the inline route operators are refused. +Which developer flows a builder may edit at all is left to permissions: write access to the flow +or its folder. + +Call it on every write **and** every preview: `run_preview_flow_job` and +`push_flow_dependencies_job` both take a request-supplied flow value, so leaving either out makes +it the way to run what the write path refuses. A flow draft goes through `validate_operator_flow` +too: a developer who loads a builder's draft in the editor runs its code as themselves. + +## Billing + +An operator of a builder workspace consumes a full author seat: composing deployable artifacts +makes them an author, and there is no half-author. `consumes_operator_seat` is the seat-role +helper; the EE counting queries share `OPERATOR_SEAT_SQL` so the displayed, enforced and reported +numbers agree. The one exception is `get_user_usage` in `stats_ee.rs`: it is a compile-checked +`query!`, which cannot interpolate the constant, so it spells the predicate out. Change both +together. + +On cloud, `billable_seats` applies the same rule; see its doc for the out-of-repo invoice it must +match. + +Granting the right runs `check_seat_cap_for_operator_builder`, which prices the change by counting +seats twice rather than by counting the workspace's operators: an operator who already authors +elsewhere must not be charged again, so re-saving settings that already have the right on is a +zero delta and never blocks. + +## Accepted risks + +- All-or-nothing per workspace: there is no per-user builder role. +- `operator_settings` is git-synced, so a pull can flip every operator's class in a workspace and + the billed seat count with it. +- A builder writes JavaScript expressions: step inputs, branch predicates, `stop_after_if` and + `suspend`. They run in QuickJS with no filesystem or network access, and forbidding them would + leave nothing to compose with. diff --git a/docs/operator-write-rights.md b/docs/operator-write-rights.md index fb9d4051bb..2c18ecd290 100644 --- a/docs/operator-write-rights.md +++ b/docs/operator-write-rights.md @@ -79,7 +79,7 @@ authorizing writes on every other replica until its own entry expires. These name capabilities operators already hold, so absence has to mean "never configured", not a value. That is easy to get wrong in two places, and the obvious implementation gets both wrong: -- The read coalesces to **true** (`operator_manage_rights`), including for a workspace with no +- The read coalesces to **true** (`operator_rights`), including for a workspace with no `workspace_settings` row, which is what `OperatorManageRights::default` is for. Coalescing to false instead revokes the right on upgrade for every workspace that ever saved operator settings, since those rows carry explicit keys and none of them is this one. diff --git a/frontend/src/lib/components/EditableSchemaForm.svelte b/frontend/src/lib/components/EditableSchemaForm.svelte index 79173424d9..11429cb6fe 100644 --- a/frontend/src/lib/components/EditableSchemaForm.svelte +++ b/frontend/src/lib/components/EditableSchemaForm.svelte @@ -19,6 +19,7 @@ import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import Label from './Label.svelte' import { sendUserToast } from '$lib/toast' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import Toggle from './Toggle.svelte' import { DynamicInput, @@ -35,9 +36,14 @@ import Section from '$lib/components/Section.svelte' import Editor from './Editor.svelte' import AddPropertyV2 from './schema/AddPropertyV2.svelte' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const operatorBuilderFlows = useOperatorBuilderFlows() // export let openEditTab: () => void = () => {} const dispatch = createEventDispatcher() @@ -88,6 +94,8 @@ schemaFormClassName?: string onChange?: (args: Record) => void workspace?: string | undefined + /** The deployed flow an operator's dropdown previews read their options from. */ + deployedFlowPath?: string } let { @@ -127,7 +135,8 @@ extraTab, schemaFormClassName = undefined, onChange = undefined, - workspace = undefined + workspace = undefined, + deployedFlowPath = undefined }: Props = $props() let ws = $derived(workspace ?? $operatingWorkspace) @@ -469,11 +478,12 @@ order: e.detail } }} - helperScript={{ - source: 'inline', - code: dynCode!, - lang: dynLang! - }} + helperScript={DynamicInput.flowHelperScript( + dynCode, + dynLang, + deployedFlowPath, + operatingUser.current?.operator + )} prettifyHeader={isAppInput} disabled={!!previewSchema} {diff} @@ -486,7 +496,7 @@ {@render runButton?.()} - {#if dynamicFunctions.length > 0} + {#if dynamicFunctions.length > 0 && !$operatorBuilderFlows}
{/each} - {#if showDynOpt} + {#if showDynOpt && !$operatorBuilderFlows} {#each DYNAMIC_OPTIONS as x} {/each} diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 60577ef124..434ff4417f 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -106,12 +106,13 @@ import { UserDraft } from '$lib/userDraft.svelte' import { setOpenInSessionHandoff } from './sessions/openInSessionContext' import { getEditorStoragePath, setEditorStoragePath } from './editorStoragePathContext' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { useOperatorBuilderFlows, useTriggerLock } from '$lib/operatorWriteRights' import { useOperatingUser, useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const triggerLock = useTriggerLock() + const operatorBuilderFlows = useOperatorBuilderFlows() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() @@ -1519,7 +1520,7 @@ {#key renderCount} - {#if !actingUser?.operator} + {#if !actingUser?.operator || $operatorBuilderFlows} {#if $pathStore} {/if} @@ -1673,7 +1674,9 @@ {forceTestTab} {highlightArg} aiChatOpen={aiChatManager.open} - showFlowAiButton={!disableAi && customUi?.topBar?.aiBuilder != false} + showFlowAiButton={!disableAi && + customUi?.topBar?.aiBuilder != false && + !$operatorBuilderFlows} toggleAiChat={() => aiChatManager.toggleOpen()} {sessionOpen} onOpenPreview={flowPreviewButtons?.openPreview} @@ -1702,7 +1705,9 @@ {/if} {:else} - Flow Builder not available to operators +
+ Flow builder not available to operators +
{/if} {/key} diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index 5489e4abb5..151d35be77 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -28,7 +28,7 @@ RefreshCw, X } from 'lucide-svelte' - import { sendUserToast, type StateStore } from '$lib/utils' + import { DynamicInput, sendUserToast, type StateStore } from '$lib/utils' import { dfs } from './flows/dfs' import { sliceModules } from './flows/flowStateUtils.svelte' import InputSelectedBadge from './schema/InputSelectedBadge.svelte' @@ -44,9 +44,13 @@ import FlowRestartButton from './FlowRestartButton.svelte' import { useNestedRestartState } from './useNestedRestartState.svelte' import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() interface Props { previewMode: 'upTo' | 'whole' @@ -552,14 +556,14 @@ savedArgs = $state.snapshot(previewArgs.val) }} bind:isValid - helperScript={flowStore.val.schema?.['x-windmill-dyn-select-code'] && - flowStore.val.schema?.['x-windmill-dyn-select-lang'] - ? { - source: 'inline', - code: flowStore.val.schema['x-windmill-dyn-select-code'] as string, - lang: flowStore.val.schema['x-windmill-dyn-select-lang'] as ScriptLang - } - : undefined} + helperScript={DynamicInput.flowHelperScript( + flowStore.val.schema?.['x-windmill-dyn-select-code'] as string | undefined, + flowStore.val.schema?.['x-windmill-dyn-select-lang'] as + | ScriptLang + | undefined, + $initialPathStore, + operatingUser.current?.operator + )} /> {/key} diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index f9af803694..e000ee25bc 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -9,6 +9,7 @@ import type ShareModal from '$lib/components/ShareModal.svelte' import { FlowService, type Flow } from '$lib/gen' import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Badge from '../badge/Badge.svelte' @@ -42,6 +43,8 @@ import EditInForkButton from './EditInForkButton.svelte' import { isCloudHosted } from '$lib/cloud' + const operatorBuilderFlows = useOperatorBuilderFlows() + interface Props { flow: Flow & { draft_only?: boolean @@ -217,6 +220,7 @@ let { draft_only, path, archived } = flow let owner = isOwner(path, $userStore, $workspaceStore) const canEdit = flow.canWrite && showEditButton + const hideForOperator = $userStore?.operator && !$operatorBuilderFlows if (draft_only) { return [ ...selectMenuItems(rowSelection), @@ -251,7 +255,7 @@ // list endpoint only surfaces own/legacy draft-only rows), so // discarding it never requires write permission on the path. disabled: !showEditButton, - hide: $userStore?.operator + hide: hideForOperator } ] } @@ -267,7 +271,7 @@ icon: GitFork, href: `${base}/flows/add?template=${path}`, disabled: !showEditButton, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: editInForkLabel($workspaceStore, $userWorkspaces), @@ -293,7 +297,7 @@ moveDrawer.openDrawer(path, flow.summary, 'flow') }, disabled: !owner || archived || !canEdit, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: 'Copy path', @@ -321,7 +325,7 @@ action: () => { flowHistory?.open() }, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: 'Schedule', @@ -330,7 +334,7 @@ scheduleEditor?.openNew(true, path) }, disabled: archived, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: 'Permissions', @@ -338,7 +342,7 @@ action: () => { shareModal.openDrawer && shareModal.openDrawer(path, 'flow') }, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: archived ? 'Unarchive' : 'Archive', @@ -348,7 +352,7 @@ }, type: 'delete', disabled: !owner || !canEdit, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: 'Delete', @@ -365,7 +369,7 @@ }, type: 'delete', disabled: !owner || !canEdit, - hide: $userStore?.operator + hide: hideForOperator } ] }} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 7ccdb3d412..7bf9685e87 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -168,6 +168,7 @@ import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRout import { appendAttachedFilesRoster } from './files/fileTools' import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from './planMode' import { PlanModeController, type PlanModeHost } from './planModeController.svelte' +import { navigationOperatorBuilderFlows } from '$lib/operatorWriteRights' // Compaction of the stored history: once the projected request size // (contextTokens — the provider's report when current, a fresh chars/4 @@ -300,6 +301,8 @@ export function supportsPlanMode(mode: AIMode): boolean { return PLAN_MODES.has(mode) } +const isOperatorBuilderFlows = fromStore(navigationOperatorBuilderFlows) + export function isAIModeVisible(mode: AIMode): boolean { return mode !== AIMode.GLOBAL || isGlobalAiEnabled() } @@ -1435,12 +1438,19 @@ export class AIChatManager implements ChatViewHost { .map((s) => ({ ...s, kind: 'skill' as const })) ]) + // The flow and script builders both write code, which the backend refuses from an operator + // with the builder right: leaving them reachable would only produce work that cannot be + // deployed. allowedModes: Record = $derived({ script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined && - !this.disabledModes.script, - flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow, + !this.disabledModes.script && + !isOperatorBuilderFlows.current, + flow: + this.flowAiChatHelpers !== undefined && + !this.disabledModes.flow && + !isOperatorBuilderFlows.current, app: this.appAiChatHelpers !== undefined && !this.disabledModes.app, navigator: !this.disabledModes.navigator, ask: !this.disabledModes.ask, diff --git a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte index 6d14463c6b..a2fa8e08ba 100644 --- a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte +++ b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte @@ -20,6 +20,7 @@ import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { hubBaseUrlStore } from '$lib/stores' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub' import { getLatestHashForScript } from '$lib/scripts' import { sendUserToast, type Item } from '$lib/utils' @@ -30,6 +31,7 @@ import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatorBuilderFlows = useOperatorBuilderFlows() interface Props { flowModuleValue?: FlowModuleValue | undefined @@ -102,7 +104,7 @@ const scriptItems: Item[] = $derived.by(() => { if (flowModuleValue?.type !== 'script') return [] const items: Item[] = [] - if (!isHub && customUi?.scriptEdit != false) { + if (!isHub && customUi?.scriptEdit != false && !$operatorBuilderFlows) { items.push({ displayName: "Edit the script's code", icon: Pen, @@ -146,7 +148,7 @@ }) } } - if (customUi?.scriptFork != false) { + if (customUi?.scriptFork != false && !$operatorBuilderFlows) { items.push({ displayName: 'Fork into an inline script', icon: GitFork, diff --git a/frontend/src/lib/components/flows/content/FlowInput.svelte b/frontend/src/lib/components/flows/content/FlowInput.svelte index ca5c6d8073..c6744282e2 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -820,6 +820,7 @@ workspace={opWs} editTab={chatInputsEditTab ? 'inputEditor' : undefined} showDynOpt + deployedFlowPath={$initialPathStore} bind:dynCode bind:dynLang on:delete={(e) => { @@ -878,6 +879,7 @@ addPropertyV2?.handleDeleteArgument([e.detail]) }} showDynOpt + deployedFlowPath={$initialPathStore} displayWebhookWarning editTab={$flowInputEditorState?.selectedTab} {previewSchema} diff --git a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte index ae28a377c4..4e8b97f76f 100644 --- a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte @@ -8,6 +8,7 @@ import FlowScriptPickerQuick from '../pickers/FlowScriptPickerQuick.svelte' import { defaultScriptLanguages, processInlineLangs } from '$lib/scripts' import { defaultScripts, enterpriseLicense, hubBaseUrlStore } from '$lib/stores' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import type { SupportedLanguage } from '$lib/common' import { createEventDispatcher, getContext, untrack } from 'svelte' import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui' @@ -34,6 +35,7 @@ } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatorBuilderFlows = useOperatorBuilderFlows() const operatingUser = useOperatingUser() const actingUser = $derived(operatingUser.current) @@ -155,7 +157,10 @@ preFilter: 'all' | 'workspace' | 'hub', selectedKind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure' ) { - if (['script', 'trigger', 'failure', 'approval', 'preprocessor'].includes(selectedKind)) { + if ( + !$operatorBuilderFlows && + ['script', 'trigger', 'failure', 'approval', 'preprocessor'].includes(selectedKind) + ) { if (!selected && preFilter == 'all') { inlineScripts = langs.filter((lang) => { return ( @@ -248,6 +253,7 @@ let showAiRows = $derived( !disableAi && !$copilotInfo.workspaceDisabled && + !$operatorBuilderFlows && funcDesc?.length > 0 && kind != 'failure' && kind != 'preprocessor' && @@ -276,9 +282,14 @@ preFilter === 'all' && !selected && customUi?.aiSandbox != false && + !$operatorBuilderFlows && matchesAiSandbox ) + // Hub runnables carry code the workspace never reviewed, and the backend refuses them in a + // flow a builder authors, so the hub browser and its integration filters are not offered. + let showHub = $derived(!$operatorBuilderFlows) + // Every result row lives in one keyboard index space, and hovering a row moves that index, so // mouse and keyboard can never highlight two different rows. Offsets follow the render order. let inlineOffset = $derived(topLevelNodes.length) @@ -331,7 +342,7 @@ {/if} {/if} - {#if preFilter === 'hub' || preFilter === 'all'} + {#if showHub && (preFilter === 'hub' || preFilter === 'all')} {#if preFilter == 'all'}
Integrations
{/if} @@ -536,7 +547,7 @@ }} /> {/if} - {#if selectedKind != 'preprocessor' && selectedKind != 'flow'} + {#if showHub && selectedKind != 'preprocessor' && selectedKind != 'flow'} {#if (!selected || selected?.kind === 'integrations') && (preFilter === 'hub' || preFilter === 'all')} {#if !selected && preFilter !== 'hub'}
Hub
diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index a256a9998a..577a6275d9 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -39,6 +39,7 @@ import type { ButtonProp } from '$lib/components/diffEditorTypes' import { loadSchemaFromModule } from '../flowInfers' import { type Job } from '$lib/gen' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import { checkIfParentLoop } from '../utils.svelte' import { useWorkspaceScriptSettings } from '../useWorkspaceScriptSettings.svelte' import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte' @@ -74,6 +75,7 @@ import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatorBuilderFlows = useOperatorBuilderFlows() const { selectionManager, @@ -235,8 +237,11 @@ !flowModule.value.path?.startsWith('hub/') && flowModule.value.hash == undefined && customUi?.scriptEdit != false && + !$operatorBuilderFlows && $workspaceScriptSettingsDrawer != undefined ) + // Same rule as the graph node's Run button (FlowModuleSchemaItem). + let builderCannotTestStep = $derived($operatorBuilderFlows && flowModule.value.type === 'script') let workspaceScriptNoEditReason = $derived( flowModule.value.type !== 'script' || canEditWorkspaceScriptSettings ? undefined @@ -288,7 +293,7 @@ } function onKeyDown(event: KeyboardEvent) { - if ((event.ctrlKey || event.metaKey) && event.key == 'Enter') { + if ((event.ctrlKey || event.metaKey) && event.key == 'Enter' && !builderCannotTestStep) { event.preventDefault() selected = 'test' modulePreview?.runTestWithStepArgs() @@ -1121,7 +1126,9 @@ {#if !preprocessorModule} {/if} - + {#if !builderCannotTestStep} + + {/if} {#if canShowChatTab && flowModule.value.type === 'aiagent'} - {:else if visibleSelected === 'test'} + {:else if visibleSelected === 'test' && !builderCannotTestStep} {#if debugMode && isDebuggableScript}
{ - const dynCode = additionalInputsSchema?.['x-windmill-dyn-select-code'] - const dynLang = additionalInputsSchema?.['x-windmill-dyn-select-lang'] - if (dynCode && dynLang) { - return { source: 'inline', code: dynCode, lang: dynLang } - } - return undefined - }) + const dynamicInputHelperScript = $derived( + DynamicInput.flowHelperScript( + additionalInputsSchema?.['x-windmill-dyn-select-code'], + additionalInputsSchema?.['x-windmill-dyn-select-lang'], + path, + operatingUser.current?.operator + ) + ) // The composer's attachments feed this input, and the paperclip is its whole editor. const attachmentsTarget = $derived.by(() => { diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte index e6d7223fcb..06c01dd75c 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte @@ -35,6 +35,9 @@ import DiffActionBar from './DiffActionBar.svelte' import { getGraphContext } from '$lib/components/graph/graphContext' import MoveHandleButton from '$lib/components/graph/MoveHandleButton.svelte' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + + const operatorBuilderFlows = useOperatorBuilderFlows() interface Props { selected?: boolean @@ -128,6 +131,12 @@ let newId: string = $state(untrack(() => id) ?? '') + let mod = $derived( + id && flowStore?.val?.value ? dfsPreviousResults(id, flowStore.val, false)[0] : undefined + ) + // A script step is tested as a script preview, which operators are refused: running the + // deployed runnable is left to "Test flow". + let builderCannotTestStep = $derived($operatorBuilderFlows && mod?.value.type === 'script') let moduleTest: ModuleTest | undefined = $state(undefined) let testIsLoading = $state(false) let hover = $state(false) @@ -234,8 +243,6 @@ {/if} {#if deletable && id && flowStore && outputPickerVisible} - {@const flowStoreVal = flowStore.val} - {@const mod = flowStoreVal?.value ? dfsPreviousResults(id, flowStoreVal, false)[0] : undefined} {#if mod && flowStateStore?.val?.[id]} (hover = true)} onmouseleave={() => (hover = false)} > - {#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible} + {#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible && !builderCannotTestStep}
{#if !testIsLoading} - {#if savedAgentsLoading} + {#if $operatorBuilderFlows} + + {:else if savedAgentsLoading}
Loading saved agents
diff --git a/frontend/src/lib/components/home/CreateActionsMenu.svelte b/frontend/src/lib/components/home/CreateActionsMenu.svelte index a400a73df0..b35ef95aa5 100644 --- a/frontend/src/lib/components/home/CreateActionsMenu.svelte +++ b/frontend/src/lib/components/home/CreateActionsMenu.svelte @@ -25,11 +25,14 @@ import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' import { importStore } from '$lib/components/apps/store' import { conditionalMelt, getLocalSetting, storeLocalSetting } from '$lib/utils' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import { createDropdownMenu, melt } from '@melt-ui/svelte' import YAML from 'yaml' import type { Snippet } from 'svelte' import { logFeatureUsage } from '$lib/utils/featureUsage' + const operatorBuilderFlows = useOperatorBuilderFlows() + interface Props { /** Replaces the default `New` button, e.g. with an inline text link. */ trigger?: Snippet @@ -247,6 +250,14 @@ } } + // A builder composes runnables that already exist, so only flows are offered: everything else + // here writes code, which the backend refuses from an operator. + // Derived, not computed once: switching workspace only sets `workspaceStore`, it does not + // remount this component, so a snapshot would keep the previous workspace's kinds. + const options: Option[] = $derived( + $operatorBuilderFlows ? allOptions.filter((o) => o.key === 'flow') : allOptions + ) + // the doc panel only shows while an option is hovered or focused, so the menu opens compact let activeKey: string | undefined = $state(undefined) // every option's import action, surfaced together under the bottom "Import" submenu. @@ -256,7 +267,7 @@ ...(onImportHubProject ? [{ label: 'Import a hub project', onSelect: onImportHubProject }] : []), - ...allOptions.flatMap((o) => o.extras ?? []) + ...options.flatMap((o) => o.extras ?? []) ]) // melt dropdown menu: arrow-key nav, typeahead, focus management and outside/escape @@ -372,7 +383,7 @@ activeKey = undefined } }) - let active = $derived(allOptions.find((o) => o.key === activeKey)) + let active = $derived(options.find((o) => o.key === activeKey)) let activeAc = $derived(active ? accentClasses[active.accent] : undefined) // shared YAML/JSON import drawer, reused by every "Import …" extra @@ -524,7 +535,7 @@ {/if} {/snippet} - {#each allOptions as option (option.key)} + {#each options 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'} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 1d9edc683b..351dd0460a 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -17,6 +17,7 @@ import { resource } from 'runed' import { getDraftItems } from '$lib/workspaceDrafts.svelte' import { disableHubStore, userStore, workspaceStore } from '$lib/stores' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import type uFuzzy from '@leeoniya/ufuzzy' import { ArrowDownUp, @@ -60,6 +61,9 @@ import { base } from '$lib/base' import BulkActionsBar from './BulkActionsBar.svelte' import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte' + + const operatorBuilderFlows = useOperatorBuilderFlows() + interface Props { subtab?: 'flow' | 'script' | 'app' showEditButtons?: boolean @@ -338,7 +342,9 @@ canWrite: canWrite(it.path, (it.extra_perms ?? {}) as any, $userStore) && (it.type === 'script' || it.workspace_id == $workspaceStore) && - !$userStore?.operator + // The builder right covers flows only; a script or an app is still off limits, so + // the row must not offer edit or delete for those. + (!$userStore?.operator || (it.type === 'flow' && $operatorBuilderFlows)) } // combinedItems reads a script's time from `created_at`; the endpoint's // unified `edited_at` holds exactly that for scripts. @@ -1064,7 +1070,7 @@ * 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) + let canCreateHere = $derived((!$userStore?.operator || $operatorBuilderFlows) && 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 @@ -1876,9 +1882,12 @@ the menu itself does no permission check. --> {#if canCreateHere} + script and flow hub pickers observe. Nor for a builder: a hub project brings + scripts and apps along. --> (hubPickerOpen = true)} + onImportHubProject={$disableHubStore || $operatorBuilderFlows + ? undefined + : () => (hubPickerOpen = true)} /> {/if}
diff --git a/frontend/src/lib/components/home/WorkspaceEmptyState.svelte b/frontend/src/lib/components/home/WorkspaceEmptyState.svelte index fc36cc334b..1f7bebb1ce 100644 --- a/frontend/src/lib/components/home/WorkspaceEmptyState.svelte +++ b/frontend/src/lib/components/home/WorkspaceEmptyState.svelte @@ -4,9 +4,12 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import type { HubProjectPick } from '$lib/hubProject' import { disableHubStore } from '$lib/stores' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import CreateActionsMenu from './CreateActionsMenu.svelte' import HubTemplatePicker from './HubTemplatePicker.svelte' + const operatorBuilderFlows = useOperatorBuilderFlows() + interface Props { /** A project was chosen here. The list owns the import dialog, and opens it on this. */ onPick: (project: HubProjectPick) => void @@ -35,6 +38,9 @@ // `border-light`; only the container outline steps up, so nothing outweighs its frame. const rowOpacities = [1, 0.7, 0.4] + // A builder gets no hub template: a hub project brings scripts and apps along. + const showHub = $derived(!$disableHubStore && !$operatorBuilderFlows) + // 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) @@ -93,9 +99,9 @@ Your scripts, flows and apps will show up here. {/if} {#if canCreate} - - {#if !$disableHubStore} + {#if showHub} +
+ +
{@render actions()}
+
{:else} + + {/if} + + agentMenuItems({ + deployUiSettings: await getDeployUiSettings(), + path: agent.path, + canWrite: agent.canWrite, + draftOnly: Boolean(agent.draft_only), + wsSpecific: agent.ws_specific, + onPermissions: () => shareModal.openDrawer?.(agent.path, 'resource'), + onDeploy: () => deploymentDrawer.openDrawer(agent.path, 'resource'), + onDelete: (event) => { + const { path } = agent + if (event?.shiftKey) remove(path) + else deleteConfirmedCallback = () => remove(path) + } + })} + on:open={() => { + menuOpen = true + }} + /> + {/snippet} + diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 4fb90151c1..76cd30640a 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -39,6 +39,7 @@ | 'flow' | 'app' | 'raw_app' + | 'agent' | 'resource' | 'variable' | 'resource_type' diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 9f0395b924..a7c99345a3 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -8,6 +8,7 @@ import AzureIcon from '$lib/components/icons/AzureIcon.svelte' import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte' import { + Bot, Boxes, Calendar, Code2, @@ -29,6 +30,7 @@ | 'app' | 'raw_app' | 'raw_app_file' + | 'agent' | 'resource' | 'variable' | 'resource_type' @@ -99,6 +101,8 @@ {:else if effectiveKind === 'script'} + {:else if effectiveKind === 'agent'} + {:else if effectiveKind === 'variable'} {:else if effectiveKind === 'resource'} diff --git a/frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte b/frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte index 288fab84b3..fe5c255596 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte @@ -21,10 +21,13 @@ they follow the mode, the connected servers and the workspace's AI settings. tools, providerTools, active, - blocksClose = $bindable() + blocksClose = $bindable(), + description = "What the assistant can call in this session: the built-in tools, whatever the connected MCP servers expose, and what the model's provider runs itself." }: { tools: ToolSummary[] providerTools: ProviderToolSummary[] + /** What the list is, under its heading. */ + description?: string /** Whether this is the panel on screen. Gates the detail page's build, which pulls * in the schema table and its syntax highlighter. */ active: boolean @@ -145,10 +148,7 @@ they follow the mode, the connected servers and the workspace's AI settings. onkeydown={highlight.onKeydown} onpointermove={highlight.pointerMoved} > -
+
void + onclick: (e: MouseEvent) => void color?: 'red' + disabled?: boolean } - const { triggersCount, triggersState } = $state(getContext('TriggerContext')) + // An agent's page has no triggers, and so no context for them. + const { triggersCount, triggersState } = $state( + getContext('TriggerContext') ?? ({} as Partial) + ) interface Props { mainButtons?: MainButton[] menuItems?: MenuItemButton[] summary?: string path?: string - tag: string | undefined - errorHandlerKind: 'flow' | 'script' - scriptOrFlowPath: string - errorHandlerMuted: boolean | undefined + tag?: string | undefined + /** Unset for what has no workspace error handler to mute, such as an agent. */ + errorHandlerKind?: 'flow' | 'script' + scriptOrFlowPath?: string + errorHandlerMuted?: boolean | undefined labels?: string[] | undefined inheritedLabels?: string[] | undefined onSaved?: (newPath: string) => void children?: import('svelte').Snippet trigger_badges?: import('svelte').Snippet + /** Controls ahead of the menu, such as the way an agent's page runs it. */ + leading_actions?: import('svelte').Snippet } let { @@ -64,7 +71,8 @@ inheritedLabels = undefined, onSaved, children, - trigger_badges + trigger_badges, + leading_actions }: Props = $props() const dispatch = createEventDispatcher() @@ -81,6 +89,7 @@ } async function toggleErrorHandler() { + if (!errorHandlerKind || !scriptOrFlowPath) return const next = await toggleWorkspaceErrorHandler( errorHandlerKind, scriptOrFlowPath, @@ -99,7 +108,7 @@ disabled: b.buttonProps.disabled, type: 'action' as const })), - ...(wide.current + ...(wide.current || !errorHandlerKind ? [] : [ { @@ -113,6 +122,7 @@ displayName: item.label, icon: item.Icon, action: item.onclick, + disabled: item.disabled, type: item.color === 'red' ? ('delete' as const) : ('action' as const), separatorTop: i === 0 && !wide.current })) @@ -176,12 +186,13 @@ {@render trigger_badges?.()}
+ {@render leading_actions?.()} {#if allMenuItems.length > 0} {#key allMenuItems} {/key} {/if} - {#if wide.current} + {#if wide.current && errorHandlerKind && scriptOrFlowPath} { + try { + await ResourceService.deleteResource({ workspace, path }) + sendUserToast(`Deleted agent ${path}`) + return true + } catch (err) { + sendUserToast(`Could not delete agent ${path}: ${err}`, true) + return false + } +} + +/** An agent's menu, as its home row and its page both show it. */ +export function agentMenuItems(agent: { + path: string + canWrite: boolean + /** Never deployed: no resource yet to hold permissions or to deploy onward. */ + draftOnly?: boolean + wsSpecific?: boolean + /** Undefined until loaded, when nothing is offered for deploy. */ + deployUiSettings: WorkspaceDeployUISettings | undefined + onPermissions: () => void + onDeploy: () => void + onDelete: (event?: MouseEvent) => void +}): Item[] { + const deployable = + !agent.wsSpecific && + !agent.draftOnly && + isDeployable('resource', agent.path, agent.deployUiSettings) + return [ + { + displayName: 'Permissions', + icon: Shield, + disabled: !agent.canWrite || Boolean(agent.draftOnly), + action: agent.onPermissions + }, + ...(deployable + ? [{ displayName: 'Deploy to prod/staging', icon: FileUp, action: agent.onDeploy }] + : []), + { + displayName: 'Delete', + icon: Trash, + type: 'delete' as const, + disabled: !agent.canWrite, + action: agent.onDelete + } + ] +} diff --git a/frontend/src/lib/components/flows/agentDraft.svelte.ts b/frontend/src/lib/components/flows/agentDraft.svelte.ts index 5ee65cd98a..b76f9bb471 100644 --- a/frontend/src/lib/components/flows/agentDraft.svelte.ts +++ b/frontend/src/lib/components/flows/agentDraft.svelte.ts @@ -7,6 +7,11 @@ import { canWrite } from '$lib/utils' import { userStore } from '$lib/stores' import { getUserExt } from '$lib/user' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' +import { UserDraft } from '$lib/userDraft.svelte' +import { onUserInput } from '$lib/userDraftEditGate' +import { DEFAULT_AGENT_MEMORY } from './agentFormFields' +import { getUsernameForNamespace } from '$lib/userNamespace' +import { random_adj } from '$lib/components/random_positive_adjetive' import { useTriggerDraftSync, type TriggerDraftSync } from '../triggers/useTriggerDraftSync.svelte' import { logReusableAgentUsage } from './agentTelemetry' import { @@ -177,10 +182,30 @@ async function writeAgentResource( return { ok: true } } +/** A `u//_agent` no resource holds yet, as the path field picks one for a new + * script or flow. Gives up on availability after a few draws rather than hold the editor. */ +async function freeAgentPath(workspace: string): Promise { + const mint = () => `u/${getUsernameForNamespace()}/${random_adj()}_agent` + let candidate = mint() + for (let i = 0; i < 10; i++) { + const taken = await ResourceService.existsResource({ workspace, path: candidate }).catch( + () => false + ) + if (!taken) break + candidate = mint() + } + return candidate +} + export interface AgentDraftOptions { /** The `ai_agent` resource being edited. */ path: () => string | undefined workspace: () => string | undefined + /** A missing row is a new agent to start empty, not a load failure. */ + isNew?: () => boolean + /** Only the deployed agent, as a page that runs it shows it: no draft is loaded, restored or + * written, and a write from another tab does not land here. */ + deployedOnly?: () => boolean } export interface AgentDraftHandle { @@ -221,7 +246,8 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { const sync = useTriggerDraftSync({ itemKind: 'resource', - path: () => opts.path() ?? '', + // An empty path holds no draft handle, which is what keeps a deployed-only view off the draft. + path: () => (opts.deployedOnly?.() ? '' : (opts.path() ?? '')), workspace: () => opts.workspace(), drawerLoading: () => loading || refusal != null, // `$state.snapshot` deep-reads, so the sync effects re-run when a nested field of `args` @@ -234,6 +260,20 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { deployed: () => deployed as Record | undefined }) + /** A new agent has no deployed value for the sync to absorb the form's settling into, so until + * the user's first input the draft cell follows the form as a seed instead: what the editor + * fills in on its own, and the name the path field shows, is not an edit to save. */ + let seedNewUntilInput = $state<{ ws: string; path: string } | undefined>(undefined) + onUserInput(() => { + seedNewUntilInput = undefined + }) + $effect(() => { + const target = seedNewUntilInput + if (!target || !state) return + const settled = $state.snapshot(state) + untrack(() => UserDraft.seed('resource', target.path, settled, { workspace: target.ws })) + }) + function refuse(reason: string) { loading = false refusal = reason @@ -260,12 +300,13 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { loadedFor = key loading = true refusal = undefined + seedNewUntilInput = undefined // The user alongside the resource, as the generic resource editor loads it: a session or // fork editor operates on a workspace that is not the one being navigated, and groups, // folders and the admin flag are all per workspace, so the nav user would answer for the // wrong membership in both directions. Promise.all([ - ResourceService.getResource({ workspace: ws, path, getDraft: true }), + ResourceService.getResource({ workspace: ws, path, getDraft: !opts.deployedOnly?.() }), getUserExt(ws).catch(() => undefined) ]) // The rejection handler is `then`'s second argument rather than a trailing `catch`, so @@ -319,18 +360,48 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { // conflict or failure for the key: a conflict is deliberately sticky (the retry // keeps the same baseline), and nothing else mounts a resolver for `resource` // drafts, so re-opening the agent is the only place it can be resolved. - UserDraftDbSyncer.recordRemoteSync( - { workspace: ws, itemKind: 'resource', path }, - (r as { draft_saved_at?: string }).draft_saved_at - ) + if (!opts.deployedOnly?.()) { + UserDraftDbSyncer.recordRemoteSync( + { workspace: ws, itemKind: 'resource', path }, + (r as { draft_saved_at?: string }).draft_saved_at + ) + } loading = false await sync.maybeRestore() }, - (err) => { + async (err) => { + if (loadedFor !== key) return + // Nothing is written until the first edit: the sync saves only on user input, and + // the first deploy creates the resource at whatever path the form then holds. The + // draft stays at the minted `draft_` storage path, and the form starts on the + // free name a new script or flow gets. Named here rather than by the path field: a + // name minted after the seed below would differ from it, and the first click would + // save it. + if (opts.isNew?.() && (err as { status?: number })?.status === 404) { + const name = await freeAgentPath(ws) + if (loadedFor !== key) return + noDeployed = true + deployed = undefined + canWriteResource = true + state = { + path: name, + description: '', + // Opens on a working chat, the way a new agent is first tried. The editor says + // what memory is for, and how to turn it off, while it is on. + args: { memory: structuredClone(DEFAULT_AGENT_MEMORY) }, + resource_type: 'ai_agent', + wsSpecific: false + } + loading = false + // Seeds the draft cell with the empty agent, or its first-write guard swallows the + // first edit. Not `sync.maybeRestore`: with nothing deployed to compare against, it + // takes the form for a restored draft and autosaves it before any input. + seedNewUntilInput = { ws, path } + return + } // A failed load knows neither the resource's type nor its value, so it refuses: // clearing `loading` alone would let the sync restore a persisted draft into a form // that would then deploy over a resource nobody read. - if (loadedFor !== key) return refuse(`Could not load agent ${path}: ${err}`) } ) diff --git a/frontend/src/lib/components/flows/agentEditorStore.svelte.ts b/frontend/src/lib/components/flows/agentEditorStore.svelte.ts index 025a426d28..24a44df0ad 100644 --- a/frontend/src/lib/components/flows/agentEditorStore.svelte.ts +++ b/frontend/src/lib/components/flows/agentEditorStore.svelte.ts @@ -14,10 +14,13 @@ export interface AgentEditorTarget { workspace?: string toolId?: string /** A level of the editor that is not the form. Mutually exclusive with `toolId`. */ - view?: 'evals' + view?: 'evals' | 'settings' /** Where to re-resolve a graph's tool nodes after a deploy, when opened from a flow step. Only a * real flow sets it: the agent editor offers no way to open a second editor from inside itself. */ host?: { flowPath: string; moduleId: string } + /** The path was minted for an agent that does not exist yet: a missing row is an empty agent to + * start from rather than a load failure. */ + isNew?: boolean } let target = $state(undefined) diff --git a/frontend/src/lib/components/flows/content/AgentConfigModal.svelte b/frontend/src/lib/components/flows/content/AgentConfigModal.svelte new file mode 100644 index 0000000000..5fe009b57b --- /dev/null +++ b/frontend/src/lib/components/flows/content/AgentConfigModal.svelte @@ -0,0 +1,151 @@ + + + + + {#snippet headerLeft()} +

+ What the agent runs with. Edit the agent to change it. +

+ {/snippet} + +
+
+ (section = id as Section)} + /> +
+ +
+ {#if section === 'model'} +
+ {#each settings as row (row.label)} +
+
{row.label}
+
{row.value}
+
+ {:else} + Not configured + {/each} +
+ {:else if section === 'instructions'} +
+ {#if systemPrompt} +

+ {systemPrompt} +

+ {:else} + No system message + {/if} +
+ {/if} + +
+ +
+
+
+
diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index f0d65068f0..9c2a57b43f 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -1,7 +1,8 @@ + +{#snippet configButton()} + + - -

Chat needs managed memory

-

- {chatGap.memoryCanTurnOn - ? 'Without it, every message would be answered without the ones before it.' - : 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'} -

- {#if chatGap.memoryCanTurnOn && !readOnly} - - {/if} -
- {:else if chatGap?.noStream} -
- - - {chatGap.noStream === 'image' - ? 'Image answers do not stream: each one shows once its run ends.' - : 'Streaming is off: each answer shows once its run ends.'} - - {#if chatGap.noStream === 'off' && !readOnly} - - {/if} -
- {/if} - -
- + + {/if} + + +
+
-
- {/if} + + + + +
- - - + {/if} + {#if chatMounted} +
+ {#if chatGap?.memory} +
+ +

Chat needs managed memory

+

+ {chatGap.memoryCanTurnOn + ? 'Without it, every message would be answered without the ones before it.' + : 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'} +

+ {#if chatGap.memoryCanTurnOn && !readOnly} + + {/if} +
+ {:else if chatGap?.noStream} + + Each answer shows once its run ends. + {#if chatGap.noStream === 'off' && !readOnly} +
+ +
+ {/if} +
+ {/if} + +
+ +
+
+ {/if} + + {/snippet} - true, (open) => !open && close()} - kind="X" - fillHeight - enterConfirms={false} - paginated - title={target.path} - {trail} - {description} - class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]" - > - {#snippet titleBadge()} - - {#if version != undefined && !refused} - - v{version} - - {/if} - {/snippet} - {#snippet levelBadge()} - - {#if inEvals && !evalsLocation} - Beta - {/if} - {/snippet} - - {#snippet settings()} -
- {#if !inEvals && !refused} - - {#if testPane?.mode} - testPane?.mode, - (mode) => { - if (testPane) testPane.mode = mode - } - } - noWFull - > - {#snippet children({ item })} - - - {/snippet} - - {/if} - {#if readOnly} - - Read only - - {/if} - - {#if !draftOnly} - - {/if} - - {/if} -
- {/snippet} + {#if layout === 'modal'} + true, (open) => !open && close()} + kind="X" + fillHeight + enterConfirms={false} + paginated + title={shownPath ?? target.path} + {trail} + {description} + {titleBadge} + {levelBadge} + {settings} + class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]" + > + {@render body()} + + {:else}
- -
- draft?.deployed} - getCurrent={() => draft?.state} - onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')} - title="Deployed <> Unsaved agent changes" - /> +
+
+
+ {#each trail as segment, i (i)} + {#if i > 0} + + {/if} + {#if segment.onclick} + + {:else} + {segment.label} + {/if} + {#if i === 0} + {@render titleBadge()} + {/if} + {/each} + {@render levelBadge()} +
+ {#if description} + {description} + {/if} +
+
+ {@render settings()} +
+
+ {@render body()}
- -
- + {/if} {/key} + {#snippet titleBadge()} + + {#if version != undefined && !refused} + + v{version} + + {/if} + {/snippet} + {#snippet levelBadge()} + + {#if inEvals && !evalsLocation} + Beta + {/if} + {/snippet} + + {#snippet settings()} +
+ {#if !inEvals && !refused} + + {#if testPane?.mode && !inSettings} + testPane?.mode, + (mode) => { + if (testPane) testPane.mode = mode + } + } + noWFull + > + {#snippet children({ item })} + + + {/snippet} + + {/if} + {#if readOnly} + + Read only + + {/if} + {#if !inSettings} + + {/if} + + {/if} +
+ {/snippet} + {#snippet body()} +
+ +
+ draft?.deployed} + getCurrent={() => draft?.state} + onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')} + title="Deployed <> Unsaved agent changes" + /> +
+ + +
+ {/snippet} + + and the config a draft run is offered on. The target is read optionally: a deploy that + navigates away clears it while these are still rendered. --> {#snippet agentPage()} showAgentEditorTool(id)} {onSaved} + isNew={target?.isNew} /> {/snippet} + {#snippet settingsFields()} + {#if draft} + host?.pathError(), (error) => host?.setPathError(error)} + /> + {/if} + {/snippet} + + {#snippet settingsPage()} +
{@render settingsFields()}
+ {/snippet} + {#snippet evalsPage()} {/snippet} + {#if layout === 'page' && ws} + + {/if} + + + settingsDrawer?.closeDrawer()}> + {@render settingsFields()} + + + versionDrawer?.closeDrawer()} noPadding> + import { Badge } from '$lib/components/common' + import Modal from '$lib/components/common/modal/Modal.svelte' + import EvalsPane from '$lib/components/aiEvals/EvalsPane.svelte' + import type { EvalsLocation } from '$lib/components/aiEvals/evalUtils' + import type { AgentDraft } from '$lib/gen' + + /** An agent's evals in a dialog of their own, over the page that opened them. */ + interface Props { + agentPath: string + workspace: string + /** The unsaved edits, offered to a run as an alternative to the deployed version. */ + editedConfig?: () => AgentDraft + } + + let { agentPath, workspace, editedConfig = undefined }: Props = $props() + + let open = $state(false) + // Where the evals pane is within itself, so its levels extend the dialog's trail. Cleared on the + // way in: the pane reports a level once it is on one, and never that it is back at its root. + let location = $state(undefined) + + export function openModal() { + location = undefined + open = true + } + + +{#if open} + + {#snippet titleBadge()} + {#if !location} + Beta + {/if} + {/snippet} + + +{/if} diff --git a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte index 83be7c876e..dc7c0948eb 100644 --- a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte +++ b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte @@ -8,6 +8,8 @@ import ResourcePathHint from '$lib/components/ResourcePathHint.svelte' import { ResourceService, type InputTransform, type Resource } from '$lib/gen' import { sendUserToast } from '$lib/toast' + import { userStore } from '$lib/stores' + import { canWrite } from '$lib/utils' import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte' import { AGENT_BRAIN_KEYS, @@ -116,6 +118,8 @@ fromDraft: boolean providerPath?: string providerOk: boolean + /** The agent's own sharing, to tell whether this user may edit it. */ + extraPerms?: Record /** The link cannot be read. `missing` (404): nothing exists at the path, the agent having been * renamed or deleted. `forbidden` (401/403): it exists and this user is refused it, a folder * they cannot read included, which says nothing about whether a run of the flow can read it. @@ -197,7 +201,8 @@ tools, fromDraft: draft != undefined, providerPath, - providerOk + providerOk, + extraPerms: (response.extra_perms ?? {}) as Record } } ) @@ -228,6 +233,11 @@ let providerPath = $derived(linkedInfo?.providerPath) let providerOk = $derived(linkedInfo?.providerOk ?? true) let unavailable = $derived(linkedInfo?.unavailable ?? false) + let canEditAgent = $derived( + !!agent && + !$userStore?.operator && + canWrite(agent, linkedInfo?.extraPerms ?? {}, $userStore ?? undefined) + ) // The hint flips on the first keystroke in the agent editor, so the badge does not wait for the // debounced autosave and the refetch behind it; the fetched answer covers a draft written // elsewhere, which no editor here has published an opinion about. @@ -678,10 +688,19 @@
{:else if !providerOk}
- - This agent's model provider{#if providerPath} - ({providerPath}){/if} isn't accessible in this workspace. - Unlink to fork the agent, or gain access to the provider resource. + + + {#if providerPath} + You don't have access to {providerPath}. + {:else} + You don't have access to this agent's model provider. + {/if} + {#if canEditAgent && !fromAgentEditor} + Edit the agent to use another, unlink it, or ask for access. + {:else} + Unlink it or ask for access. + {/if}
{/if} diff --git a/frontend/src/lib/components/flows/content/AgentSettings.svelte b/frontend/src/lib/components/flows/content/AgentSettings.svelte new file mode 100644 index 0000000000..449edfccff --- /dev/null +++ b/frontend/src/lib/components/flows/content/AgentSettings.svelte @@ -0,0 +1,79 @@ + + +{#if draft.state} +
+ + {#if readOnly} + {#if draft.state.labels?.length} +
+ {#each draft.state.labels as label (label)} + {label} + {/each} +
+ {/if} + {:else} + + {/if} + {#if deployTo.current} + + {/if} + +
+{/if} diff --git a/frontend/src/lib/components/flows/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte index 04671e061b..321caeb190 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChat.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChat.svelte @@ -61,6 +61,8 @@ /** What a message runs, as the chat names it. An agent has no deployed chats, so its * sidebar offers no filter between those and the test ones. */ subject?: 'flow' | 'agent' + /** The host's own controls, after the model's in the composer's footer. */ + composerSettings?: import('svelte').Snippet } let { @@ -75,7 +77,8 @@ wideLayout = false, frame = 'top', conversationKind = 'deployed', - subject = 'flow' + subject = 'flow', + composerSettings = undefined }: Props = $props() const flowEditorContext = getContext('FlowEditorContext') @@ -239,6 +242,7 @@ {wideLayout} {conversationKind} {subject} + extraSettings={composerSettings} /> {/if} diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index 51046cd03d..f6cc9af6f5 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -55,6 +55,8 @@ conversationKind?: 'test' | 'deployed' /** What a message runs, as the composer names it. */ subject?: 'flow' | 'agent' + /** The host's own controls, after the model's in the composer's footer. */ + extraSettings?: import('svelte').Snippet } let { @@ -71,7 +73,8 @@ description = undefined, wideLayout = false, conversationKind = 'deployed', - subject = 'flow' + subject = 'flow', + extraSettings = undefined }: Props = $props() const operatingUser = useOperatingUser() @@ -301,6 +304,7 @@ {workspace} /> {/if} + {@render extraSettings?.()} {/snippet} diff --git a/frontend/src/routes/(root)/(logged)/agents/add/+page.ts b/frontend/src/routes/(root)/(logged)/agents/add/+page.ts new file mode 100644 index 0000000000..38697b7210 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/agents/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('agents/edit') diff --git a/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte new file mode 100644 index 0000000000..b4539d0a8e --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte @@ -0,0 +1,31 @@ + + +
+ t.host === undefined} + onClose={(deployed) => goto(deployed ? `${base}/agents/get/${path}` : `${base}/?kind=agent`)} + onDeployed={(saved) => + // Replaced, not pushed, when this URL stopped naming what it opens: Back would otherwise + // reopen `new_draft` on an agent that now exists, or a path a rename just moved away from. + goto(`${base}/agents/get/${saved}`, { replaceState: isNew || saved !== path })} + /> +
diff --git a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte new file mode 100644 index 0000000000..f6de4c078e --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte @@ -0,0 +1,188 @@ + + + + + (deleteOpen = false)} + on:confirmed={() => { + deleteOpen = false + remove() + }} +> + Every flow that links {path} will fail at its agent step once it is deleted. + + +
+ evalsModal?.openModal() + } + }, + { + label: 'Edit', + buttonProps: { + variant: 'accent', + unifiedSize: 'md', + startIcon: Pen, + href: `${base}/agents/edit/${path}` + } + } + ] + : []) + ]} + > + {#snippet leading_actions()} + + {#if chatAvailable && testPane?.mode} + testPane?.mode, + (mode) => { + if (testPane) testPane.mode = mode + } + } + noWFull + > + {#snippet children({ item })} + + + {/snippet} + + {/if} + {/snippet} + +
+ {#key `${ws}:${path}`} + configModal?.open()} + > + {#snippet viewForm({ schema, run, loading, actions })} + + {/snippet} + + {/key} +
+
+ +{#if ws} + +{/if} + +{#if config} + host?.toolSchema(id)} /> +{/if} From cc52dfcce5b3f7746af2c1969c56851c6c65f836 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 30 Sep 2026 09:58:55 -0700 Subject: [PATCH 09/31] fix: keep every control the band took over reachable Four controls the page-header conversion could put out of reach, each found by a review pass and reproduced in the browser: - The flow editor's preview buttons. `topbarWidth` is bound only in the branch a full-page editor does not render, so `compactTopbar` was permanently false there; below the 800px phone bar `phoneTopbar` dropped the buttons inline and the graph overlay never took them, leaving FlowPreviewButtons mounted nowhere and step testing, whole-flow testing and "Test flow & record" no-ops through optional calls. It now measures the header the way ScriptBuilder and AppEditorHeader do, and the inline and overlay conditions are exact complements. - The pin on a deployed app. The band lands over the corner handle that called it down, so the pointer that arrives there hits the band. The band carries the pin itself now, on the same pixels, in place of the sidebar handle it hides. - The detached sidebar's own menus. They portal to `body`, so moving into one left the card and closed it 200ms later, sliding it out from under the menu being read. The card holds while the pointer is in a menu and takes the close from the leave of that. - A deployed app's name for a read-only viewer. The breadcrumb was registered behind write access, so without it the trail fell back to the section name "Apps"; and the Edit button rode `afterName`, which the band drops for an embed along with the trail. The name is registered for whoever opened the app, and what sits after it survives `navHidden` as the page's own control. navPageFor gets a table test: it answers both the sidebar links and the breadcrumb label, and its longest-prefix and first-segment rules are what made the "Apps" fallback look like a correct answer. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/components/FlowBuilder.svelte | 19 ++++++++++---- .../src/lib/components/PageHeaderBar.svelte | 26 ++++++++++++++++++- .../apps/editor/InWorkspaceAppViewer.svelte | 18 ++++++++----- .../lib/components/sidebar/navPages.test.ts | 24 +++++++++++++++++ .../src/routes/(root)/(logged)/+layout.svelte | 26 ++++++++++++++----- 5 files changed, 94 insertions(+), 19 deletions(-) create mode 100644 frontend/src/lib/components/sidebar/navPages.test.ts diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 13847760f7..3224e7f28a 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -223,7 +223,14 @@ // rely on viewport `md:` because the editor lives inside other panes // (session pane, drawer, etc.) where the viewport stays wide. let topbarWidth = $state(0) - const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720) + // In the page header the buttons share the row with the breadcrumb, so they collapse sooner: + // the trail and the summary take ~560px before either truncates, and the full group — the + // dropdown, Diff, the preview trio and Deploy — is ~610px. + const compactBelow = $derived(ownsPageHeader ? 1200 : 720) + const compactTopbar = $derived.by(() => { + const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth + return w > 0 && w < compactBelow + }) // A phone's bar holds the trail and Deploy: the diff joins the menu and the preview buttons // stand down, since there is no room to run a flow beside its own name. const phoneTopbar = $derived( @@ -1701,9 +1708,11 @@ /> {/snippet} - + {#snippet previewButtons()} {#if !disableAi} diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index 98451045b1..d8f27ff0c6 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -193,34 +193,36 @@ ) // Labels, paths and icons come from the shared page table, so the nav and the page header's // breadcrumb always call a page the same thing. Everything else here is nav-only. - const MAIN_MENU_EXTRAS: Record void }> = - { - Home: { - aiId: 'sidebar-menu-link-home', - aiDescription: - "Button to navigate to home which contains all the user's scripts, flows and apps" - }, - Runs: { - aiId: 'sidebar-menu-link-runs', - aiDescription: 'Button to navigate to runs', - onclick: () => { - setTimeout(() => { - window.dispatchEvent(new Event('popstate')) - }, 100) - } - }, - Variables: { - aiId: 'sidebar-menu-link-variables', - aiDescription: 'Button to navigate to variables' - }, - Resources: { - aiId: 'sidebar-menu-link-resources', - aiDescription: 'Button to navigate to resources' - }, - Assets: { aiId: 'sidebar-menu-link-assets', aiDescription: 'Button to navigate to assets' }, - Folders: { aiId: 'sidebar-menu-link-folders', aiDescription: 'Button to navigate to folders' }, - Groups: { aiId: 'sidebar-menu-link-groups', aiDescription: 'Button to navigate to groups' } - } + const MAIN_MENU_EXTRAS: Record< + string, + { aiId: string; aiDescription: string; onclick?: () => void } + > = { + Home: { + aiId: 'sidebar-menu-link-home', + aiDescription: + "Button to navigate to home which contains all the user's scripts, flows and apps" + }, + Runs: { + aiId: 'sidebar-menu-link-runs', + aiDescription: 'Button to navigate to runs', + onclick: () => { + setTimeout(() => { + window.dispatchEvent(new Event('popstate')) + }, 100) + } + }, + Variables: { + aiId: 'sidebar-menu-link-variables', + aiDescription: 'Button to navigate to variables' + }, + Resources: { + aiId: 'sidebar-menu-link-resources', + aiDescription: 'Button to navigate to resources' + }, + Assets: { aiId: 'sidebar-menu-link-assets', aiDescription: 'Button to navigate to assets' }, + Folders: { aiId: 'sidebar-menu-link-folders', aiDescription: 'Button to navigate to folders' }, + Groups: { aiId: 'sidebar-menu-link-groups', aiDescription: 'Button to navigate to groups' } + } let mainMenuLinks = $derived( NAV_PAGES.filter((p) => p.label in MAIN_MENU_EXTRAS) .map((p) => ({ diff --git a/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte b/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte index 4f4698403e..467167e44f 100644 --- a/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte @@ -122,40 +122,40 @@ {/snippet} {#snippet auditActions()} - -
- - {#snippet filters()} - auditLogsLoader.reload()} - /> - {/snippet} - -
+ +
+ + {#snippet filters()} + auditLogsLoader.reload()} + /> + {/snippet} + +
{/snippet}
From 8953ca670aac63feab1a04369df50a669c1f40bc Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 30 Sep 2026 20:04:06 +0200 Subject: [PATCH 15/31] feat: make hub integrations usable as examples in global AI chat (#10599) * feat: make hub integrations usable as examples in global chat Co-Authored-By: Claude Opus 5 (1M context) * fix: keep web search guidance in sync with provider capability Co-Authored-By: Claude Opus 5 (1M context) * fix: resync web search guidance before the request is built Co-Authored-By: Claude Opus 5 (1M context) * fix: carry loop web search availability into every prompt rebuild Co-Authored-By: Claude Opus 5 (1M context) * fix: drop web search guidance on the completions api fallback Co-Authored-By: Claude Opus 5 (1M context) * docs: correct onBeforeIteration contract for the fallback re-entry Co-Authored-By: Claude Opus 5 (1M context) * feat: give the ai chat hub script descriptions and integration metadata Co-Authored-By: Claude Opus 5 * fix: treat authored hub metadata as evidence the scripts were curated Co-Authored-By: Claude Opus 5 * fix: treat hub curated as three-state and speak only for a stated true Co-Authored-By: Claude Opus 5 * test: let the benchmark hub serve integration metadata Co-Authored-By: Claude Opus 5 * test: benchmark the hub tool against a real integration's scripts and metadata Co-Authored-By: Claude Opus 5 * test: cover two more real integrations where the scripts already answer Co-Authored-By: Claude Opus 5 * test: report metadata_source from whether the fixture has authored meta Co-Authored-By: Claude Opus 5 * fix: parse the hub resource type schema instead of relaying it as a string Co-Authored-By: Claude Opus 5 * fix: match integration suggestions on slug words instead of substrings Co-Authored-By: Claude Opus 5 * fix: search the integration a query names outright instead of ranking past it Co-Authored-By: Claude Opus 5 * fix: reject hub integration slugs that would re-target the proxied request Co-Authored-By: Claude Opus 5 * fix: add a mentioned integration's hits instead of filtering the search to it Co-Authored-By: Claude Opus 5 * fix: keep named-integration hits under the content cap and unmangle fixture placeholders Co-Authored-By: Claude Opus 5 * fix: cut the middle of a capped hub result instead of repeating its tail Co-Authored-By: Claude Opus 5 * fix: cap hub results by keeping the best of the ranked and named hits Co-Authored-By: Claude Opus 5 * fix: keep a named integration that ranking placed below the content cap Co-Authored-By: Claude Opus 5 * fix: let plan mode use the hub integration lookup Co-Authored-By: Claude Opus 5 * feat: mark which hub integrations carry authored provider knowledge The hub flags the integrations whose document holds hand-written provider knowledge, so a caller can tell before spending a call on the metadata endpoint: 18 of ~216 qualify, and for the rest the endpoint returns what was inferred from the same scripts a search already hands back. Carry the flag onto search results, where the model first meets a slug, so get_hub_integration is aimed at the few instead of guessed at. A hub that predates the flag omits it and nothing is marked. Co-Authored-By: Claude Opus 5 * fix: stop the documented mark reading as a reason not to call The flag says the hub additionally holds provider knowledge checked against the live API. It is not a signal to skip the lookup elsewhere: that call still returns the resource type and the usage-ranked examples, neither of which a search result carries, and neither guessable. The prompt said to read a script instead when the mark is absent, which traded those for a guess on the ~198 undocumented integrations. Co-Authored-By: Claude Opus 5 * fix: keep the benchmark's documented flag true to what it serves The eval hub reported Baremetrics and Holded as undocumented while its metadata endpoint handed back their authored notes, so a case could teach the model the flag means nothing. Derive it from both fixture sources, and pin the agreement. Also trims the documentedIntegrations comment to the four lines AGENTS.md allows, keeping the case-folding constraint. Co-Authored-By: Claude Opus 5 * fix: keep the benchmark hub mostly undocumented, like the real one Adding holded and baremetrics to the documented set resolved the fixture's contradiction the wrong way: it left five of six integrations marked, against the live hub's 18 of ~216, and claimed authored notes for two the hub reports as having none. Drop the notes instead. Their auth and endpoints are in their shipped scripts, which is what the cases that use them are about. Also drops the last two places still describing the flag as a reason to spend or skip the metadata call. Co-Authored-By: Claude Opus 5 * refactor: let the model name the integration instead of guessing it from the query Co-Authored-By: Claude Opus 5 * refactor: word web search guidance conditionally instead of tracking provider capability Co-Authored-By: Claude Opus 5 * fix: open the hub metadata route to job tokens and share one integrations fetch Co-Authored-By: Claude Opus 5 * fix: record a copied hub script's source where write_script keeps it Co-Authored-By: Claude Opus 5 * test: pin that a query narrows to the integration it was given Co-Authored-By: Claude Opus 5 * fix: match integration suggestions on the name the hub curates Co-Authored-By: Claude Opus 5 * fix: name an integration the hub leaves unnamed from the local word table Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 (1M context) --- ai_evals/adapters/frontend/mockBackend.ts | 356 +++++- .../adapters/frontend/mockBackendApi.test.ts | 18 + ai_evals/cases/global.yaml | 171 +++ .../frontend/global/hub/confluence.json | 983 +++++++++++++++ .../frontend/global/hub/outreach.json | 1055 +++++++++++++++++ .../frontend/global/hub/servicenow.json | 672 +++++++++++ backend/windmill-api-auth/src/scopes.rs | 4 +- backend/windmill-api-embeddings/src/lib.rs | 38 + backend/windmill-api/openapi.yaml | 168 +++ backend/windmill-api/src/integration.rs | 68 +- .../lib/components/copilot/chat/app/core.ts | 2 +- .../copilot/chat/global/core.test.ts | 8 +- .../components/copilot/chat/global/core.ts | 6 +- .../components/copilot/chat/shared.test.ts | 381 +++++- .../src/lib/components/copilot/chat/shared.ts | 319 ++++- 15 files changed, 4198 insertions(+), 51 deletions(-) create mode 100644 ai_evals/fixtures/frontend/global/hub/confluence.json create mode 100644 ai_evals/fixtures/frontend/global/hub/outreach.json create mode 100644 ai_evals/fixtures/frontend/global/hub/servicenow.json diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 90df0cf870..2f47e85be6 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -1,4 +1,7 @@ import { randomUUID } from 'node:crypto' +import confluenceFixture from '../../fixtures/frontend/global/hub/confluence.json' +import servicenowFixture from '../../fixtures/frontend/global/hub/servicenow.json' +import outreachFixture from '../../fixtures/frontend/global/hub/outreach.json' import type { AppWithLastVersion, CompletedJob, @@ -250,9 +253,7 @@ export function listBenchmarkApps(workspace: string): ListableApp[] | null { } export function getBenchmarkAppByPath(workspace: string, path: string): AppWithLastVersion | null { - const app = benchmarkWorkspaceRunnables - .get(workspace) - ?.apps?.find((entry) => entry.path === path) + const app = benchmarkWorkspaceRunnables.get(workspace)?.apps?.find((entry) => entry.path === path) return app ? buildBenchmarkApp(app) : null } @@ -448,7 +449,10 @@ export function createBenchmarkCompletedJob(input: { labels: input.label ? [input.label] : undefined } - benchmarkJobs.set(benchmarkJobKey(input.workspace, jobId), { workspace: input.workspace, job }) + benchmarkJobs.set(benchmarkJobKey(input.workspace, jobId), { + workspace: input.workspace, + job + }) return jobId } @@ -532,7 +536,13 @@ export function getBenchmarkFlowAllResults(workspace: string, jobId: string) { */ const benchmarkDrafts = new Map< string, - { workspace: string; kind: UserDraftItemKind; path: string; value: unknown; createdAt: string } + { + workspace: string + kind: UserDraftItemKind + path: string + value: unknown + createdAt: string + } >() // Counter-based timestamps: deterministic run-to-run (same event order → same @@ -614,7 +624,9 @@ export function getBenchmarkDraftForUser(input: { }): GetDraftForUserResponse { const entry = benchmarkDrafts.get(benchmarkDraftKey(input.workspace, input.kind, input.path)) if (!entry) { - throw Object.assign(new Error(`no draft for "${input.path}"`), { status: 404 }) + throw Object.assign(new Error(`no draft for "${input.path}"`), { + status: 404 + }) } return { value: entry.value, created_at: entry.createdAt } } @@ -635,7 +647,11 @@ export function getBenchmarkOwnDraft(input: { /** Whether a deployed benchmark item exists for a draft row's kind+path — * drives `draft_only`, which production computes against the deployed tables. */ -function benchmarkDeployedExists(workspace: string, kind: UserDraftItemKind, path: string): boolean { +function benchmarkDeployedExists( + workspace: string, + kind: UserDraftItemKind, + path: string +): boolean { if (kind === 'script') return Boolean(getBenchmarkScriptByPath(workspace, path)) if (kind === 'flow') return Boolean(getBenchmarkFlowByPath(workspace, path)) if (kind === 'app' || kind === 'raw_app') return Boolean(getBenchmarkAppByPath(workspace, path)) @@ -762,10 +778,11 @@ export function runBenchmarkDatatableSql(input: { * shape `pollJobResult` consumes. The job is created synchronously before * polling, so it is always present and completed. */ -export function getBenchmarkCompletedJobResultMaybe(input: { - workspace: string - id: string -}): { success: boolean; completed: boolean; result: unknown } { +export function getBenchmarkCompletedJobResultMaybe(input: { workspace: string; id: string }): { + success: boolean + completed: boolean + result: unknown +} { const job = getBenchmarkCompletedJob(input.workspace, input.id) if (!job) { throw new Error(`Job "${input.id}" not found in benchmark workspace`) @@ -896,7 +913,9 @@ export function previewBenchmarkSchedule(input: { }): Record { const schedule = input.requestBody?.schedule if (typeof schedule !== 'string' || schedule.trim().split(/\s+/).length !== 6) { - throw new Error(`schedule must use a six-field cron expression, got ${JSON.stringify(schedule)}`) + throw new Error( + `schedule must use a six-field cron expression, got ${JSON.stringify(schedule)}` + ) } return { @@ -928,7 +947,9 @@ export function createBenchmarkHttpTrigger(input: { typeof input.requestBody.route_path === 'string' && input.requestBody.route_path.startsWith('/') ) { - throw new Error(`HTTP trigger route_path must not start with /, got "${input.requestBody.route_path}"`) + throw new Error( + `HTTP trigger route_path must not start with /, got "${input.requestBody.route_path}"` + ) } return { path: input.requestBody.path, @@ -1024,7 +1045,39 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion { * hub tools throw and no case can exercise hub reuse. Serving fixtures rather * than the live hub also keeps assertions on script content stable as the real * hub republishes new versions. */ +const BENCHMARK_INTEGRATION_META_PATH = /^\/api\/integrations\/hub\/([^/]+)\/meta$/ + +/** One integration lifted verbatim from the content repo — its shipped scripts, its + * authored meta.json and its resource type. Hand-written fixtures make both routes to + * an integration's conventions look equally cheap; a real one is the only way to tell + * whether reading the metadata beats reading a script. */ +const REAL_HUB_INTEGRATIONS = [ + confluenceFixture, + servicenowFixture, + outreachFixture +] as unknown as Array<{ + app: string + display_name: string + description: string + docs_url: string + curated: boolean | null + meta: unknown + resource_type: { name: string; description: string; schema: unknown } + scripts: Array<{ + version_id: number + app: string + summary: string + description: string + terms: string + kind: string + language: string + content: string + schema: unknown + }> +}> + const BENCHMARK_HUB_SCRIPTS = [ + ...REAL_HUB_INTEGRATIONS.flatMap((integration) => integration.scripts), { version_id: 22235, app: 'holded', @@ -1112,6 +1165,87 @@ export async function main(discord_webhook: DiscordWebhook, message: string) { message: { type: 'string' } } } + }, + // Baremetrics carries no annotation script, on purpose: it stands for an + // integration whose conventions (an `apiKey` resource, bearer auth, the /v1 + // base) are only learnable by reading a script that does something else. + { + version_id: 8995, + app: 'baremetrics', + summary: 'List Sources', + description: 'List all the sources attached to a Baremetrics account.', + terms: 'baremetrics sources list revenue metrics', + language: 'bunnative', + content: `//native +type Baremetrics = { + apiKey: string; +}; +export async function main(resource: Baremetrics) { + const response = await fetch("https://api.baremetrics.com/v1/sources", { + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: \`Bearer \${resource.apiKey}\`, + }, + }); + if (!response.ok) { + throw new Error(\`\${response.status} \${await response.text()}\`); + } + return await response.json(); +} +`, + schema: { + type: 'object', + required: ['resource'], + properties: { + resource: { type: 'object', format: 'resource-baremetrics' } + } + } + }, + { + version_id: 8996, + app: 'baremetrics', + summary: 'Create Customer', + description: 'Create a customer on a Baremetrics source.', + terms: 'baremetrics customer create source', + language: 'bunnative', + content: `//native +type Baremetrics = { + apiKey: string; +}; +export async function main( + resource: Baremetrics, + sourceId: string, + body: { oid: string; name?: string; email?: string }, +) { + const response = await fetch( + \`https://api.baremetrics.com/v1/\${sourceId}/customers\`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: \`Bearer \${resource.apiKey}\`, + }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + throw new Error(\`\${response.status} \${await response.text()}\`); + } + return await response.json(); +} +`, + schema: { + type: 'object', + required: ['resource', 'sourceId', 'body'], + properties: { + resource: { type: 'object', format: 'resource-baremetrics' }, + sourceId: { type: 'string' }, + body: { type: 'object' } + } + } } ] @@ -1121,20 +1255,26 @@ export async function main(discord_webhook: DiscordWebhook, message: string) { * integration, or overlapping on three meaningful words. A looser bar answers * "send a Slack message" with the Discord fixture, handing an unrelated case a * plausible-looking wrong integration. */ -function searchBenchmarkHubScripts(text: string) { +function searchBenchmarkHubScripts(text: string, app: string | null, kind: string | null) { const tokens = new Set( text .toLowerCase() .split(/[^a-z0-9]+/) .filter((token) => token.length > 2) ) - return BENCHMARK_HUB_SCRIPTS.map((script) => { - const words = new Set( - `${script.app} ${script.summary} ${script.terms}`.toLowerCase().split(/[^a-z0-9]+/) - ) - const score = [...tokens].filter((token) => words.has(token)).length - return { script, score, namesApp: tokens.has(script.app) } - }) + // The real endpoint filters by app and kind before ranking, so honour both here — + // otherwise a narrowed search silently returns other integrations' scripts, and a + // trigger answers a search production would never have shown it to. + return BENCHMARK_HUB_SCRIPTS.filter( + (script) => (!app || script.app === app) && (!kind || (script.kind ?? 'script') === kind) + ) + .map((script) => { + const words = new Set( + `${script.app} ${script.summary} ${script.terms}`.toLowerCase().split(/[^a-z0-9]+/) + ) + const score = [...tokens].filter((token) => words.has(token)).length + return { script, score, namesApp: tokens.has(script.app) } + }) .filter((entry) => entry.namesApp || entry.score >= 3) .sort((a, b) => b.score - a.score) .map(({ script }, index) => ({ @@ -1142,12 +1282,104 @@ function searchBenchmarkHubScripts(text: string) { id: script.version_id, version_id: script.version_id, summary: script.summary, + description: script.description ?? null, app: script.app, - kind: 'script', + kind: script.kind ?? 'script', score: 1 - index * 0.01 })) } +/** Listing an integration is unranked and description-bearing, matching the hub's + * top-scripts endpoint — that asymmetry with the semantic search is the whole + * reason the chat browses by app when no script matches the task. */ +function listBenchmarkHubScriptsByApp(app: string | null, kind: string | null) { + return BENCHMARK_HUB_SCRIPTS.filter( + (script) => (!app || script.app === app) && (!kind || (script.kind ?? 'script') === kind) + ).map((script) => ({ + id: script.version_id, + ask_id: script.version_id, + version_id: script.version_id, + summary: script.summary, + description: script.description ?? null, + app: script.app, + kind: script.kind ?? 'script', + views: 0, + votes: 0 + })) +} + +/** What `/integrations/hub//meta` serves. Carries the integration's conventions + * and none of the endpoints a case asks the model to write, so the tool shortens the + * path to that knowledge without supplying answers. */ +/** The integrations a case writes against, none of which the content repo documents — + * the same majority shape as the live hub, where 18 of ~216 carry a meta.json. Their + * conventions live in their shipped scripts, which is what these cases exercise. */ +const BENCHMARK_HUB_INTEGRATION_META: Record< + string, + { + display_name: string + description: string + docs_url: string + curated: boolean | null + meta: unknown + } +> = { + baremetrics: { + display_name: 'Baremetrics', + description: 'Subscription analytics for recurring-revenue businesses.', + docs_url: 'https://developers.baremetrics.com/reference', + curated: null, + meta: null + }, + holded: { + display_name: 'Holded', + description: 'Invoicing, accounting and CRM for small businesses.', + docs_url: 'https://developers.holded.com/reference', + curated: null, + meta: null + } +} + +/** Mirrors the hub's own derivation: hosts seen in the shipped scripts, whether they + * call the provider directly, and how many there are of each kind. */ +function benchmarkDerivedFacts(app: string) { + const scripts = BENCHMARK_HUB_SCRIPTS.filter((script) => script.app === app) + const hosts = new Map() + const languages: Record = {} + for (const script of scripts) { + languages[script.language] = (languages[script.language] ?? 0) + 1 + for (const match of script.content.matchAll(/https?:\/\/([a-zA-Z0-9._-]+)/g)) { + hosts.set(match[1], (hosts.get(match[1]) ?? 0) + 1) + } + } + return { + api_hosts: [...hosts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([host, count]) => ({ host, count })), + style: 'fetch', + languages, + script_counts: { + total: scripts.length, + by_kind: scripts.reduce>((acc, script) => { + const kind = script.kind ?? 'script' + acc[kind] = (acc[kind] ?? 0) + 1 + return acc + }, {}) + }, + top_scripts: scripts.map((script) => ({ + path: `hub/${script.version_id}/${script.app}/${script.summary.toLowerCase().replaceAll(/\s+/g, '_')}`, + ask_id: script.version_id, + version_id: script.version_id, + summary: script.summary, + description: script.description ?? null, + kind: script.kind ?? 'script', + language: script.language, + views: 0, + votes: 0 + })) + } +} + /** The hub keys a script by its version id; the app and slug segments that * follow are descriptive, so match on the id exactly as the real hub does. */ function getBenchmarkHubScript(path: string) { @@ -1190,6 +1422,9 @@ export function hasBenchmarkApiHandler(url: string): boolean { return ( path === '/api/workers/list' || path === '/api/embeddings/query_hub_scripts' || + path === '/api/scripts/hub/top' || + path === '/api/integrations/hub/list' || + BENCHMARK_INTEGRATION_META_PATH.test(path) || path.startsWith('/api/scripts/hub/get_full/') || BENCHMARK_AI_MODELS_PATH.test(path) ) @@ -1213,8 +1448,81 @@ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Respon return Response.json({ data: (seed?.models ?? []).map((id) => ({ id })) }) } if (path === '/api/embeddings/query_hub_scripts') { - const text = new URLSearchParams(url.split('?')[1] ?? '').get('text') ?? '' - return Response.json(searchBenchmarkHubScripts(text)) + const params = new URLSearchParams(url.split('?')[1] ?? '') + return Response.json( + searchBenchmarkHubScripts(params.get('text') ?? '', params.get('app'), params.get('kind')) + ) + } + if (path === '/api/scripts/hub/top') { + const params = new URLSearchParams(url.split('?')[1] ?? '') + return Response.json({ + asks: listBenchmarkHubScriptsByApp(params.get('app'), params.get('kind')) + }) + } + const integrationMeta = BENCHMARK_INTEGRATION_META_PATH.exec(path) + if (integrationMeta) { + const app = decodeURIComponent(integrationMeta[1]) + const real = REAL_HUB_INTEGRATIONS.find((integration) => integration.app === app) + if (real) { + return Response.json({ + app, + display_name: real.display_name, + description: real.description, + docs_url: real.docs_url, + curated: real.curated, + metadata_source: real.meta ? 'curated' : 'derived', + meta: real.meta, + meta_updated_at: null, + derived: benchmarkDerivedFacts(app), + resource_types: [{ id: 1, ...real.resource_type }] + }) + } + const entry = BENCHMARK_HUB_INTEGRATION_META[app] + if (!entry) { + return Response.json({ error: 'integration not found' }, { status: 404 }) + } + return Response.json({ + app, + display_name: entry.display_name, + description: entry.description, + docs_url: entry.docs_url, + curated: entry.curated, + metadata_source: entry.meta ? 'curated' : 'derived', + meta: entry.meta, + meta_updated_at: null, + derived: benchmarkDerivedFacts(app), + resource_types: [ + { + id: 1, + name: app, + description: `${entry.display_name} credentials`, + schema: { + type: 'object', + required: ['apiKey'], + properties: { + apiKey: { + type: 'string', + description: `${entry.display_name} API key` + } + } + } + } + ] + }) + } + if (path === '/api/integrations/hub/list') { + const apps = [...new Set(BENCHMARK_HUB_SCRIPTS.map((script) => script.app))].sort() + // Read off both fixtures the metadata endpoint serves from, or the list would + // call an integration undocumented and then hand back its authored notes. + const documented = new Set([ + ...REAL_HUB_INTEGRATIONS.filter((integration) => integration.meta).map( + (integration) => integration.app + ), + ...Object.entries(BENCHMARK_HUB_INTEGRATION_META) + .filter(([, entry]) => entry.meta) + .map(([app]) => app) + ]) + return Response.json(apps.map((name) => ({ name, documented: documented.has(name) }))) } if (path.startsWith('/api/scripts/hub/get_full/')) { const script = getBenchmarkHubScript(path) diff --git a/ai_evals/adapters/frontend/mockBackendApi.test.ts b/ai_evals/adapters/frontend/mockBackendApi.test.ts index 85bd1f3320..b3829e56da 100644 --- a/ai_evals/adapters/frontend/mockBackendApi.test.ts +++ b/ai_evals/adapters/frontend/mockBackendApi.test.ts @@ -16,3 +16,21 @@ describe('benchmark API fetch handlers', () => { expect(body[0]).toMatchObject({ worker: expect.any(String), worker_group: expect.any(String) }) }) }) + +// A benchmark whose list calls an integration undocumented while its metadata endpoint +// hands back authored notes teaches the model the flag means nothing. +describe('benchmark hub integration list', () => { + it('flags exactly the integrations whose metadata carries authored notes', async () => { + const listed = (await handleBenchmarkApiFetch('/api/integrations/hub/list').json()) as Array<{ + name: string + documented: boolean + }> + expect(listed.length).toBeGreaterThan(0) + + for (const { name, documented } of listed) { + const res = handleBenchmarkApiFetch(`/api/integrations/hub/${name}/meta`) + const authored = res.status === 200 && !!((await res.json()) as { meta?: unknown }).meta + expect(authored).toBe(documented) + } + }) +}) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 96aa3ab983..476218a066 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -2312,6 +2312,105 @@ - email skipJudge: true +# `List Pages` takes a cursor without ever following one, so where the next cursor +# comes from (`_links.next`) is stated in the authored metadata and in no script body. +# Paginating is therefore the one task where reading scripts is not an equal route. +- id: global-hub4-confluence-paginate-all-pages + prompt: |- + I need a script that returns every page in one of my Confluence spaces, not just the + first batch — it should keep fetching until there are no more. + Save it as a draft at `f/evals/global/confluence_all_pages` and leave it as an AI + draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 14 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/confluence_all_pages + valueIncludes: + - /wiki/ + - Basic + toolExpect: + requiredToolsUsed: + - write_script + # Either route to the integration's conventions is fine; the checklist is where the + # difference between them has to show up. + requiredToolsAnyOf: + - - search_hub_scripts + - get_hub_integration + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the script loops until the API stops returning a next link, rather than making a single request + - the next cursor is read from the response's `_links.next` or the Link header, not from an invented field such as `next_cursor`, `nextPageToken` or `offset` + - it authenticates with HTTP Basic built from the Confluence resource's email and API token rather than a bearer token + - the script takes a Confluence resource as an input parameter rather than loose credential strings + - the result stays an AI draft and is not deployed + +# Unlike the Confluence case, both facts this needs — the {"result"} envelope and +# PATCH-not-PUT — are visible in ServiceNow's own shipped scripts. That makes it the +# control for whether the metadata earns its place when the scripts already answer. +- id: global-hub5-servicenow-assign-incident + prompt: |- + Write me a script that assigns an existing ServiceNow incident to a person, looking + that person up by their email address. + Save it as a draft at `f/evals/global/servicenow_assign_incident` and leave it as an + AI draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 14 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/servicenow_assign_incident + toolExpect: + requiredToolsUsed: + - write_script + requiredToolsAnyOf: + - - search_hub_scripts + - get_hub_integration + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - it looks the person up in the sys_user table by email rather than assuming a sys_id was supplied + - it reads records out of the `result` envelope the ServiceNow API wraps responses in + - it sets assigned_to to the user's sys_id rather than to the raw email address + - it updates the incident with a partial update (PATCH) rather than a full replace that would clear the fields it did not send + - the result stays an AI draft and is not deployed + +# Outreach is JSON:API. Its content type and payload envelope are both stated in the +# metadata and visible in the shipped update scripts, so this is a second control. +- id: global-hub6-outreach-reschedule-task + prompt: |- + Write me a script that moves an existing Outreach task to a new due date. + Save it as a draft at `f/evals/global/outreach_reschedule_task` and leave it as an AI + draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 14 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/outreach_reschedule_task + toolExpect: + requiredToolsUsed: + - write_script + requiredToolsAnyOf: + - - search_hub_scripts + - get_hub_integration + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the request sends the JSON:API content type application/vnd.api+json for its body + - the payload wraps the change as data.type and data.attributes rather than sending bare top-level fields + - it identifies the task by id inside the payload rather than only in the URL, and treats the id as a number + - it updates the existing task with PATCH rather than creating a new one + - the result stays an AI draft and is not deployed + # --- Windmill Hub reuse (search_hub_scripts + read_workspace_item on a hub/ path) --- # Holded's API is obscure enough that a model writing from memory cannot reproduce # its endpoint and `key` auth header — so the draft's fidelity to the published @@ -2358,6 +2457,78 @@ - the document type, document id, and recipient emails are inputs to the script - the result stays an AI draft and is not deployed +# Same task as global-hub1 with the hub never mentioned: this is what tells us the +# model reaches for the hub on its own rather than only when pointed at it. +- id: global-hub2-checks-hub-unprompted + prompt: |- + I want to email one of my Holded invoices to a customer from Windmill. + Save it as a draft script at `f/evals/global/holded_send_document` and leave it as + an AI draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 12 + validate: + draftCountExactly: 1 + requiredDrafts: + # No language assertion: the Holded script's body is `//native`, so a faithful + # fork lands on bun or bunnative depending on how literally it is copied. + - type: script + path: f/evals/global/holded_send_document + valueIncludes: + - api.holded.com/api/invoicing/v1/documents + - mailTemplateId + toolExpect: + requiredToolsUsed: + - search_hub_scripts + - write_script + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the draft sends an existing Holded document by email rather than creating one + - the request targets Holded's document send endpoint, not an invented URL + - authentication uses Holded's own key header rather than a bearer token + - the result stays an AI draft and is not deployed + +# The fixture hub carries two Baremetrics scripts, neither for annotations, so nothing +# does what is asked. Its conventions — an `apiKey` resource, bearer auth, the +# api.baremetrics.com/v1 base — are only knowable by reading one of those near-misses. +- id: global-hub3-integration-as-example + prompt: |- + Write me a script that adds an annotation to my Baremetrics account. + Save it as a draft at `f/evals/global/baremetrics_create_annotation` and leave it as + an AI draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 14 + validate: + draftCountExactly: 1 + requiredDrafts: + # No language assertion: matching the hub script's own dialect is the point, + # and Baremetrics' scripts are `//native`, so both bun and bunnative are right. + - type: script + path: f/evals/global/baremetrics_create_annotation + valueIncludes: + - api.baremetrics.com/v1 + - apiKey + toolExpect: + requiredToolsUsed: + - search_hub_scripts + - write_script + # Reading a near-miss script and reading the integration's own metadata are two + # routes to the same conventions, so requiring either keeps the case about the + # outcome rather than about which route the model took. + requiredToolsAnyOf: + - - read_workspace_item + - get_hub_integration + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the script takes a Baremetrics resource as an input parameter rather than a bare API key string + - the resource type it declares carries an apiKey field, matching how other Baremetrics hub scripts take credentials + - the request authenticates with a bearer token built from that resource + - the script posts an annotation rather than reading data back + - the result stays an AI draft and is not deployed + # The value of a secret variable is unreadable, so a metadata-only edit must leave # it untouched: passing any `value` here means inventing one, which silently # replaces the real secret at deploy. diff --git a/ai_evals/fixtures/frontend/global/hub/confluence.json b/ai_evals/fixtures/frontend/global/hub/confluence.json new file mode 100644 index 0000000000..29b5c0f11a --- /dev/null +++ b/ai_evals/fixtures/frontend/global/hub/confluence.json @@ -0,0 +1,983 @@ +{ + "app": "confluence", + "display_name": "Confluence", + "description": "Atlassian's team workspace: pages, blog posts and spaces.", + "docs_url": "https://developer.atlassian.com/cloud/confluence/rest/v2/intro/", + "curated": true, + "meta": { + "api_docs": "https://developer.atlassian.com/cloud/confluence/rest/v2/intro/", + "shipped_surfaces": [ + "pages", + "blogposts", + "spaces", + "search", + "users" + ], + "validation": { + "status": "live_validated", + "method": "smoke test via `bun --env-file=.env.test -e` (+ `bun test` with the windmill-client fake for the trigger) against a free Confluence Cloud site (a test site) with an API token. All 13 actions + the trigger returned 2xx: full page lifecycle (create -> get -> update -> list -> delete), blog post create/update/delete, list_spaces, search_content (CQL), get_current_user, and the new_or_updated_page polling trigger.", + "sources": { + "pages": "HIGH \u2014 Pipedream actions + official v2 OpenAPI; create/get/update/list/delete all run live (2xx)", + "blogposts": "HIGH \u2014 Pipedream actions + official v2 OpenAPI; create/update/delete all run live (2xx)", + "spaces": "HIGH \u2014 Pipedream list-spaces + official v2 OpenAPI; list_spaces run live (2xx)", + "search": "HIGH \u2014 Pipedream search-content + official v1 REST; search_content (CQL) run live (2xx)", + "users": "HIGH \u2014 v1 /wiki/rest/api/user/current run live (2xx) with Basic auth", + "trigger": "HIGH \u2014 new_or_updated_page e2e-tested under bun test with the auto-fake; first run sets the watermark, a subsequent run emits a freshly created page" + }, + "low_confidence_fields": [] + }, + "pagination": { + "pattern": "cursor", + "request_params": { + "cursor": "cursor", + "limit": "limit" + }, + "response_fields": { + "next": "_links.next" + }, + "default_limit": 25, + "notes": "v2 endpoints return an opaque cursor inside the `_links.next` relative URL (?cursor=...) and also in the Link response header. The v1 /search endpoint paginates the same way." + }, + "gotchas": [ + "Two API versions are in play: pages / blog posts / spaces use the v2 REST API (/wiki/api/v2); CQL search and current-user use the v1 REST API (/wiki/rest/api).", + "Auth is HTTP Basic with the Atlassian account email as the username and an API token (NOT the account password) as the password.", + "v2 query params are kebab-case (body-format, created-date, get-draft, root-level); v2 JSON request-body fields are camelCase (spaceId, parentId).", + "Updating a page or blog post requires version.number = currentVersion + 1; the update_* scripts fetch the current version and increment it automatically.", + "spaceId / parentId in v2 request bodies are numeric IDs passed as strings, not space keys.", + "DELETE returns 204 with an empty body and trashes by default; pass purge=true to permanently remove an item that is already in the trash." + ], + "enums": { + "page_status": [ + "current", + "archived", + "deleted", + "trashed", + "draft", + "historical" + ], + "body_representation": [ + "storage", + "atlas_doc_format" + ], + "body_format_read": [ + "storage", + "atlas_doc_format", + "view", + "export_view", + "anonymous_export_view", + "styled_view", + "editor" + ], + "space_type": [ + "global", + "collaboration", + "knowledge_base", + "personal" + ], + "page_sort": [ + "id", + "-id", + "created-date", + "-created-date", + "modified-date", + "-modified-date", + "title", + "-title" + ] + }, + "errors": { + "401": "Authentication failed \u2014 verify the email + API token pair and that the token has not been revoked.", + "404": "Resource not found, or the authenticating account lacks permission to view it (Confluence often returns 404 rather than 403 for content the user cannot access)." + } + }, + "resource_type": { + "name": "confluence", + "description": "Confluence Cloud credentials. Authenticate with an Atlassian account email and an API token (HTTP Basic auth) against your site base URL.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "apiToken": { + "description": "API token for your Atlassian account. Create one at https://id.atlassian.com/manage-profile/security/api-tokens", + "type": "string" + }, + "baseUrl": { + "description": "Base URL of your Confluence Cloud site, e.g. https://your-domain.atlassian.net (no trailing slash, no /wiki).", + "type": "string" + }, + "email": { + "description": "Email of the Atlassian account used to authenticate.", + "type": "string" + } + }, + "required": [ + "email", + "apiToken", + "baseUrl" + ], + "type": "object" + } + }, + "scripts": [ + { + "version_id": 28765, + "app": "confluence", + "summary": "Create Blog Post", + "description": "Create a new blog post in a space.", + "terms": "confluence create blog post create a new blog post in a space.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Blog Post\n * Create a new blog post in a space. The body is supplied as a content string in the chosen representation (default \"storage\", i.e. Confluence storage HTML).\n */\nexport async function main(\n auth: RT.Confluence,\n space_id: string,\n title: string,\n body: string,\n status: \"current\" | \"draft\" | undefined,\n representation: \"storage\" | \"atlas_doc_format\" | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/blogposts`)\n\n const payload = {\n spaceId: space_id,\n status: status ?? \"current\",\n title,\n body: {\n representation: representation ?? \"storage\",\n value: body,\n },\n }\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(payload),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "representation": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "space_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "draft" + ], + "originalType": "enum", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "space_id", + "title", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28766, + "app": "confluence", + "summary": "Create Page", + "description": "Create a new page in a space.", + "terms": "confluence create page create a new page in a space.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Page\n * Create a new page in a space. The body is supplied as a content string in the chosen representation (default \"storage\", i.e. Confluence storage HTML).\n */\nexport async function main(\n auth: RT.Confluence,\n space_id: string,\n title: string,\n body: string,\n status: \"current\" | \"draft\" | undefined,\n parent_id: string | undefined,\n representation: \"storage\" | \"atlas_doc_format\" | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/pages`)\n\n const payload = {\n spaceId: space_id,\n status: status ?? \"current\",\n title,\n parentId: parent_id,\n body: {\n representation: representation ?? \"storage\",\n value: body,\n },\n }\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(payload),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "parent_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "representation": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "space_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "draft" + ], + "originalType": "enum", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "space_id", + "title", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28767, + "app": "confluence", + "summary": "Delete Blog Post", + "description": "Delete a blog post by its ID (moved to trash by default; purge to permanently remove).", + "terms": "confluence delete blog post delete a blog post by its id (moved to trash by default; purge to permanently remove).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Delete Blog Post\n * Delete a blog post by its ID. By default the post is moved to the trash; set purge to permanently remove an already-trashed post.\n */\nexport async function main(\n auth: RT.Confluence,\n blog_post_id: string,\n purge: boolean | undefined,\n draft: boolean | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/blogposts/${blog_post_id}`)\n if (purge !== undefined) url.searchParams.append(\"purge\", String(purge))\n if (draft !== undefined) url.searchParams.append(\"draft\", String(draft))\n\n const response = await fetch(url, {\n method: \"DELETE\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n if (response.status === 204) return { success: true }\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "blog_post_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "draft": { + "default": null, + "description": "", + "type": "boolean" + }, + "purge": { + "default": null, + "description": "", + "type": "boolean" + } + }, + "required": [ + "auth", + "blog_post_id" + ], + "type": "object" + } + }, + { + "version_id": 28768, + "app": "confluence", + "summary": "Delete Page", + "description": "Delete a page by its ID (moved to trash by default; purge to permanently remove).", + "terms": "confluence delete page delete a page by its id (moved to trash by default; purge to permanently remove).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Delete Page\n * Delete a page by its ID. By default the page is moved to the trash; set purge to permanently remove an already-trashed page.\n */\nexport async function main(\n auth: RT.Confluence,\n page_id: string,\n purge: boolean | undefined,\n draft: boolean | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/pages/${page_id}`)\n if (purge !== undefined) url.searchParams.append(\"purge\", String(purge))\n if (draft !== undefined) url.searchParams.append(\"draft\", String(draft))\n\n const response = await fetch(url, {\n method: \"DELETE\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n if (response.status === 204) return { success: true }\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "draft": { + "default": null, + "description": "", + "type": "boolean" + }, + "page_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "purge": { + "default": null, + "description": "", + "type": "boolean" + } + }, + "required": [ + "auth", + "page_id" + ], + "type": "object" + } + }, + { + "version_id": 28769, + "app": "confluence", + "summary": "Get Current User", + "description": "Returns the profile of the user that the API token authenticates as.", + "terms": "confluence get current user returns the profile of the user that the api token authenticates as.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Current User\n * Returns the profile of the user that the API token authenticates as (account ID, display name, email).\n */\nexport async function main(auth: RT.Confluence) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/rest/api/user/current`)\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28770, + "app": "confluence", + "summary": "Get Page by ID", + "description": "Retrieve a single page by its ID, optionally returning its body in a specific format or a previous version.", + "terms": "confluence get page by id retrieve a single page by its id, optionally returning its body in a specific format or a previous version.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Page by ID\n * Retrieve a single page by its ID, optionally returning its body in a specific format or a previous version.\n */\nexport async function main(\n auth: RT.Confluence,\n page_id: string,\n body_format:\n | \"storage\"\n | \"atlas_doc_format\"\n | \"view\"\n | \"export_view\"\n | \"anonymous_export_view\"\n | \"styled_view\"\n | \"editor\"\n | undefined,\n version: number | undefined,\n get_draft: boolean | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/pages/${page_id}`)\n if (body_format !== undefined)\n url.searchParams.append(\"body-format\", body_format)\n if (version !== undefined) url.searchParams.append(\"version\", String(version))\n if (get_draft !== undefined)\n url.searchParams.append(\"get-draft\", String(get_draft))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body_format": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format", + "view", + "export_view", + "anonymous_export_view", + "styled_view", + "editor" + ], + "originalType": "enum", + "type": "string" + }, + "get_draft": { + "default": null, + "description": "", + "type": "boolean" + }, + "page_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "version": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "page_id" + ], + "type": "object" + } + }, + { + "version_id": 28771, + "app": "confluence", + "summary": "Get Pages in Space", + "description": "Retrieve a paginated list of pages within a given space.", + "terms": "confluence get pages in space retrieve a paginated list of pages within a given space.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Pages in Space\n * Retrieve a paginated list of pages within a given space.\n */\nexport async function main(\n auth: RT.Confluence,\n space_id: string,\n status: (\"current\" | \"archived\" | \"deleted\" | \"trashed\")[] | undefined,\n title: string | undefined,\n sort:\n | \"id\"\n | \"-id\"\n | \"created-date\"\n | \"-created-date\"\n | \"modified-date\"\n | \"-modified-date\"\n | \"title\"\n | \"-title\"\n | undefined,\n body_format: \"storage\" | \"atlas_doc_format\" | undefined,\n cursor: string | undefined,\n limit: number | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/spaces/${space_id}/pages`)\n for (const s of status ?? []) url.searchParams.append(\"status\", s)\n if (title !== undefined && title !== \"\")\n url.searchParams.append(\"title\", title)\n if (sort !== undefined) url.searchParams.append(\"sort\", sort)\n if (body_format !== undefined)\n url.searchParams.append(\"body-format\", body_format)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body_format": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "enum": [ + "id", + "-id", + "created-date", + "-created-date", + "modified-date", + "-modified-date", + "title", + "-title" + ], + "originalType": "enum", + "type": "string" + }, + "space_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "items": { + "enum": [ + "current", + "archived", + "deleted", + "trashed" + ], + "type": "string" + }, + "originalType": "enum[]", + "type": "array" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "space_id" + ], + "type": "object" + } + }, + { + "version_id": 28772, + "app": "confluence", + "summary": "List Blog Posts", + "description": "Retrieve a paginated list of blog posts on the site, with optional filtering and sorting.", + "terms": "confluence list blog posts retrieve a paginated list of blog posts on the site, with optional filtering and sorting.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Blog Posts\n * Retrieve a paginated list of blog posts on the site, with optional filtering and sorting.\n */\nexport async function main(\n auth: RT.Confluence,\n status: (\"current\" | \"archived\" | \"deleted\" | \"trashed\")[] | undefined,\n title: string | undefined,\n sort:\n | \"id\"\n | \"-id\"\n | \"created-date\"\n | \"-created-date\"\n | \"modified-date\"\n | \"-modified-date\"\n | undefined,\n body_format: \"storage\" | \"atlas_doc_format\" | undefined,\n cursor: string | undefined,\n limit: number | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/blogposts`)\n for (const s of status ?? []) url.searchParams.append(\"status\", s)\n if (title !== undefined && title !== \"\")\n url.searchParams.append(\"title\", title)\n if (sort !== undefined) url.searchParams.append(\"sort\", sort)\n if (body_format !== undefined)\n url.searchParams.append(\"body-format\", body_format)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body_format": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "enum": [ + "id", + "-id", + "created-date", + "-created-date", + "modified-date", + "-modified-date" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "items": { + "enum": [ + "current", + "archived", + "deleted", + "trashed" + ], + "type": "string" + }, + "originalType": "enum[]", + "type": "array" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28773, + "app": "confluence", + "summary": "List Pages", + "description": "Retrieve a paginated list of pages on the site, with optional filtering and sorting.", + "terms": "confluence list pages retrieve a paginated list of pages on the site, with optional filtering and sorting.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Pages\n * Retrieve a paginated list of pages on the site, with optional filtering and sorting.\n */\nexport async function main(\n auth: RT.Confluence,\n status: (\"current\" | \"archived\" | \"deleted\" | \"trashed\")[] | undefined,\n title: string | undefined,\n sort:\n | \"id\"\n | \"-id\"\n | \"created-date\"\n | \"-created-date\"\n | \"modified-date\"\n | \"-modified-date\"\n | \"title\"\n | \"-title\"\n | undefined,\n body_format: \"storage\" | \"atlas_doc_format\" | undefined,\n cursor: string | undefined,\n limit: number | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/pages`)\n for (const s of status ?? []) url.searchParams.append(\"status\", s)\n if (title !== undefined && title !== \"\")\n url.searchParams.append(\"title\", title)\n if (sort !== undefined) url.searchParams.append(\"sort\", sort)\n if (body_format !== undefined)\n url.searchParams.append(\"body-format\", body_format)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body_format": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "enum": [ + "id", + "-id", + "created-date", + "-created-date", + "modified-date", + "-modified-date", + "title", + "-title" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "items": { + "enum": [ + "current", + "archived", + "deleted", + "trashed" + ], + "type": "string" + }, + "originalType": "enum[]", + "type": "array" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28774, + "app": "confluence", + "summary": "List Spaces", + "description": "Retrieve a paginated list of spaces on the site, with optional filtering by key, type, and status.", + "terms": "confluence list spaces retrieve a paginated list of spaces on the site, with optional filtering by key, type, and status.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Spaces\n * Retrieve a paginated list of spaces on the site, with optional filtering by key, type, and status.\n */\nexport async function main(\n auth: RT.Confluence,\n keys: string[] | undefined,\n type: \"global\" | \"collaboration\" | \"knowledge_base\" | \"personal\" | undefined,\n status: \"current\" | \"archived\" | undefined,\n sort: \"id\" | \"-id\" | \"key\" | \"-key\" | \"name\" | \"-name\" | undefined,\n cursor: string | undefined,\n limit: number | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/spaces`)\n if (keys !== undefined && keys.length > 0)\n url.searchParams.append(\"keys\", keys.join(\",\"))\n if (type !== undefined) url.searchParams.append(\"type\", type)\n if (status !== undefined) url.searchParams.append(\"status\", status)\n if (sort !== undefined) url.searchParams.append(\"sort\", sort)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "keys": { + "default": null, + "description": "", + "items": { + "type": "string" + }, + "originalType": "string[]", + "type": "array" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "enum": [ + "id", + "-id", + "key", + "-key", + "name", + "-name" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "archived" + ], + "originalType": "enum", + "type": "string" + }, + "type": { + "default": null, + "description": "", + "enum": [ + "global", + "collaboration", + "knowledge_base", + "personal" + ], + "originalType": "enum", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28775, + "app": "confluence", + "summary": "Search Content (CQL)", + "description": "Search for content using the Confluence Query Language (CQL).", + "terms": "confluence search content (cql) search for content using the confluence query language (cql).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Search Content (CQL)\n * Search for content using the Confluence Query Language (CQL), e.g. `type=page AND space=ENG AND text ~ \"release notes\"`.\n */\nexport async function main(\n auth: RT.Confluence,\n cql: string,\n cursor: string | undefined,\n limit: number | undefined,\n expand: string | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/rest/api/search`)\n url.searchParams.append(\"cql\", cql)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n if (expand !== undefined && expand !== \"\")\n url.searchParams.append(\"expand\", expand)\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "cql": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "expand": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "cql" + ], + "type": "object" + } + }, + { + "version_id": 28776, + "app": "confluence", + "summary": "Update Blog Post", + "description": "Update a blog post's title, body, or status. The current version is fetched automatically and incremented.", + "terms": "confluence update blog post update a blog post's title, body, or status. the current version is fetched automatically and incremented.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Update Blog Post\n * Update a blog post's title, body, or status. The current version is fetched automatically and incremented; omitted fields keep their existing values.\n */\nexport async function main(\n auth: RT.Confluence,\n blog_post_id: string,\n title: string | undefined,\n body: string | undefined,\n status: \"current\" | \"draft\" | \"archived\" | undefined,\n representation: \"storage\" | \"atlas_doc_format\" | undefined,\n version_message: string | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const repr = representation ?? \"storage\"\n const authHeader = \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`)\n\n // Fetch the current blog post to obtain the version number and any fields not being changed.\n const currentRes = await fetch(\n `${base}/wiki/api/v2/blogposts/${blog_post_id}?body-format=${repr}`,\n {\n method: \"GET\",\n headers: { Authorization: authHeader, Accept: \"application/json\" },\n }\n )\n if (!currentRes.ok) {\n throw new Error(`${currentRes.status} ${await currentRes.text()}`)\n }\n const current = (await currentRes.json()) as {\n title: string\n status: string\n version: { number: number }\n body?: { [key: string]: { value?: string } }\n }\n\n const payload = {\n id: blog_post_id,\n status: status ?? current.status,\n title: title ?? current.title,\n body: {\n representation: repr,\n value: body ?? current.body?.[repr]?.value ?? \"\",\n },\n version: {\n number: current.version.number + 1,\n message: version_message,\n },\n }\n\n const response = await fetch(\n `${base}/wiki/api/v2/blogposts/${blog_post_id}`,\n {\n method: \"PUT\",\n headers: {\n Authorization: authHeader,\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(payload),\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "blog_post_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "body": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "representation": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "draft", + "archived" + ], + "originalType": "enum", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "version_message": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "blog_post_id" + ], + "type": "object" + } + }, + { + "version_id": 28777, + "app": "confluence", + "summary": "Update Page", + "description": "Update a page's title, body, or status. The current version is fetched automatically and incremented.", + "terms": "confluence update page update a page's title, body, or status. the current version is fetched automatically and incremented.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Update Page\n * Update a page's title, body, or status. The current version is fetched automatically and incremented; omitted fields keep their existing values.\n */\nexport async function main(\n auth: RT.Confluence,\n page_id: string,\n title: string | undefined,\n body: string | undefined,\n status: \"current\" | \"draft\" | \"archived\" | undefined,\n representation: \"storage\" | \"atlas_doc_format\" | undefined,\n version_message: string | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const repr = representation ?? \"storage\"\n const authHeader = \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`)\n\n // Fetch the current page to obtain the version number and any fields not being changed.\n const currentRes = await fetch(\n `${base}/wiki/api/v2/pages/${page_id}?body-format=${repr}`,\n {\n method: \"GET\",\n headers: { Authorization: authHeader, Accept: \"application/json\" },\n }\n )\n if (!currentRes.ok) {\n throw new Error(`${currentRes.status} ${await currentRes.text()}`)\n }\n const current = (await currentRes.json()) as {\n title: string\n status: string\n version: { number: number }\n body?: { [key: string]: { value?: string } }\n }\n\n const payload = {\n id: page_id,\n status: status ?? current.status,\n title: title ?? current.title,\n body: {\n representation: repr,\n value: body ?? current.body?.[repr]?.value ?? \"\",\n },\n version: {\n number: current.version.number + 1,\n message: version_message,\n },\n }\n\n const response = await fetch(`${base}/wiki/api/v2/pages/${page_id}`, {\n method: \"PUT\",\n headers: {\n Authorization: authHeader,\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(payload),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "representation": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "draft", + "archived" + ], + "originalType": "enum", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "version_message": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "page_id" + ], + "type": "object" + } + }, + { + "version_id": 28778, + "app": "confluence", + "summary": "New or Updated Page", + "description": "Emits pages created or updated since the last poll, tracked via Windmill state. Optionally restrict to a single space.", + "terms": "confluence new or updated page emits pages created or updated since the last poll, tracked via windmill state. optionally restrict to a single space.", + "kind": "trigger", + "language": "bunnative", + "content": "//native\n\nimport * as wmill from \"windmill-client\"\n\n/**\n * New or Updated Page\n * Emits pages created or updated since the last poll, tracked via Windmill state. Optionally restrict to a single space.\n */\nexport async function main(auth: RT.Confluence, space_id: string | undefined) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const lastChecked: number = (await wmill.getState()) ?? 0\n\n const path = space_id\n ? `/wiki/api/v2/spaces/${space_id}/pages`\n : `/wiki/api/v2/pages`\n const url = new URL(`${base}${path}`)\n url.searchParams.append(\"sort\", \"-modified-date\")\n url.searchParams.append(\"limit\", \"50\")\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n const data = (await response.json()) as {\n results?: { version?: { createdAt?: string } }[]\n }\n const pages = data.results ?? []\n\n const ts = (p: { version?: { createdAt?: string } }) =>\n p.version?.createdAt ? Date.parse(p.version.createdAt) : 0\n const maxTs = pages.reduce((m, p) => Math.max(m, ts(p)), 0)\n\n // First run: set the watermark to the newest page seen and don't emit a backlog.\n if (!lastChecked) {\n if (maxTs) await wmill.setState(maxTs)\n return []\n }\n\n const fresh = pages\n .filter((p) => ts(p) > lastChecked)\n .sort((a, b) => ts(a) - ts(b))\n\n if (maxTs > lastChecked) {\n await wmill.setState(maxTs)\n }\n\n return fresh\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "space_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + } + ] +} \ No newline at end of file diff --git a/ai_evals/fixtures/frontend/global/hub/outreach.json b/ai_evals/fixtures/frontend/global/hub/outreach.json new file mode 100644 index 0000000000..b63aad6849 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/hub/outreach.json @@ -0,0 +1,1055 @@ +{ + "app": "outreach", + "display_name": "Outreach", + "description": "Sales engagement: prospects, sequences and tasks.", + "docs_url": "https://developers.outreach.io/api/", + "curated": true, + "meta": { + "api_docs": "https://developers.outreach.io/api/", + "shipped_surfaces": [ + "prospects", + "accounts", + "sequences", + "sequence_states", + "mailings", + "tasks", + "mailboxes" + ], + "validation": { + "status": "not_live_validated", + "method": "static \u2014 shapes derived from the official OpenAPI spec (https://api.outreach.io/api/v2/schema/openapi.json) + Pipedream actions; filters/pagination cross-checked against the spec's per-relationship filter notes and Airbyte source-outreach's live incremental-sync params; smoke test blocked: Outreach has no self-serve signup (demo-request only) and the developer portal requires an existing org admin account. Production-connector cross-validation pass (2026-06): checked ActivePieces (no outreach piece), n8n (no Outreach node), Microsoft PowerPlatformConnectors (no Outreach connector); corroborated against estuary/connectors source-outreach (newFilterSyntax [gte]/[lte] datetime cursors, page[size]/page[after] from links.next), singer-io/tap-outreach a.k.a. Stitch (old-syntax filter[updatedAt]=..inf + sort + count=false, links.next pagination), Supaglue (supaglue-labs, via fork: old-syntax ..inf with millisecond ISO datetimes; JSON:API create bodies for account/prospect/sequenceState incl. emails[] attribute and prospect/sequence/mailbox relationships), and friggframework api-module-library (task create/update with polymorphic subject relationship, live-test fixture). No contradictions found; no script changes required.", + "sources": { + "prospects": "MEDIUM \u2014 official OpenAPI spec + Pipedream create-prospect action; create body (attributes incl. emails[], account relationship) corroborated by Supaglue's production mapper; list filters corroborated by the Stitch tap; not run live", + "accounts": "MEDIUM \u2014 official OpenAPI spec + Pipedream create-account action; create body (name/domain attributes, owner relationship) corroborated by Supaglue; list filters corroborated by the Stitch tap; not run live", + "sequences": "MEDIUM \u2014 official OpenAPI spec; reads corroborated by Supaglue (sequences/sequenceSteps) and the Stitch tap; not run live", + "sequence_states": "MEDIUM \u2014 official OpenAPI spec + Pipedream add-prospect-sequence action; create body (prospect/sequence/mailbox relationships, mailbox required) matches Supaglue's production mapper exactly; not run live", + "mailings": "MEDIUM \u2014 official OpenAPI spec; list + updatedAt filter corroborated by the Stitch tap and Estuary; not run live", + "tasks": "MEDIUM \u2014 official OpenAPI spec; polymorphic subject relationship on create/update corroborated by friggframework's live-test fixture; list filters corroborated by the Stitch tap; not run live", + "mailboxes": "MEDIUM \u2014 official OpenAPI spec + Pipedream mailbox options resolver; list corroborated by the Stitch tap; not run live" + }, + "low_confidence_fields": [] + }, + "pagination": { + "pattern": "cursor", + "request_params": { + "limit": "page[size]", + "cursor": "page[after]" + }, + "response_fields": { + "next": "links.next (full URL; the page[after] value is its query param)" + }, + "default_limit": 50 + }, + "gotchas": [ + "JSON:API \u2014 every request with a body must send Content-Type: application/vnd.api+json or the API returns 415.", + "Write payloads wrap fields as { data: { type, attributes, relationships } }; ids are integers.", + "Filters: filter[attr]=value; multi filter[id]=1,2,3; ranges filter[updatedAt]=2024-01-01..inf (full ISO datetimes work too \u2014 Stitch's tap and Supaglue both send filter[updatedAt]=..inf in production; range bounds are inclusive); relationship filters filter[prospect][id]=123 (only [id] is reliably supported \u2014 attribute-based relationship filters were deprecated May 2023); sort=-createdAt for descending.", + "New filter syntax (newFilterSyntax=true) replaces ranges with filter[attr][gte]/[lte] and value lists with filter[attr][]=v \u2014 required when values contain ',' or '..'; Airbyte's connector uses it for datetime cursors, so prefer it for timestamp comparisons.", + "Task\u2194prospect association is set via the polymorphic `subject` relationship (type account|opportunity|prospect); the `prospect` relationship on tasks is readonly/derived.", + "Action endpoints (POST \u2026/tasks/{id}/actions/markComplete, \u2026/sequenceStates/{id}/actions/{pause|resume|finish}) take no request body and return 200 with the updated resource.", + "OAuth access tokens last 2h; refresh is grant_type=refresh_token at the same https://api.outreach.io/oauth/token endpoint, so Windmill auto-refresh works.", + "Scopes are ., e.g. prospects.all sequenceStates.all. All scopes registered in oauth_connect.json are enumerated verbatim in Outreach's official SDK (getoutreach/extensibility-sdk, src/manifest/api/Scopes.ts)." + ], + "enums": { + "task_action": [ + "action_item", + "call", + "email", + "in_person" + ], + "task_state": [ + "pending", + "incomplete", + "complete" + ], + "mailing_state": [ + "bounced", + "delivered", + "delivering", + "drafted", + "failed", + "opened", + "placeholder", + "queued", + "replied", + "scheduled" + ] + }, + "errors": { + "415": "Missing/wrong Content-Type \u2014 JSON:API requires application/vnd.api+json on requests with a body.", + "502": "Bad Gateway on list endpoints usually means the requested page[size] was too large to serve in time \u2014 retry with a smaller page[size] (Estuary's connector halves it and retries).", + "422": "Validation error \u2014 response carries errors[] with title/detail and a source.pointer to the offending attribute." + } + }, + "resource_type": { + "name": "outreach", + "description": "OAuth2 access token for the Outreach REST API (api.outreach.io). Connect with OAuth so the token refreshes automatically \u2014 Outreach has no long-lived API keys.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "token": { + "description": "OAuth2 access token, filled automatically when you connect with OAuth (recommended \u2014 tokens are refreshed for you). Pasting a token minted from your own Outreach OAuth app (https://developers.outreach.io/api/oauth/) also works but it expires after 2 hours, so only do that for one-off testing.", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + } + }, + "scripts": [ + { + "version_id": 28686, + "app": "outreach", + "summary": "Add Prospect to Sequence", + "description": "Adds an existing prospect to a sequence, sending from the selected mailbox.", + "terms": "outreach add prospect to sequence adds an existing prospect to a sequence, sending from the selected mailbox.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_sequence = string\n\n// Resolver: lists the org's sequences for the dropdown.\nexport async function sequence(auth: RT.Outreach) {\n const url = new URL(\"https://api.outreach.io/api/v2/sequences\")\n url.searchParams.append(\"sort\", \"name\")\n url.searchParams.append(\"page[size]\", \"100\")\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n const { data } = (await response.json()) as {\n data: { id: number; attributes: { name: string } }[]\n }\n return data.map((s) => ({ value: String(s.id), label: s.attributes.name }))\n}\n\nexport type DynSelect_mailbox = string\n\n// Resolver: lists the org's sending mailboxes for the dropdown.\nexport async function mailbox(auth: RT.Outreach) {\n const url = new URL(\"https://api.outreach.io/api/v2/mailboxes\")\n url.searchParams.append(\"page[size]\", \"100\")\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n const { data } = (await response.json()) as {\n data: { id: number; attributes: { email: string } }[]\n }\n return data.map((m) => ({ value: String(m.id), label: m.attributes.email }))\n}\n\n/**\n * Add Prospect to Sequence\n * Adds an existing prospect to a sequence, sending from the selected mailbox.\n */\nexport async function main(\n auth: RT.Outreach,\n prospect_id: number,\n sequence: DynSelect_sequence,\n mailbox: DynSelect_mailbox\n) {\n const response = await fetch(\n \"https://api.outreach.io/api/v2/sequenceStates\",\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({\n data: {\n type: \"sequenceState\",\n relationships: {\n prospect: { data: { type: \"prospect\", id: prospect_id } },\n sequence: { data: { type: \"sequence\", id: Number(sequence) } },\n mailbox: { data: { type: \"mailbox\", id: Number(mailbox) } },\n },\n },\n }),\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "mailbox": { + "default": null, + "description": "", + "format": "dynselect-mailbox", + "type": "object" + }, + "prospect_id": { + "default": null, + "description": "", + "type": "number" + }, + "sequence": { + "default": null, + "description": "", + "format": "dynselect-sequence", + "type": "object" + } + }, + "required": [ + "auth", + "prospect_id", + "sequence", + "mailbox" + ], + "type": "object" + } + }, + { + "version_id": 28687, + "app": "outreach", + "summary": "Complete Task", + "description": "Marks a task as complete.", + "terms": "outreach complete task marks a task as complete.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Complete Task\n * Marks a task as complete.\n */\nexport async function main(auth: RT.Outreach, task_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/tasks/${task_id}/actions/markComplete`,\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "task_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "task_id" + ], + "type": "object" + } + }, + { + "version_id": 28688, + "app": "outreach", + "summary": "Create Account", + "description": "Creates a company account in Outreach.", + "terms": "outreach create account creates a company account in outreach.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Account\n * Creates a company account in Outreach.\n */\nexport async function main(\n auth: RT.Outreach,\n name: string,\n domain: string | undefined,\n additional_attributes: { [key: string]: any } | undefined\n) {\n const attributes: { [key: string]: any } = { ...additional_attributes, name }\n if (domain !== undefined && domain !== \"\") {\n attributes.domain = domain\n }\n\n const response = await fetch(\"https://api.outreach.io/api/v2/accounts\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({ data: { type: \"account\", attributes } }),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "additional_attributes": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "domain": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "name" + ], + "type": "object" + } + }, + { + "version_id": 28689, + "app": "outreach", + "summary": "Create Prospect", + "description": "Creates a new prospect in Outreach, optionally linked to an account.", + "terms": "outreach create prospect creates a new prospect in outreach, optionally linked to an account.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Prospect\n * Creates a new prospect in Outreach, optionally linked to an account.\n */\nexport async function main(\n auth: RT.Outreach,\n first_name: string | undefined,\n last_name: string | undefined,\n emails: string[] | undefined,\n title: string | undefined,\n company: string | undefined,\n account_id: number | undefined,\n additional_attributes: { [key: string]: any } | undefined\n) {\n const attributes: { [key: string]: any } = { ...additional_attributes }\n if (first_name !== undefined && first_name !== \"\") {\n attributes.firstName = first_name\n }\n if (last_name !== undefined && last_name !== \"\") {\n attributes.lastName = last_name\n }\n if (emails !== undefined && emails.length > 0) {\n attributes.emails = emails\n }\n if (title !== undefined && title !== \"\") {\n attributes.title = title\n }\n if (company !== undefined && company !== \"\") {\n attributes.company = company\n }\n\n const data: { [key: string]: any } = { type: \"prospect\", attributes }\n if (account_id !== undefined) {\n data.relationships = {\n account: { data: { type: \"account\", id: account_id } },\n }\n }\n\n const response = await fetch(\"https://api.outreach.io/api/v2/prospects\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({ data }),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "account_id": { + "default": null, + "description": "", + "type": "number" + }, + "additional_attributes": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "company": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "emails": { + "default": null, + "description": "", + "items": { + "type": "string" + }, + "originalType": "string[]", + "type": "array" + }, + "first_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "last_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28690, + "app": "outreach", + "summary": "Create Task", + "description": "Creates a manual task, optionally tied to a prospect and assigned to an owner.", + "terms": "outreach create task creates a manual task, optionally tied to a prospect and assigned to an owner.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Task\n * Creates a manual task, optionally tied to a prospect and assigned to an owner.\n */\nexport async function main(\n auth: RT.Outreach,\n action: \"action_item\" | \"call\" | \"email\" | \"in_person\",\n due_at: string,\n note: string | undefined,\n prospect_id: number | undefined,\n owner_id: number | undefined\n) {\n const attributes: { [key: string]: any } = { action, dueAt: due_at }\n if (note !== undefined && note !== \"\") {\n attributes.note = note\n }\n\n const relationships: { [key: string]: any } = {}\n if (prospect_id !== undefined) {\n // the writable association is `subject`; the `prospect` relationship is readonly\n relationships.subject = { data: { type: \"prospect\", id: prospect_id } }\n }\n if (owner_id !== undefined) {\n relationships.owner = { data: { type: \"user\", id: owner_id } }\n }\n\n const response = await fetch(\"https://api.outreach.io/api/v2/tasks\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({ data: { type: \"task\", attributes, relationships } }),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "action": { + "default": null, + "description": "", + "enum": [ + "action_item", + "call", + "email", + "in_person" + ], + "originalType": "enum", + "type": "string" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "due_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "note": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "owner_id": { + "default": null, + "description": "", + "type": "number" + }, + "prospect_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "action", + "due_at" + ], + "type": "object" + } + }, + { + "version_id": 28691, + "app": "outreach", + "summary": "Get Account", + "description": "Retrieves a single account by its ID.", + "terms": "outreach get account retrieves a single account by its id.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Account\n * Retrieves a single account by its ID.\n */\nexport async function main(auth: RT.Outreach, account_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/accounts/${account_id}`,\n {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "account_id": { + "default": null, + "description": "", + "type": "number" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + } + }, + "required": [ + "auth", + "account_id" + ], + "type": "object" + } + }, + { + "version_id": 28692, + "app": "outreach", + "summary": "Get Mailing", + "description": "Retrieves a single mailing by its ID, including engagement attributes.", + "terms": "outreach get mailing retrieves a single mailing by its id, including engagement attributes.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Mailing\n * Retrieves a single mailing by its ID, including engagement attributes (opens, clicks, replies).\n */\nexport async function main(auth: RT.Outreach, mailing_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/mailings/${mailing_id}`,\n {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "mailing_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "mailing_id" + ], + "type": "object" + } + }, + { + "version_id": 28693, + "app": "outreach", + "summary": "Get Prospect", + "description": "Retrieves a single prospect by its ID.", + "terms": "outreach get prospect retrieves a single prospect by its id.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Prospect\n * Retrieves a single prospect by its ID.\n */\nexport async function main(auth: RT.Outreach, prospect_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/prospects/${prospect_id}`,\n {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "prospect_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "prospect_id" + ], + "type": "object" + } + }, + { + "version_id": 28694, + "app": "outreach", + "summary": "Get Sequence", + "description": "Retrieves a single sequence by its ID.", + "terms": "outreach get sequence retrieves a single sequence by its id.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Sequence\n * Retrieves a single sequence by its ID.\n */\nexport async function main(auth: RT.Outreach, sequence_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/sequences/${sequence_id}`,\n {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "sequence_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "sequence_id" + ], + "type": "object" + } + }, + { + "version_id": 28695, + "app": "outreach", + "summary": "List Accounts", + "description": "Lists accounts with optional filters and cursor pagination.", + "terms": "outreach list accounts lists accounts with optional filters and cursor pagination.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Accounts\n * Lists accounts with optional filters (name, domain, updatedAt range like \"2024-01-01..inf\") and cursor pagination.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_name: string | undefined,\n filter_domain: string | undefined,\n filter_updated_at: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/accounts\")\n if (filter_name !== undefined && filter_name !== \"\") {\n url.searchParams.append(\"filter[name]\", filter_name)\n }\n if (filter_domain !== undefined && filter_domain !== \"\") {\n url.searchParams.append(\"filter[domain]\", filter_domain)\n }\n if (filter_updated_at !== undefined && filter_updated_at !== \"\") {\n url.searchParams.append(\"filter[updatedAt]\", filter_updated_at)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_domain": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_updated_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28696, + "app": "outreach", + "summary": "List Mailboxes", + "description": "Lists the org's sending mailboxes, optionally filtered by email address.", + "terms": "outreach list mailboxes lists the org's sending mailboxes, optionally filtered by email address.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Mailboxes\n * Lists the org's sending mailboxes, optionally filtered by email address.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_email: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/mailboxes\")\n if (filter_email !== undefined && filter_email !== \"\") {\n url.searchParams.append(\"filter[email]\", filter_email)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_email": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28697, + "app": "outreach", + "summary": "List Mailings", + "description": "Lists mailings (sent/scheduled emails), filterable by prospect, state or updatedAt range.", + "terms": "outreach list mailings lists mailings (sent/scheduled emails), filterable by prospect, state or updatedat range.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Mailings\n * Lists mailings (sent/scheduled emails), filterable by prospect, state or updatedAt range.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_prospect_id: number | undefined,\n filter_state:\n | (\n | \"bounced\"\n | \"delivered\"\n | \"delivering\"\n | \"drafted\"\n | \"failed\"\n | \"opened\"\n | \"placeholder\"\n | \"queued\"\n | \"replied\"\n | \"scheduled\"\n )\n | undefined,\n filter_updated_at: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/mailings\")\n if (filter_prospect_id !== undefined) {\n url.searchParams.append(\"filter[prospect][id]\", String(filter_prospect_id))\n }\n if (filter_state !== undefined) {\n url.searchParams.append(\"filter[state]\", filter_state)\n }\n if (filter_updated_at !== undefined && filter_updated_at !== \"\") {\n url.searchParams.append(\"filter[updatedAt]\", filter_updated_at)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_prospect_id": { + "default": null, + "description": "", + "type": "number" + }, + "filter_state": { + "default": null, + "description": "", + "enum": [ + "bounced", + "delivered", + "delivering", + "drafted", + "failed", + "opened", + "placeholder", + "queued", + "replied", + "scheduled" + ], + "originalType": "enum", + "type": "string" + }, + "filter_updated_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28698, + "app": "outreach", + "summary": "List Prospects", + "description": "Lists prospects with optional filters and cursor pagination.", + "terms": "outreach list prospects lists prospects with optional filters and cursor pagination.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Prospects\n * Lists prospects with optional filters (email, name, updatedAt range like \"2024-01-01..inf\") and cursor pagination.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_emails: string | undefined,\n filter_first_name: string | undefined,\n filter_last_name: string | undefined,\n filter_updated_at: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/prospects\")\n if (filter_emails !== undefined && filter_emails !== \"\") {\n url.searchParams.append(\"filter[emails]\", filter_emails)\n }\n if (filter_first_name !== undefined && filter_first_name !== \"\") {\n url.searchParams.append(\"filter[firstName]\", filter_first_name)\n }\n if (filter_last_name !== undefined && filter_last_name !== \"\") {\n url.searchParams.append(\"filter[lastName]\", filter_last_name)\n }\n if (filter_updated_at !== undefined && filter_updated_at !== \"\") {\n url.searchParams.append(\"filter[updatedAt]\", filter_updated_at)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_emails": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_first_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_last_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_updated_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28699, + "app": "outreach", + "summary": "List Sequence States", + "description": "Lists sequence states (prospect \u2194 sequence memberships), filterable by prospect, sequence or state.", + "terms": "outreach list sequence states lists sequence states (prospect \u2194 sequence memberships), filterable by prospect, sequence or state.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Sequence States\n * Lists sequence states (prospect \u2194 sequence memberships), filterable by prospect, sequence or state.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_prospect_id: number | undefined,\n filter_sequence_id: number | undefined,\n filter_state: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/sequenceStates\")\n if (filter_prospect_id !== undefined) {\n url.searchParams.append(\"filter[prospect][id]\", String(filter_prospect_id))\n }\n if (filter_sequence_id !== undefined) {\n url.searchParams.append(\"filter[sequence][id]\", String(filter_sequence_id))\n }\n if (filter_state !== undefined && filter_state !== \"\") {\n url.searchParams.append(\"filter[state]\", filter_state)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_prospect_id": { + "default": null, + "description": "", + "type": "number" + }, + "filter_sequence_id": { + "default": null, + "description": "", + "type": "number" + }, + "filter_state": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28700, + "app": "outreach", + "summary": "List Sequences", + "description": "Lists sequences with an optional name filter and cursor pagination.", + "terms": "outreach list sequences lists sequences with an optional name filter and cursor pagination.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Sequences\n * Lists sequences with an optional name filter and cursor pagination.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_name: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/sequences\")\n if (filter_name !== undefined && filter_name !== \"\") {\n url.searchParams.append(\"filter[name]\", filter_name)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28701, + "app": "outreach", + "summary": "List Tasks", + "description": "Lists tasks, filterable by state, task type, prospect or dueAt range.", + "terms": "outreach list tasks lists tasks, filterable by state, task type, prospect or dueat range.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Tasks\n * Lists tasks, filterable by state, task type, prospect or dueAt range (e.g. \"2024-01-01..inf\").\n */\nexport async function main(\n auth: RT.Outreach,\n filter_state: (\"pending\" | \"incomplete\" | \"complete\") | undefined,\n filter_task_type: string | undefined,\n filter_prospect_id: number | undefined,\n filter_due_at: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/tasks\")\n if (filter_state !== undefined) {\n url.searchParams.append(\"filter[state]\", filter_state)\n }\n if (filter_task_type !== undefined && filter_task_type !== \"\") {\n url.searchParams.append(\"filter[taskType]\", filter_task_type)\n }\n if (filter_prospect_id !== undefined) {\n url.searchParams.append(\"filter[prospect][id]\", String(filter_prospect_id))\n }\n if (filter_due_at !== undefined && filter_due_at !== \"\") {\n url.searchParams.append(\"filter[dueAt]\", filter_due_at)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_due_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_prospect_id": { + "default": null, + "description": "", + "type": "number" + }, + "filter_state": { + "default": null, + "description": "", + "enum": [ + "pending", + "incomplete", + "complete" + ], + "originalType": "enum", + "type": "string" + }, + "filter_task_type": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28702, + "app": "outreach", + "summary": "Pause, Resume or Finish Sequence State", + "description": "Performs a lifecycle action on a prospect's sequence state.", + "terms": "outreach pause, resume or finish sequence state performs a lifecycle action on a prospect's sequence state.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Pause, Resume or Finish Sequence State\n * Performs a lifecycle action on a prospect's sequence state.\n */\nexport async function main(\n auth: RT.Outreach,\n sequence_state_id: number,\n action: \"pause\" | \"resume\" | \"finish\"\n) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/sequenceStates/${sequence_state_id}/actions/${action}`,\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "action": { + "default": null, + "description": "", + "enum": [ + "pause", + "resume", + "finish" + ], + "originalType": "enum", + "type": "string" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "sequence_state_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "sequence_state_id", + "action" + ], + "type": "object" + } + }, + { + "version_id": 28703, + "app": "outreach", + "summary": "Update Account", + "description": "Updates attributes of an existing account.", + "terms": "outreach update account updates attributes of an existing account.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Update Account\n * Updates attributes of an existing account (e.g. { \"industry\": \"Manufacturing\" }).\n */\nexport async function main(\n auth: RT.Outreach,\n account_id: number,\n attributes: { [key: string]: any }\n) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/accounts/${account_id}`,\n {\n method: \"PATCH\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({\n data: { type: \"account\", id: account_id, attributes },\n }),\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "account_id": { + "default": null, + "description": "", + "type": "number" + }, + "attributes": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + } + }, + "required": [ + "auth", + "account_id", + "attributes" + ], + "type": "object" + } + }, + { + "version_id": 28704, + "app": "outreach", + "summary": "Update Prospect", + "description": "Updates attributes of an existing prospect.", + "terms": "outreach update prospect updates attributes of an existing prospect.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Update Prospect\n * Updates attributes of an existing prospect (e.g. { \"title\": \"VP Sales\", \"occupation\": \"Sales\" }).\n */\nexport async function main(\n auth: RT.Outreach,\n prospect_id: number,\n attributes: { [key: string]: any }\n) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/prospects/${prospect_id}`,\n {\n method: \"PATCH\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({\n data: { type: \"prospect\", id: prospect_id, attributes },\n }),\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "attributes": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "prospect_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "prospect_id", + "attributes" + ], + "type": "object" + } + }, + { + "version_id": 28705, + "app": "outreach", + "summary": "New Mailing", + "description": "Returns mailings created since the last run.", + "terms": "outreach new mailing returns mailings created since the last run.", + "kind": "trigger", + "language": "bunnative", + "content": "//native\n\nimport * as wmill from \"windmill-client\"\n\n/**\n * New Mailing\n * Returns mailings created since the last run (first run sets the watermark and returns nothing).\n */\nexport async function main(auth: RT.Outreach) {\n const lastChecked: string | undefined = await wmill.getState()\n\n const url = new URL(\"https://api.outreach.io/api/v2/mailings\")\n url.searchParams.append(\"sort\", \"-createdAt\")\n url.searchParams.append(\"page[size]\", \"100\")\n url.searchParams.append(\"count\", \"false\")\n if (lastChecked) {\n url.searchParams.append(\"newFilterSyntax\", \"true\")\n url.searchParams.append(\"filter[createdAt][gte]\", lastChecked)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n const { data } = (await response.json()) as {\n data: { id: number; attributes: { createdAt: string } }[]\n }\n\n const newItems = lastChecked\n ? data.filter((m) => m.attributes.createdAt > lastChecked)\n : []\n const newWatermark =\n data[0]?.attributes.createdAt ?? lastChecked ?? new Date().toISOString()\n await wmill.setState(newWatermark)\n\n return newItems\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28706, + "app": "outreach", + "summary": "New Task", + "description": "Returns tasks created since the last run.", + "terms": "outreach new task returns tasks created since the last run.", + "kind": "trigger", + "language": "bunnative", + "content": "//native\n\nimport * as wmill from \"windmill-client\"\n\n/**\n * New Task\n * Returns tasks created since the last run (first run sets the watermark and returns nothing).\n */\nexport async function main(auth: RT.Outreach) {\n const lastChecked: string | undefined = await wmill.getState()\n\n const url = new URL(\"https://api.outreach.io/api/v2/tasks\")\n url.searchParams.append(\"sort\", \"-createdAt\")\n url.searchParams.append(\"page[size]\", \"100\")\n url.searchParams.append(\"count\", \"false\")\n if (lastChecked) {\n url.searchParams.append(\"newFilterSyntax\", \"true\")\n url.searchParams.append(\"filter[createdAt][gte]\", lastChecked)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n const { data } = (await response.json()) as {\n data: { id: number; attributes: { createdAt: string } }[]\n }\n\n const newItems = lastChecked\n ? data.filter((t) => t.attributes.createdAt > lastChecked)\n : []\n const newWatermark =\n data[0]?.attributes.createdAt ?? lastChecked ?? new Date().toISOString()\n await wmill.setState(newWatermark)\n\n return newItems\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + } + ] +} \ No newline at end of file diff --git a/ai_evals/fixtures/frontend/global/hub/servicenow.json b/ai_evals/fixtures/frontend/global/hub/servicenow.json new file mode 100644 index 0000000000..02121cf8ea --- /dev/null +++ b/ai_evals/fixtures/frontend/global/hub/servicenow.json @@ -0,0 +1,672 @@ +{ + "app": "servicenow", + "display_name": "ServiceNow", + "description": "IT service management: incidents, records and attachments.", + "docs_url": "https://www.servicenow.com/docs/bundle/zurich-api-reference/page/integrate/inbound-rest/concept/c_RESTAPI.html", + "curated": true, + "meta": { + "api_docs": "https://www.servicenow.com/docs/bundle/zurich-api-reference/page/integrate/inbound-rest/concept/c_RESTAPI.html", + "shipped_surfaces": [ + "table", + "stats", + "attachment", + "import_set", + "user" + ], + "validation": { + "status": "live_validated", + "method": "All surfaces run live against a ServiceNow PDI (a developer instance, Zurich) on 2026-06-04 with Basic auth: Table CRUD (create / get / update PATCH+PUT / delete on incident, delete verified by 404), list_records (encoded query + fields + pagination), aggregate (Stats group-by count), get_current_user, list_tables, and the table/fields/group_by dynselect resolvers. Attachment upload (binary /file) -> list -> download -> delete round-trip with the downloaded bytes byte-for-byte matching the upload. insert_import_set + get_import_set_result against imp_user (transform status surfaced). Trigger e2e-tested via bun test with the windmill-client fake. All created records cleaned up.", + "sources": { + "table_crud": "HIGH - run live (create/get/update PATCH & PUT/delete on incident)", + "aggregate_stats": "HIGH - run live (/api/now/stats group-by count on incident)", + "attachment": "HIGH - run live (binary /file upload -> list -> download -> delete round-trip; downloaded bytes verified equal)", + "import_set": "HIGH - run live (POST /api/now/import/imp_user + GET result; top-level {import_set,staging_table,result} envelope, transform status surfaced)", + "user": "HIGH - run live (/api/now/ui/user/current_user -> admin sys_user)", + "trigger": "HIGH - e2e-tested live via bun test (first run sets watermark + no backlog, second run emits the new record)", + "dropdowns": "HIGH - run live (sys_db_object table list; sys_dictionary super_class inheritance walk confirmed - incident resolves 106 fields incl. task-inherited short_description/priority/state)" + }, + "low_confidence_fields": [ + "trigger datetime comparison assumes the integration user's timezone is UTC/GMT (validated with the PDI admin user, whose TZ is UTC); a non-UTC integration user could shift the sys_updated_on> watermark boundary by the offset" + ] + }, + "pagination": { + "pattern": "offset", + "request_params": { + "offset": "sysparm_offset", + "limit": "sysparm_limit" + }, + "response_fields": { + "next": "Link (response header, rel=\"next\")", + "total": "X-Total-Count (response header)" + }, + "default_limit": 10000, + "notes": "Table API pages with sysparm_offset + sysparm_limit. Each page response carries a Link header (RFC 5988) with rel=\"first\"/\"prev\"/\"next\"/\"last\" full URLs, plus X-Total-Count with the total match count. Add sysparm_no_count=true to skip the count on huge tables. The list_records action exposes limit+offset and returns one page; iterate offset until fewer than limit rows return." + }, + "gotchas": [ + "Base path is {instance_url}/api/now; instance_url lives on the resource. Auth is Basic (username:password) OR Bearer token \u2014 scripts send Bearer when auth.token is set, otherwise Basic. Both hit identical paths.", + "Table/Attachment/Stats reads/writes return a {\"result\": ...} envelope: list/stats -> result is an array, get/create/update -> result is an object. EXCEPTION: the Import Set API (POST /api/now/import/{staging}, GET .../{sys_id}) returns a TOP-LEVEL object {import_set, staging_table, result:[...]} with NO outer 'result' wrapper. Scripts return the raw JSON (envelope intact).", + "Datetimes are 'YYYY-MM-DD HH:MM:SS' in UTC with NO trailing 'Z'. Used directly in encoded queries, e.g. sys_updated_on>2026-01-01 00:00:00.", + "sysparm_query is an ENCODED query string: '^' = AND clause separator, '^OR' = OR, operators =, !=, LIKE, >=, <=, IN, STARTSWITH, ORDERBY, ORDERBYDESC. A literal '^' inside a value must be doubled to '^^'. Example: active=true^priority=1^ORDERBYDESCsys_updated_on.", + "System fields sys_id, sys_created_on, sys_updated_on, sys_created_by are auto-managed; do not send them on create/update.", + "DELETE (record and attachment) returns 204 No Content with an empty body \u2014 guard with `if (response.status === 204) return { success: true }` before response.json().", + "Update: PATCH partial-updates; PUT replaces the whole record (clears omitted fields). update_record sends PUT when replace=true, PATCH otherwise.", + "Attachment upload uses POST /api/now/attachment/file with table_name, table_sys_id, file_name in the QUERY STRING and the raw file bytes as the body (Content-Type = the file's MIME type), NOT multipart. Returns 201. Download is GET /api/now/attachment/{sys_id}/file (raw bytes).", + "Import Set transform runs SYNCHRONOUSLY. A result[].status of 'error' is an application-level failure even on HTTP 200/201 \u2014 inspect result[].status / error_message, don't trust the HTTP code alone.", + "Table-name DROPDOWN comes from GET /api/now/table/sys_db_object?sysparm_fields=name,label (use the 'name' as the value). A table's COLUMNS come from sys_dictionary \u2014 but sys_dictionary stores each field once, under the table where it is DEFINED, so name= returns ONLY that table's own fields. Extended tables (incident/change_request/sc_task all extend task) inherit columns like short_description/priority/assigned_to/state that live on the parent. To get the full column set, walk sys_db_object.super_class up the hierarchy (GET sys_db_object?sysparm_query=name=&sysparm_fields=super_class.name, repeat) and query GET sys_dictionary?sysparm_query=nameIN
,^elementISNOTEMPTY&sysparm_fields=element,column_label (elementISNOTEMPTY drops the collection/meta row).", + "Table API enforces row- and field-level ACLs: a 200 can omit fields the user can't read; inaccessible tables/ops return 403." + ], + "enums": { + "sysparm_display_value": [ + "true", + "false", + "all" + ], + "sysparm_view": [ + "desktop", + "mobile", + "both" + ], + "import_set_result_status": [ + "inserted", + "updated", + "ignored", + "error" + ], + "incident_state_note": "incident.state and priority/urgency/impact are instance-configurable; defaults: state 1=New,2=In Progress,3=On Hold,6=Resolved,7=Closed,8=Canceled; priority/urgency/impact 1-5. On task/incident, priority is CALCULATED from impact+urgency (a priority value sent on create/update is recomputed/ignored) \u2014 set impact+urgency instead. Never hardcode for generic actions." + }, + "errors": { + "401": "Authentication failed or OAuth token expired/invalid. Check username/password or refresh the bearer token.", + "403": "ACL denies the table or operation, or the user lacks the required role (e.g. rest_api_explorer / table ACL). Response: {\"error\":{\"message\":\"User Not Authorized\"}}.", + "404": "No Record found (bad sys_id) or Invalid table (bad/inaccessible table name, common Import Set staging-table error).", + "400": "Bad encoded query, invalid field name, or malformed request body." + } + }, + "resource_type": { + "name": "servicenow", + "description": "ServiceNow instance credential. Set instance_url to your instance base (https://.service-now.com). Authenticate with EITHER Basic auth (username + password) OR an OAuth 2.0 bearer token \u2014 scripts send a Bearer header when token is set, otherwise Basic auth.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "order": [ + "instance_url", + "username", + "password", + "token" + ], + "properties": { + "instance_url": { + "default": "", + "description": "Instance base URL, e.g. https://dev12345.service-now.com (no trailing slash). Every REST call is made against this host.", + "placeholder": "https://dev12345.service-now.com", + "type": "string" + }, + "password": { + "default": "", + "description": "Basic auth password. Leave blank when using an OAuth bearer token.", + "password": true, + "type": "string" + }, + "token": { + "default": "", + "description": "OAuth 2.0 access token (Bearer). Populated automatically when you connect via Windmill's ServiceNow OAuth flow (admin configures the instance + client id/secret in instance settings), or paste one manually. Provide this instead of username/password to authenticate with OAuth.", + "password": true, + "type": "string" + }, + "username": { + "default": "", + "description": "Basic auth username. Leave blank when using an OAuth bearer token.", + "type": "string" + } + }, + "required": [ + "instance_url" + ], + "type": "object" + } + }, + "scripts": [ + { + "version_id": 28665, + "app": "servicenow", + "summary": "Aggregate Records", + "description": "Count records on a table (Aggregate API), optionally filtered by an encoded query and grouped by a field.", + "terms": "servicenow aggregate records count records on a table (aggregate api), optionally filtered by an encoded query and grouped by a field.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\nexport type DynSelect_group_by = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n// Walk the table's inheritance chain (sys_db_object.super_class) so the group-by\n// options include fields inherited from parent tables, not just fields defined\n// on the chosen table (sys_dictionary stores each field under its defining\n// table \u2014 e.g. incident extends task, so priority/state live on task).\n// Best-effort: any failure falls back to just the table itself.\nasync function tableChain(auth: RT.Servicenow, table: string) {\n const chain: string[] = []\n let current: string | undefined = table\n while (current && !chain.includes(current)) {\n chain.push(current)\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_query=name=${current}&sysparm_fields=super_class.name&sysparm_limit=1`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) break\n const { result } = (await response.json()) as {\n result: { \"super_class.name\"?: string }[]\n }\n current = result[0]?.[\"super_class.name\"] || undefined\n }\n return chain\n}\n\n// Dependent select: the chosen table's columns (sys_dictionary) to group by,\n// including columns inherited from parent tables.\nexport async function group_by(auth: RT.Servicenow, table: DynSelect_table) {\n if (!table) return []\n const chain = await tableChain(auth, table)\n const url = new URL(`${auth.instance_url}/api/now/table/sys_dictionary`)\n url.searchParams.append(\n \"sysparm_query\",\n `nameIN${chain.join(\",\")}^elementISNOTEMPTY`\n )\n url.searchParams.append(\"sysparm_fields\", \"element,column_label\")\n url.searchParams.append(\"sysparm_limit\", \"10000\")\n\n const response = await fetch(url, {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { element: string; column_label: string }[]\n }\n return result\n .filter((f) => f.element)\n .map((f) => ({\n value: f.element,\n label: `${f.column_label} (${f.element})`,\n }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Aggregate Records\n * Count records on a table (Aggregate API), optionally filtered by an encoded query and grouped by a field. Returns ServiceNow's stats envelope with the count per group.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n query: string | undefined,\n group_by: DynSelect_group_by | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/stats/${table}`)\n url.searchParams.append(\"sysparm_count\", \"true\")\n if (query !== undefined && query !== \"\") {\n url.searchParams.append(\"sysparm_query\", query)\n }\n if (group_by !== undefined && group_by !== \"\") {\n url.searchParams.append(\"sysparm_group_by\", group_by)\n }\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "group_by": { + "default": null, + "description": "", + "format": "dynselect-group_by", + "type": "object" + }, + "query": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table" + ], + "type": "object" + } + }, + { + "version_id": 28666, + "app": "servicenow", + "summary": "Create Record", + "description": "Insert a record into any table (incident, change_request, sys_user, cmdb_ci, or a custom table). Pick the table from a live dropdown and pass a column/value map.", + "terms": "servicenow create record insert a record into any table (incident, change_request, sys_user, cmdb_ci, or a custom table). pick the table from a live dropdown and pass a column/value map.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Create Record\n * Insert a record into any table (incident, change_request, sys_user, cmdb_ci, or a custom table). Pick the table from a live dropdown and pass a column/value map.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n body: { [key: string]: any }\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}`)\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: authHeader(auth),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(body),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28667, + "app": "servicenow", + "summary": "Delete Attachment", + "description": "Delete an attachment by sys_id. Returns {success, sys_id} (ServiceNow replies 204 No Content).", + "terms": "servicenow delete attachment delete an attachment by sys_id. returns {success, sys_id} (servicenow replies 204 no content).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * Delete Attachment\n * Delete an attachment by its sys_id. ServiceNow returns 204 No Content on success, so this returns {success, sys_id}.\n */\nexport async function main(auth: RT.Servicenow, attachment_sys_id: string) {\n const response = await fetch(\n `${auth.instance_url}/api/now/attachment/${attachment_sys_id}`,\n {\n method: \"DELETE\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return { success: true, sys_id: attachment_sys_id }\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "attachment_sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + } + }, + "required": [ + "auth", + "attachment_sys_id" + ], + "type": "object" + } + }, + { + "version_id": 28668, + "app": "servicenow", + "summary": "Delete Record", + "description": "Delete a record by sys_id. Returns {success, sys_id} (ServiceNow replies 204 No Content).", + "terms": "servicenow delete record delete a record by sys_id. returns {success, sys_id} (servicenow replies 204 no content).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Delete Record\n * Delete a record by sys_id. ServiceNow returns 204 No Content on success, so this returns {success, sys_id}.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n sys_id: string\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}/${sys_id}`)\n\n const response = await fetch(url, {\n method: \"DELETE\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return { success: true, sys_id }\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table", + "sys_id" + ], + "type": "object" + } + }, + { + "version_id": 28670, + "app": "servicenow", + "summary": "Get Current User", + "description": "Return the sys_user record of the authenticated user.", + "terms": "servicenow get current user return the sys_user record of the authenticated user.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * Get Current User\n * Return the sys_user record of the authenticated user (resolves current_user, then fetches the full record from sys_user).\n */\nexport async function main(auth: RT.Servicenow) {\n const currentResponse = await fetch(\n `${auth.instance_url}/api/now/ui/user/current_user`,\n {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!currentResponse.ok) {\n throw new Error(`${currentResponse.status} ${await currentResponse.text()}`)\n }\n const { result } = (await currentResponse.json()) as {\n result: { user_sys_id: string }\n }\n\n const userResponse = await fetch(\n `${auth.instance_url}/api/now/table/sys_user/${result.user_sys_id}`,\n {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!userResponse.ok) {\n throw new Error(`${userResponse.status} ${await userResponse.text()}`)\n }\n\n return await userResponse.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28671, + "app": "servicenow", + "summary": "Get Import Set Result", + "description": "Fetch the outcome of a prior import row by the staging-row sys_id returned by Insert Into Import Set (result.sys_id).", + "terms": "servicenow get import set result fetch the outcome of a prior import row by the staging-row sys_id returned by insert into import set (result.sys_id).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * Get Import Set Result\n * Fetch the outcome of a prior import row by the staging-row sys_id returned by Insert Into Import Set (result.sys_id), returning the transform result (target table, status, target record link).\n */\nexport async function main(\n auth: RT.Servicenow,\n staging_table: string,\n sys_id: string\n) {\n const url = new URL(\n `${auth.instance_url}/api/now/import/${staging_table}/${sys_id}`\n )\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "staging_table": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "staging_table", + "sys_id" + ], + "type": "object" + } + }, + { + "version_id": 28672, + "app": "servicenow", + "summary": "Get Record", + "description": "Retrieve a single record by sys_id. Optionally restrict the returned columns, and choose raw vs. display values.", + "terms": "servicenow get record retrieve a single record by sys_id. optionally restrict the returned columns, and choose raw vs. display values.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\nexport type DynMultiselect_fields = string[]\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n// Walk the table's inheritance chain (sys_db_object.super_class) so the column\n// list includes fields inherited from parent tables, not just fields defined on\n// the chosen table (sys_dictionary stores each field under its defining table \u2014\n// e.g. incident extends task, so short_description/priority live on task).\n// Best-effort: any failure falls back to just the table itself.\nasync function tableChain(auth: RT.Servicenow, table: string) {\n const chain: string[] = []\n let current: string | undefined = table\n while (current && !chain.includes(current)) {\n chain.push(current)\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_query=name=${current}&sysparm_fields=super_class.name&sysparm_limit=1`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) break\n const { result } = (await response.json()) as {\n result: { \"super_class.name\"?: string }[]\n }\n current = result[0]?.[\"super_class.name\"] || undefined\n }\n return chain\n}\n\n// Dependent multi-select: the chosen table's columns (sys_dictionary), including\n// columns inherited from parent tables.\nexport async function fields(auth: RT.Servicenow, table: DynSelect_table) {\n if (!table) return []\n const chain = await tableChain(auth, table)\n const url = new URL(`${auth.instance_url}/api/now/table/sys_dictionary`)\n url.searchParams.append(\n \"sysparm_query\",\n `nameIN${chain.join(\",\")}^elementISNOTEMPTY`\n )\n url.searchParams.append(\"sysparm_fields\", \"element,column_label\")\n url.searchParams.append(\"sysparm_limit\", \"10000\")\n\n const response = await fetch(url, {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { element: string; column_label: string }[]\n }\n return result\n .filter((f) => f.element)\n .map((f) => ({\n value: f.element,\n label: `${f.column_label} (${f.element})`,\n }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Get Record\n * Retrieve a single record by sys_id. Optionally restrict the returned columns, and choose raw vs. display values.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n sys_id: string,\n fields: DynMultiselect_fields | undefined,\n display_value: \"false\" | \"true\" | \"all\" | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}/${sys_id}`)\n if (fields && fields.length > 0) {\n url.searchParams.append(\"sysparm_fields\", fields.join(\",\"))\n }\n if (display_value !== undefined && display_value !== \"\") {\n url.searchParams.append(\"sysparm_display_value\", display_value)\n }\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "display_value": { + "default": null, + "description": "", + "enum": [ + "false", + "true", + "all" + ], + "originalType": "enum", + "type": "string" + }, + "fields": { + "default": null, + "description": "", + "format": "dynmultiselect-fields", + "type": "object" + }, + "sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table", + "sys_id" + ], + "type": "object" + } + }, + { + "version_id": 28673, + "app": "servicenow", + "summary": "Insert Into Import Set", + "description": "Push a row into an import set staging table; the transform map runs synchronously. Inspect result[].status.", + "terms": "servicenow insert into import set push a row into an import set staging table; the transform map runs synchronously. inspect result[].status.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * Insert Into Import Set\n * Push a row into an import set staging table; the associated transform map runs synchronously. Inspect result[].status (inserted/updated/ignored/error) \u2014 a transform error returns 200/201 with status \"error\".\n */\nexport async function main(\n auth: RT.Servicenow,\n staging_table: string,\n body: { [key: string]: any }\n) {\n const url = new URL(`${auth.instance_url}/api/now/import/${staging_table}`)\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: authHeader(auth),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(body),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "staging_table": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "staging_table", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28674, + "app": "servicenow", + "summary": "List Attachments", + "description": "List attachment metadata, optionally filtered with an encoded query (e.g. table_name=incident^table_sys_id=).", + "terms": "servicenow list attachments list attachment metadata, optionally filtered with an encoded query (e.g. table_name=incident^table_sys_id=).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * List Attachments\n * List attachment metadata (sys_id, file_name, content_type, size, download_link). Filter with an encoded query, e.g. table_name=incident^table_sys_id=.\n */\nexport async function main(\n auth: RT.Servicenow,\n query: string | undefined,\n limit: number | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/attachment`)\n if (query !== undefined && query !== \"\") {\n url.searchParams.append(\"sysparm_query\", query)\n }\n url.searchParams.append(\"sysparm_limit\", String(limit ?? 100))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "query": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28675, + "app": "servicenow", + "summary": "List Records", + "description": "Query records from any table with an encoded query (sysparm_query), pagination, column selection, and raw/display values.", + "terms": "servicenow list records query records from any table with an encoded query (sysparm_query), pagination, column selection, and raw/display values.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\nexport type DynMultiselect_fields = string[]\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n// Walk the table's inheritance chain (sys_db_object.super_class) so the column\n// list includes fields inherited from parent tables, not just fields defined on\n// the chosen table (sys_dictionary stores each field under its defining table \u2014\n// e.g. incident extends task, so short_description/priority live on task).\n// Best-effort: any failure falls back to just the table itself.\nasync function tableChain(auth: RT.Servicenow, table: string) {\n const chain: string[] = []\n let current: string | undefined = table\n while (current && !chain.includes(current)) {\n chain.push(current)\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_query=name=${current}&sysparm_fields=super_class.name&sysparm_limit=1`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) break\n const { result } = (await response.json()) as {\n result: { \"super_class.name\"?: string }[]\n }\n current = result[0]?.[\"super_class.name\"] || undefined\n }\n return chain\n}\n\n// Dependent multi-select: the chosen table's columns (sys_dictionary), including\n// columns inherited from parent tables.\nexport async function fields(auth: RT.Servicenow, table: DynSelect_table) {\n if (!table) return []\n const chain = await tableChain(auth, table)\n const url = new URL(`${auth.instance_url}/api/now/table/sys_dictionary`)\n url.searchParams.append(\n \"sysparm_query\",\n `nameIN${chain.join(\",\")}^elementISNOTEMPTY`\n )\n url.searchParams.append(\"sysparm_fields\", \"element,column_label\")\n url.searchParams.append(\"sysparm_limit\", \"10000\")\n\n const response = await fetch(url, {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { element: string; column_label: string }[]\n }\n return result\n .filter((f) => f.element)\n .map((f) => ({\n value: f.element,\n label: `${f.column_label} (${f.element})`,\n }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * List Records\n * Query records from any table with an encoded query (sysparm_query), pagination, column selection, and raw/display values. Returns one page; iterate offset until fewer than limit rows return.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n query: string | undefined,\n limit: number | undefined,\n offset: number | undefined,\n fields: DynMultiselect_fields | undefined,\n display_value: \"false\" | \"true\" | \"all\" | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}`)\n if (query !== undefined && query !== \"\") {\n url.searchParams.append(\"sysparm_query\", query)\n }\n url.searchParams.append(\"sysparm_limit\", String(limit ?? 100))\n if (offset !== undefined) {\n url.searchParams.append(\"sysparm_offset\", String(offset))\n }\n if (fields && fields.length > 0) {\n url.searchParams.append(\"sysparm_fields\", fields.join(\",\"))\n }\n if (display_value !== undefined && display_value !== \"\") {\n url.searchParams.append(\"sysparm_display_value\", display_value)\n }\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "display_value": { + "default": null, + "description": "", + "enum": [ + "false", + "true", + "all" + ], + "originalType": "enum", + "type": "string" + }, + "fields": { + "default": null, + "description": "", + "format": "dynmultiselect-fields", + "type": "object" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "offset": { + "default": null, + "description": "", + "type": "number" + }, + "query": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table" + ], + "type": "object" + } + }, + { + "version_id": 28676, + "app": "servicenow", + "summary": "List Tables", + "description": "List the instance tables (sys_db_object) with API name, label, and sys_id. Optionally filter by a name/label substring.", + "terms": "servicenow list tables list the instance tables (sys_db_object) with api name, label, and sys_id. optionally filter by a name/label substring.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * List Tables\n * List the instance's tables (sys_db_object) with their API name, label, and sys_id. Optionally filter by a substring matched against name or label.\n */\nexport async function main(\n auth: RT.Servicenow,\n name_filter: string | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/sys_db_object`)\n url.searchParams.append(\"sysparm_fields\", \"name,label,sys_id\")\n if (name_filter !== undefined && name_filter !== \"\") {\n url.searchParams.append(\n \"sysparm_query\",\n `nameLIKE${name_filter}^ORlabelLIKE${name_filter}`\n )\n }\n url.searchParams.append(\"sysparm_limit\", \"10000\")\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "name_filter": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28677, + "app": "servicenow", + "summary": "Update Record", + "description": "Update a record by sys_id with a column/value map. PATCH (partial) by default; set replace=true to PUT (full overwrite).", + "terms": "servicenow update record update a record by sys_id with a column/value map. patch (partial) by default; set replace=true to put (full overwrite).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Update Record\n * Update a record by sys_id with a column/value map. PATCH (partial update) by default; set replace=true to PUT (full overwrite that clears omitted fields).\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n sys_id: string,\n body: { [key: string]: any },\n replace: boolean | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}/${sys_id}`)\n\n const response = await fetch(url, {\n method: replace ? \"PUT\" : \"PATCH\",\n headers: {\n Authorization: authHeader(auth),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(body),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "replace": { + "default": null, + "description": "", + "type": "boolean" + }, + "sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table", + "sys_id", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28679, + "app": "servicenow", + "summary": "New or Updated Record", + "description": "Emits records whose timestamp is newer than the last poll, tracked via Windmill state. Defaults to sys_updated_on.", + "terms": "servicenow new or updated record emits records whose timestamp is newer than the last poll, tracked via windmill state. defaults to sys_updated_on.", + "kind": "trigger", + "language": "bunnative", + "content": "//native\n\nimport * as wmill from \"windmill-client\"\n\nexport type DynSelect_table = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * New or Updated Record\n * Emits records from a table whose timestamp is newer than the last poll, tracked via Windmill state. Defaults to sys_updated_on (set timestamp_field to sys_created_on for new-only). Optionally narrow with an extra encoded query.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n timestamp_field: \"sys_updated_on\" | \"sys_created_on\" | undefined,\n query: string | undefined\n) {\n const tsField =\n timestamp_field && timestamp_field !== \"\"\n ? timestamp_field\n : \"sys_updated_on\"\n const lastChecked: string | undefined = await wmill.getState()\n\n // ServiceNow datetimes are 'YYYY-MM-DD HH:MM:SS' in UTC (no trailing Z).\n const nowUtc = new Date().toISOString().slice(0, 19).replace(\"T\", \" \")\n\n // First run: set the watermark to now and don't emit a backlog.\n if (!lastChecked) {\n await wmill.setState(nowUtc)\n return []\n }\n\n let encoded = `${tsField}>${lastChecked}`\n if (query !== undefined && query !== \"\") {\n encoded += `^${query}`\n }\n encoded += `^ORDERBY${tsField}`\n\n const url = new URL(`${auth.instance_url}/api/now/table/${table}`)\n url.searchParams.append(\"sysparm_query\", encoded)\n url.searchParams.append(\"sysparm_limit\", \"200\")\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n const { result } = (await response.json()) as {\n result: Array<{ [key: string]: any }>\n }\n\n if (result.length > 0) {\n await wmill.setState(result[result.length - 1][tsField])\n }\n\n return result\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "query": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + }, + "timestamp_field": { + "default": null, + "description": "", + "enum": [ + "sys_updated_on", + "sys_created_on" + ], + "originalType": "enum", + "type": "string" + } + }, + "required": [ + "auth", + "table" + ], + "type": "object" + } + } + ] +} \ No newline at end of file diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index b824936d2f..e100cf88c6 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -1111,9 +1111,9 @@ fn is_global_read_open_to_job_token(route_path: &str) -> bool { | "/api/settings/global/hub_base_url" | "/api/docs/search" | "/api/docs/page" - | "/api/integrations/hub/list" | "/api/embeddings/query_hub_scripts" - ) || route_path.starts_with("/api/scripts/hub/") + ) || route_path.starts_with("/api/integrations/hub/") + || route_path.starts_with("/api/scripts/hub/") || route_path.starts_with("/api/flows/hub/") || route_path.starts_with("/api/apps/hub/") } diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs index 1de99fb44f..6e4d6a1c71 100644 --- a/backend/windmill-api-embeddings/src/lib.rs +++ b/backend/windmill-api-embeddings/src/lib.rs @@ -78,6 +78,8 @@ pub struct HubScriptResult { id: i64, version_id: i64, summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, app: String, kind: String, score: f32, @@ -163,6 +165,9 @@ struct HubScript { id: i64, version_id: i64, summary: String, + // Nearly a fifth of hub scripts carry an explicit `"description": null`; a bare + // String here fails the whole blob and takes hub search down with it. + description: Option, app: String, kind: String, embedding: Vec, @@ -360,6 +365,10 @@ impl EmbeddingsDb { let mut hm = HashMap::new(); hm.insert("ask_id".to_string(), script.ask_id.clone().to_string()); hm.insert("summary".to_string(), script.summary.clone()); + hm.insert( + "description".to_string(), + script.description.clone().unwrap_or_default(), + ); hm.insert("app".to_string(), script.app.clone()); hm.insert("kind".to_string(), script.kind.clone()); hm.insert("id".to_string(), script.id.clone().to_string()); @@ -517,6 +526,10 @@ impl EmbeddingsDb { .get("summary") .ok_or(Error::msg("no summary"))? .to_owned(), + description: metadata + .get("description") + .filter(|d| !d.is_empty()) + .map(|d| d.to_owned()), app: metadata.get("app").ok_or(Error::msg("no app"))?.to_owned(), kind: metadata .get("kind") @@ -707,6 +720,31 @@ pub fn global_service() -> Router { mod tests { use super::trim_to_top_score; + // The blob carries an explicit `"description": null` for roughly a fifth of hub + // scripts, and an older hub omits the key entirely. A bare String here fails the + // whole 155 MB array and takes hub search down with it. + #[test] + fn reads_a_hub_script_whether_or_not_it_has_a_description() { + let present = r#"{"ask_id":1,"id":2,"version_id":3,"summary":"s","description":"d","app":"a","kind":"script","embedding":[]}"#; + let null = r#"{"ask_id":1,"id":2,"version_id":3,"summary":"s","description":null,"app":"a","kind":"script","embedding":[]}"#; + let missing = r#"{"ask_id":1,"id":2,"version_id":3,"summary":"s","app":"a","kind":"script","embedding":[]}"#; + + assert_eq!( + serde_json::from_str::(present) + .unwrap() + .description, + Some("d".to_string()) + ); + for without in [null, missing] { + assert_eq!( + serde_json::from_str::(without) + .unwrap() + .description, + None + ); + } + } + #[test] fn trims_scores_more_than_5pct_below_top() { // top=1.0, cutoff at 0.95: 0.96 stays (0.04 drop), 0.93 is the first diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 67e08077c1..d8755f8995 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -9909,6 +9909,13 @@ paths: properties: name: type: string + documented: + description: >- + whether the integration carries provider knowledge checked + against the live API, on top of the resource type and example + scripts /integrations/hub/{app}/meta returns for any + integration. Absent on a hub predating the flag. + type: boolean picks: description: how often the integration has been picked, absent on a hub that does not count picks type: integer @@ -9919,6 +9926,162 @@ paths: required: - name + /integrations/hub/{app}/meta: + get: + summary: get hub integration metadata + operationId: getHubIntegrationMeta + tags: + - integration + parameters: + - name: app + description: integration slug + in: path + required: true + schema: + type: string + responses: + "200": + description: integration metadata + content: + application/json: + schema: + type: object + properties: + app: + type: string + display_name: + type: string + description: + type: string + nullable: true + docs_url: + type: string + nullable: true + curated: + description: >- + whether the script set was pruned to idiomatic actions rather + than generated one per endpoint; null when nobody has assessed + it, which is distinct from a stated false + type: boolean + nullable: true + metadata_source: + description: >- + whether the provider knowledge was authored (curated) or + inferred from the shipped scripts (derived) + type: string + enum: + - curated + - derived + meta: + description: >- + the integration's authored meta.json verbatim; the content + repo owns its schema, so it is passed through unvalidated + type: object + nullable: true + additionalProperties: true + meta_updated_at: + type: string + nullable: true + derived: + description: facts computed from the integration's shipped scripts + type: object + properties: + api_hosts: + type: array + items: + type: object + properties: + host: + type: string + count: + type: number + required: + - host + - count + style: + type: string + enum: + - fetch + - sdk + - mixed + - unknown + languages: + type: object + additionalProperties: + type: number + script_counts: + type: object + properties: + total: + type: number + by_kind: + type: object + additionalProperties: + type: number + required: + - total + - by_kind + top_scripts: + type: array + items: + type: object + properties: + path: + type: string + ask_id: + type: number + version_id: + type: number + summary: + type: string + description: + type: string + nullable: true + kind: + type: string + language: + type: string + nullable: true + views: + type: number + votes: + type: number + required: + - path + - summary + - kind + required: + - api_hosts + - style + - languages + - script_counts + - top_scripts + resource_types: + type: array + items: + type: object + properties: + id: + type: number + name: + type: string + description: + type: string + nullable: true + schema: + description: >- + the resource type's JSON schema; the hub stores it as text + and returns it as a JSON string, so a client must be ready + for either that or an object + required: + - name + - schema + # `derived` and `resource_types` are omitted by a hub older than the + # client, which reads every section as optional. + required: + - app + - display_name + /flows/hub/list: get: summary: list all hub flows @@ -10247,6 +10410,9 @@ paths: type: number summary: type: string + description: + type: string + nullable: true app: type: string version_id: @@ -10316,6 +10482,8 @@ paths: type: number summary: type: string + description: + type: string app: type: string kind: diff --git a/backend/windmill-api/src/integration.rs b/backend/windmill-api/src/integration.rs index 2def3e6ed0..10bd1fc24c 100644 --- a/backend/windmill-api/src/integration.rs +++ b/backend/windmill-api/src/integration.rs @@ -1,9 +1,16 @@ use crate::{db::DB, HTTP_CLIENT}; -use axum::{extract::Query, response::IntoResponse, routing::get, Extension, Router}; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + routing::get, + Extension, Router, +}; use windmill_common::{error::Error, utils::query_elems_from_hub, HUB_BASE_URL}; pub fn global_service() -> Router { - Router::new().route("/hub/list", get(list_hub_integrations)) + Router::new() + .route("/hub/list", get(list_hub_integrations)) + .route("/hub/{app}/meta", get(get_hub_integration_meta)) } #[derive(serde::Deserialize)] @@ -29,3 +36,60 @@ async fn list_hub_integrations( .await?; Ok::<_, Error>((status_code, headers, response)) } + +/// Axum percent-decodes a path parameter, so an interpolated slug carrying `..`, `?` +/// or `#` re-targets the proxied GET at another path on the hub origin — with the +/// instance's hub credentials attached. Slugs are `[A-Za-z0-9_-]`; reject the rest. +fn is_hub_integration_slug(app: &str) -> bool { + !app.is_empty() + && app.len() <= 64 + && app + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// Everything a caller needs to write code against one integration: its resource +/// types, the provider knowledge the content repo authored, and facts derived from +/// the shipped scripts. A hub older than the endpoint answers 404, which passes +/// through as-is. +async fn get_hub_integration_meta( + Path(app): Path, + Extension(db): Extension, +) -> impl IntoResponse { + if !is_hub_integration_slug(&app) { + return Err(Error::BadRequest(format!( + "Not a valid integration name: {app}" + ))); + } + let (status_code, headers, response) = query_elems_from_hub( + &HTTP_CLIENT, + &format!("{}/integrations/{}/meta", **HUB_BASE_URL.load(), app), + None, + &db, + ) + .await?; + Ok::<_, Error>((status_code, headers, response)) +} + +#[cfg(test)] +mod tests { + use super::is_hub_integration_slug; + + #[test] + fn rejects_slugs_that_would_re_target_the_proxied_request() { + assert!(is_hub_integration_slug("confluence")); + assert!(is_hub_integration_slug("aws-ses")); + assert!(is_hub_integration_slug("bamboo_hr")); + assert!(is_hub_integration_slug("RSS")); + + for escape in [ + "../../scripts/top", + "confluence?foo=bar", + "confluence#frag", + "confluence/meta", + "", + ] { + assert!(!is_hub_integration_slug(escape), "accepted {escape}"); + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/app/core.ts b/frontend/src/lib/components/copilot/chat/app/core.ts index 7964f1a908..b013c58ebc 100644 --- a/frontend/src/lib/components/copilot/chat/app/core.ts +++ b/frontend/src/lib/components/copilot/chat/app/core.ts @@ -974,7 +974,7 @@ Use \`patch_file\` for small, localized edits when you can copy an exact snippet ### Discovery - \`search_workspace(query, type)\`: Search workspace scripts and flows - \`get_runnable_details(path, type)\`: Get details (summary, description, schema, content) of a specific script or flow -- \`search_hub_scripts(query)\`: Search hub scripts +- \`search_hub_scripts(query, integration)\`: Search hub scripts, or list one integration's scripts by slug ### Data Tables - \`list_datatables()\`: List configured datatables with schema and table names only. Does not include columns. Use this directly for table-list or available-tables summaries. diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index e0df5c4a98..63bfa6e1b2 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -1137,13 +1137,15 @@ describe('global AI tools', () => { expect(ScriptService.queryHubScripts).toHaveBeenCalledWith({ text: 'slack message', - kind: 'script' + kind: 'script', + app: undefined }) expect(ScriptService.getHubScriptContentByPath).not.toHaveBeenCalled() - expect(JSON.parse(raw)).toEqual([ + expect(JSON.parse(raw).results).toEqual([ { path: 'hub/7/slack/send_message', - summary: 'Send Message' + summary: 'Send Message', + integration: 'slack' } ]) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index e0d1cf683c..b3bf4a8078 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -133,6 +133,7 @@ import { createToolDef, droppedOptionKeys, createSearchHubScriptsTool, + getHubIntegrationTool, executeTestRun, findAndReplace, isHubPath, @@ -1437,7 +1438,9 @@ Rules:${when( - You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. "$var:path/to/variable" is how a resource value references a variable — it is never a variable's own value. - Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition. - When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it. -- Hub scripts are prebuilt integrations for third-party services, hosted outside the workspace under \`hub///\` paths. Use search_hub_scripts to find one before hand-writing an integration, then read_workspace_item with type "script" and the returned hub path to get its code, language, and input schema. +- Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under \`hub///\` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type "script" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (note the source hub path in a comment at the top of the code), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its \`integration\` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the \`suggested_integrations\` a search hands back when it finds nothing. +- Before writing your own code against an integration the hub covers, call get_hub_integration with its slug: it returns the resource type to take, its auth fields and the integration's most-used scripts, which beats inferring them from script bodies. Call it for the integration you are about to write against, whichever it is. A search marks an integration \`documented\` when the hub additionally holds provider knowledge checked against the live API — pagination, enums, error codes and gotchas — so read that closely where it appears rather than trusting your own memory of the API. +- If you have a web search tool and the hub does not cover a third-party API, search for the vendor's own API documentation rather than writing its endpoints and auth from memory, and link the page you relied on. Reserve it for external APIs: search_docs answers questions about Windmill itself. ${when(canRunPreview, '- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n')}- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. ${pipelineBullet}` )}${when( @@ -3388,6 +3391,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, createSearchHubScriptsTool(false), + getHubIntegrationTool, searchNpmPackagesTool, searchDocsTool, readDocsPageTool, diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 4574df7355..a75d1b4440 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -38,6 +38,7 @@ vi.mock('$lib/components/flows/flowTree', () => ({ vi.mock('$lib/gen', () => ({ ScriptService: {}, FlowService: {}, + IntegrationService: {}, JobService: { getJob: vi.fn(), getJobUpdates: vi.fn() }, ScheduleService: { previewSchedule: vi.fn(), @@ -1890,7 +1891,7 @@ describe('createSearchHubScriptsTool', () => { toolId: 't1', toolCallbacks: { setToolStatus: vi.fn() } } as any) - return JSON.parse(raw) + return JSON.parse(raw).results } it('reports each script language alongside its content', async () => { @@ -1905,12 +1906,14 @@ describe('createSearchHubScriptsTool', () => { { path: 'hub/1/discord/send_a_message', summary: 'Send a message', + integration: 'discord', language: 'bunnative', content: '// hub/1/discord/send_a_message' }, { path: 'hub/2/slack/post_a_message', summary: 'Post a message', + integration: 'slack', language: 'python3', content: '// hub/2/slack/post_a_message' } @@ -1929,6 +1932,382 @@ describe('createSearchHubScriptsTool', () => { expect(results[0].content).toBeUndefined() expect(results[1].content).toBe('ok') }) + + // Browsing by app is what surfaces an integration's other scripts as examples, + // and only the top-scripts endpoint takes no query and applies no similarity + // floor, so the near-misses worth reading survive instead of being cut. + it('lists an integration through the top-scripts endpoint, with descriptions', async () => { + const { ScriptService } = await import('$lib/gen') + const getTopHubScripts = vi.fn(async () => ({ + asks: [{ ...hit(3, 'stripe', 'Create a Payout'), description: 'Send funds to your bank' }] + })) + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []), getTopHubScripts }) + + const { createSearchHubScriptsTool } = await import('./shared') + const raw = await createSearchHubScriptsTool().fn({ + args: { integration: 'stripe' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(getTopHubScripts).toHaveBeenCalledWith({ app: 'stripe', kind: 'script', limit: 20 }) + expect(ScriptService.queryHubScripts).not.toHaveBeenCalled() + expect(JSON.parse(raw).results).toEqual([ + { + path: 'hub/3/stripe/create_a_payout', + summary: 'Create a Payout', + integration: 'stripe', + description: 'Send funds to your bank' + } + ]) + }) + + // Narrowing a query to a slug is the only thing that searches an integration the + // user named: ranking alone surfaces other integrations whose scripts merely mention + // it, so a query that drops `app` silently searches the whole hub instead. + it('narrows a query to the integration it was given', async () => { + const { ScriptService } = await import('$lib/gen') + const queryHubScripts = vi.fn(async () => [hit(9, 'salesforce', 'SOSL Search')]) + Object.assign(ScriptService, { queryHubScripts, getTopHubScripts: vi.fn() }) + + const { createSearchHubScriptsTool } = await import('./shared') + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'look up an account', integration: 'salesforce' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(queryHubScripts).toHaveBeenCalledWith({ + text: 'look up an account', + kind: 'script', + app: 'salesforce' + }) + expect(ScriptService.getTopHubScripts).not.toHaveBeenCalled() + expect(JSON.parse(raw).results.map((r: any) => r.integration)).toEqual(['salesforce']) + }) + + // A search below the similarity floor otherwise dead-ends; the slug is what + // lets the model fall back to browsing the integration. + it('suggests matching integrations when the search finds nothing', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [{ name: 'stripe' }, { name: 'slack' }]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'refund a stripe charge' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw)).toEqual({ results: [], suggested_integrations: ['stripe'] }) + }) + + // Matching a query word anywhere inside a slug turns every short English word into + // a hit — `for` in salesforce, `the` in basis_theory — so a task that names no + // integration came back with a confident-looking list of them. + it('suggests nothing for a request that names no integration', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [ + { name: 'salesforce' }, + { name: 'basis_theory' }, + { name: 'hackernews' }, + { name: 's3' }, + // A slug that is an ordinary word with a letter in front of it: matching + // one character in would read "node" as this integration. + { name: 'enode' } + ]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'list all the invoices for the new month in a node script' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw).suggested_integrations).toEqual([]) + }) + + // Google's integrations compress the vendor to a `g`, so the word a user says + // appears in the name the hub curates and nowhere in the slug. + it('reaches an integration whose slug compresses the vendor name', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [ + { name: 'gsheets', display_name: 'Google Sheets' }, + { name: 'gdrive', display_name: 'Google Drive' }, + { name: 'smartsheet', display_name: null } + ]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'add a row to a google sheet' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + // "google" names both products, and a suggestion list is allowed to offer both. + expect(JSON.parse(raw).suggested_integrations).toEqual(['gsheets', 'gdrive']) + }) + + // The hub names only the slugs its own titleisation gets wrong, so the ones it + // leaves null are still named here. + it('falls back to the local name table for a slug the hub does not name', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [{ name: 'gcloud', display_name: null }]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'deploy a service to google cloud' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw).suggested_integrations).toEqual(['gcloud']) + }) + + // A slug shorter than the token floor is only reachable by an exact match. + it('still reaches a two-character integration slug', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [{ name: 's3' }, { name: 'salesforce' }]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'upload a file to s3' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw).suggested_integrations).toEqual(['s3']) + }) + + // Only about one hub integration in twelve carries authored provider knowledge, + // and a search result is where the model first sees a slug — so the mark has to + // ride along, or nothing tells it which integrations have verified notes to read. + it('marks the integrations get_hub_integration has knowledge for', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { + queryHubScripts: vi.fn(async () => [ + hit(1, 'confluence', 'Create page'), + hit(2, 'notion', 'Create page') + ]) + }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [ + { name: 'Confluence', documented: true }, + { name: 'notion', documented: false } + ]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'create a wiki page' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + const results = JSON.parse(raw).results + // the hub's slugs are case-sensitive, so the two casings are one integration + expect(results.find((r: any) => r.integration === 'confluence').documented).toBe(true) + expect(results.find((r: any) => r.integration === 'notion').documented).toBeUndefined() + }) + + // A hub predating the flag sends no `documented` at all; reading that as "yes" + // would send the model after notes that are not coming. + it('claims no knowledge when the hub does not report any', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { + queryHubScripts: vi.fn(async () => [hit(1, 'confluence', 'Create page')]) + }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [{ name: 'confluence' }]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'create a wiki page' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw).results[0].documented).toBeUndefined() + }) +}) + +describe('getHubIntegrationTool', () => { + // The plan-mode gate fails closed, so a read-only tool that is not tagged is + // silently unusable while planning. + it('is usable while planning, like the hub search beside it', async () => { + const { getHubIntegrationTool, createSearchHubScriptsTool } = await import('./shared') + expect(getHubIntegrationTool.planModeSafe).toBe(true) + expect(createSearchHubScriptsTool().planModeSafe).toBe(true) + }) + + const doc = { + app: 'confluence', + display_name: 'Confluence', + // Authored knowledge, but nobody has asserted whether the script set was pruned. + curated: null, + metadata_source: 'curated', + meta: { gotchas: ['Auth is Basic with an API token, not the password'] }, + derived: { + api_hosts: [{ host: 'api.atlassian.com', count: 12 }], + style: 'fetch', + languages: { bun: 14 }, + script_counts: { total: 14, by_kind: { script: 13 } }, + top_scripts: [ + { path: 'hub/1/confluence/create_page', summary: 'Create page', language: 'bun' } + ] + }, + resource_types: [{ name: 'confluence', schema: { type: 'object' } }] + } + + // Hand-validated provider knowledge and facts inferred from script bodies must + // stay under separate keys, or the model will report guesses as verified. + it('keeps authored notes apart from what was inferred from the scripts', async () => { + const { IntegrationService } = await import('$lib/gen') + Object.assign(IntegrationService, { getHubIntegrationMeta: vi.fn(async () => doc) }) + + const { getHubIntegrationTool } = await import('./shared') + const parsed = JSON.parse( + await getHubIntegrationTool.fn({ + args: { integration: 'confluence' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + ) + + expect(parsed.verified_provider_notes).toEqual(doc.meta) + expect(parsed.scripts_note).toBeUndefined() + expect(parsed.observed_from_scripts).toEqual({ + api_hosts: ['api.atlassian.com'], + style: 'fetch', + languages: ['bun'], + script_counts: { total: 14, by_kind: { script: 13 } } + }) + }) + + // The hub repo owns this payload, so a hub older than this client can send a + // subset. Reading it must degrade to less content, never to a tool error. + it('returns what an older hub sent instead of failing on the missing sections', async () => { + const { IntegrationService } = await import('$lib/gen') + Object.assign(IntegrationService, { + getHubIntegrationMeta: vi.fn(async () => ({ + app: 'stripe', + display_name: 'Stripe', + curated: false + })) + }) + + const { getHubIntegrationTool } = await import('./shared') + const parsed = JSON.parse( + await getHubIntegrationTool.fn({ + args: { integration: 'stripe' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + ) + + expect(parsed.integration).toBe('stripe') + expect(parsed.observed_from_scripts).toBeUndefined() + expect(parsed.resource_types).toEqual([]) + expect(parsed.example_scripts).toEqual([]) + }) + + // `curated` has three states and only `true` is a licence to call the scripts good + // examples. `false` and the unassessed `null` must both stay silent, or the 212 + // integrations nobody has looked at get characterised anyway. + it.each([ + [true, true], + [false, false], + [null, false] + ])('speaks about the script set only when curated is true (%s)', async (curated, expected) => { + const { IntegrationService } = await import('$lib/gen') + Object.assign(IntegrationService, { + getHubIntegrationMeta: vi.fn(async () => ({ ...doc, curated })) + }) + + const { getHubIntegrationTool } = await import('./shared') + const parsed = JSON.parse( + await getHubIntegrationTool.fn({ + args: { integration: 'confluence' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + ) + + expect(!!parsed.scripts_note).toBe(expected) + }) + + // A hub that times out has said nothing about whether the integration exists, and + // reporting it as absent would stick for the rest of the conversation. + it('does not report a transient hub failure as a missing integration', async () => { + const { IntegrationService } = await import('$lib/gen') + Object.assign(IntegrationService, { + getHubIntegrationMeta: vi.fn(async () => { + throw Object.assign(new Error('Service Unavailable'), { status: 503 }) + }), + listHubIntegrations: vi.fn(async () => [{ name: 'confluence' }]) + }) + + const { getHubIntegrationTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const parsed = JSON.parse( + await getHubIntegrationTool.fn({ + args: { integration: 'confluence' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + ) + + expect(parsed.error).toContain('Could not reach the hub') + expect(parsed.error).not.toContain('No hub metadata') + }) + + // A hub with no such integration and one too old to serve the endpoint both 404; + // neither may surface as a tool error, since the model can still read scripts. + it('suggests real slugs instead of failing when the integration is unknown', async () => { + const { IntegrationService } = await import('$lib/gen') + Object.assign(IntegrationService, { + getHubIntegrationMeta: vi.fn(async () => { + throw Object.assign(new Error('Not Found'), { status: 404 }) + }), + listHubIntegrations: vi.fn(async () => [{ name: 'stripe' }, { name: 'slack' }]) + }) + + const { getHubIntegrationTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const parsed = JSON.parse( + await getHubIntegrationTool.fn({ + args: { integration: 'stripe_billing' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + ) + + expect(parsed.error).toContain('stripe_billing') + expect(parsed.suggested_integrations).toEqual(['stripe']) + }) }) describe('processToolCall confirmation hooks', () => { diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index af386b8617..0b751f150a 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -7,7 +7,8 @@ import type { UserDraftItemKind } from '$lib/gen' // The gate's two refusals, from a module that holds prose and one size limit: under the // shallow-import rule below, the rest of plan mode is not reachable from here. import { PLAN_MODE_MESSAGES } from './planModeMessages' -import { NONE } from './sessionCapabilities' +import { NONE, type SessionTool } from './sessionCapabilities' +import { integrationDisplayName } from '$lib/components/resourceTypeDisplay' // Import-free leaf, so it satisfies the shallow-import rule below. import { openItemPreviewAction, @@ -45,6 +46,7 @@ import { z } from 'zod' import { ScriptService, FlowService, + IntegrationService, JobService, type Job, type CompletedJob, @@ -1515,13 +1517,22 @@ function hasOptionalProperties(schema: Record | undefined): boolean const searchHubScriptsSchema = z.object({ query: z .string() - .describe('The query to search for, e.g. send email, list stripe invoices, etc..') + .optional() + .describe( + 'What you want the script to do, e.g. "send email", "list stripe invoices". Semantic search, so describe the intent. Omit when browsing with `integration` alone.' + ), + integration: z + .string() + .optional() + .describe( + "Integration slug (e.g. \"stripe\") from a previous result's `integration` or `suggested_integrations`, or the vendor the user named. Alone, lists that integration's scripts with descriptions, including ones that do not match your task but show how the integration is used. With `query`, narrows the search to it; pass it whenever the user names the vendor, since ranking alone can surface another integration's scripts that merely mention it." + ) }) const searchHubScriptsToolDef = createToolDef( searchHubScriptsSchema, 'search_hub_scripts', - 'Search for scripts in the hub' + "Search the Windmill Hub for prebuilt integration scripts, or list one integration's scripts with `integration`." ) /** The hub resolves a script by its version id alone; the app and summary @@ -1536,29 +1547,304 @@ export function isHubPath(path: string): boolean { return path.startsWith('hub/') } +const MAX_BROWSED_HUB_SCRIPTS = 20 +const MAX_FETCHED_HUB_SCRIPTS = 3 +const MAX_SUGGESTED_INTEGRATIONS = 5 + +/** Common shape of the two hub listings. Both carry a description, but the hub has + * none for roughly a fifth of its scripts, and says so as a null rather than by + * omitting the key. */ +type HubScriptHit = { + version_id: number + app: string + summary: string + description?: string | null +} + +type HubIntegration = { name: string; displayName?: string | null; documented: boolean } + +/** The integration slugs are a large but static list, so one fetch per session + * is enough. Only matched slugs ever reach the model, never the whole list. */ +let hubIntegrationsCache: Promise | undefined + +async function fetchHubIntegrations(): Promise { + try { + const integrations = await IntegrationService.listHubIntegrations({ kind: 'script' }) + return integrations.map((i) => ({ + name: i.name, + // The hub names only the slugs its own titleisation gets wrong, so a slug it + // leaves null can still have a name here — `gcloud` is "Google Cloud" in the + // word table this resolves through. + displayName: i.display_name ?? integrationDisplayName(i.name), + // A hub predating the flag omits it, and so does one with no authored + // notes. Both mean the same thing: nothing to read beyond what the + // metadata call returns for every integration. + documented: i.documented === true + })) + } catch (err) { + console.error('Could not list hub integrations', err) + return [] + } +} + +/** Concurrent callers share one in-flight fetch. An unreachable hub must degrade to + * "no suggestions" rather than turn the search into a tool error, so a failed or + * empty response is left uncached and retried. */ +function loadHubIntegrations(): Promise { + return (hubIntegrationsCache ??= fetchHubIntegrations().then((integrations) => { + if (!integrations.length) hubIntegrationsCache = undefined + return integrations + })) +} + +/** Which integrations carry provider knowledge checked against the live API, + * lowercased because a hub slug is case-sensitive and both casings can exist. The + * mark means there is more to read, never that the call is skippable elsewhere. */ +async function documentedIntegrations(): Promise> { + const available = await loadHubIntegrations() + return new Set(available.filter((i) => i.documented).map((i) => i.name.toLowerCase())) +} + +async function suggestHubIntegrations(query: string): Promise { + const available = await loadHubIntegrations() + const tokens = query + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((t) => t.length >= 2) + // Ranked by how much of the query each name accounts for, because a vendor with + // several products shares one word across all of them: "google drive" matches + // every `g*` integration on "google", and only Google Drive on both words. The + // cap would otherwise cut the one the user named. + return available + .map(({ name, displayName }) => ({ + name, + matched: tokens.filter((t) => tokenMatchesIntegration(t, name, displayName)).length + })) + .filter(({ matched }) => matched > 0) + .sort((a, b) => b.matched - a.matched) + .map(({ name }) => name) + .slice(0, MAX_SUGGESTED_INTEGRATIONS) +} + +/** Matches a query word against an integration's slug and its name, which is what + * reaches a slug compressing the vendor to a letter: "google drive" matches `gdrive` + * only through its "Google Drive". A bare substring test + * instead makes every three-letter English word a hit — `for` in sales*for*ce, `the` + * in basis_*the*ory — so a token matches a whole word, or extends one from four + * characters on. Short tokens must equal a word, which is also what reaches the + * two-character slugs (`s3`, `wiz`) a length floor would hide. */ +function tokenMatchesIntegration( + token: string, + slug: string, + displayName?: string | null +): boolean { + const words = [slug, displayName ?? ''] + .join(' ') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + if (words.includes(token)) { + return true + } + if (token.length < 4) { + return false + } + const extends_ = (a: string, b: string) => a.startsWith(b) || b.startsWith(a) + return words.some((w) => w.length >= 4 && extends_(w, token)) +} + +export const clearHubIntegrationsCache = () => { + hubIntegrationsCache = undefined +} + +const getHubIntegrationSchema = z.object({ + integration: z + .string() + .describe( + 'Integration slug, e.g. "stripe". Take it from a search_hub_scripts result\'s `integration`, or guess the vendor name: a wrong guess comes back with the closest real slugs.' + ) +}) + +const getHubIntegrationToolDef = createToolDef( + getHubIntegrationSchema, + 'get_hub_integration', + 'Read how one integration works before writing code against it: the resource type it takes, its auth fields, and its most-used scripts as examples. For an integration a search marked `documented` it also returns provider knowledge checked against the live API: pagination, enums, error codes and gotchas.' +) + +/** Enough to show the integration's idiom; search_hub_scripts is the way to find + * a specific one. */ +const MAX_INTEGRATION_EXAMPLES = 5 + +/** How the authored notes were checked — method, per-surface confidence, the instance + * used — is provenance for a human reviewing the hub, and up to a third of the + * document. The verdict and any fields it flags as shaky are the parts a caller can + * act on, so those stay. */ +function withoutProvenance(meta: unknown): unknown { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { + return meta + } + const { validation, ...rest } = meta as Record + const kept = Object.fromEntries( + Object.entries((validation as Record | undefined) ?? {}).filter( + ([key]) => key === 'status' || key === 'low_confidence_fields' + ) + ) + return Object.keys(kept).length ? { ...rest, validation: kept } : rest +} + +/** The hub keeps a resource type's schema in a text column and hands it back as a + * JSON string, so parse it rather than passing an escaped blob to the model. + * Anything already structured goes through untouched. */ +function parseResourceTypeSchema(schema: unknown): unknown { + if (typeof schema !== 'string') { + return schema + } + try { + return JSON.parse(schema) + } catch { + return schema + } +} + +export const getHubIntegrationTool = { + requires: NONE, + def: getHubIntegrationToolDef, + // Reads one hub document over a GET and writes nothing, so planning may use it. + planModeSafe: true, + fn: async ({ args, toolId, toolCallbacks }) => { + const { integration } = getHubIntegrationSchema.parse(args) + toolCallbacks.setToolStatus(toolId, { content: `Reading the ${integration} integration...` }) + + let doc: Awaited> + try { + doc = await IntegrationService.getHubIntegrationMeta({ app: integration }) + } catch (err) { + // Only a 404 means the integration is absent — an unknown slug, or a hub + // predating the endpoint. Reporting a timeout the same way would teach the + // model that a real integration does not exist for the rest of the chat. + const absent = (err as { status?: number } | undefined)?.status === 404 + const label = absent + ? `No hub integration named ${integration}` + : `Could not reach the hub for ${integration}` + toolCallbacks.setToolStatus(toolId, { content: label }) + const suggested = await suggestHubIntegrations(integration) + return JSON.stringify({ + error: absent + ? `No hub metadata for "${integration}".` + : `Could not reach the hub for "${integration}"; it may still exist. Read its scripts instead, or try again.`, + suggested_integrations: suggested + }) + } + + toolCallbacks.setToolStatus(toolId, { content: `Read the ${doc.display_name} integration` }) + // The hub owns this response's shape and can be older than this client, so + // every section is read as optional: a hub that sends less should return less, + // not fail the call. + const derived = doc.derived + return JSON.stringify({ + integration: doc.app, + display_name: doc.display_name, + ...(doc.description ? { description: doc.description } : {}), + ...(doc.docs_url ? { docs_url: doc.docs_url } : {}), + // Authored provider knowledge and facts inferred from the scripts stay + // separate: only the former was checked against the live API. + ...(doc.meta ? { verified_provider_notes: withoutProvenance(doc.meta) } : {}), + ...(derived + ? { + observed_from_scripts: { + api_hosts: derived.api_hosts?.map((h) => h.host) ?? [], + style: derived.style, + languages: Object.keys(derived.languages ?? {}), + script_counts: derived.script_counts + } + } + : {}), + // `curated` is three-state: only an integration's own meta.json asserts it, and + // anything else means nobody has assessed the script set. Speak only for a + // positive assertion, so an unassessed integration is never characterised + // either way; `script_counts` below is the factual signal for the rest. + ...(doc.curated === true + ? { + scripts_note: + 'These scripts were pruned to idiomatic actions rather than generated one per API endpoint, so they are worth following as style examples.' + } + : {}), + resource_types: (doc.resource_types ?? []).map((rt) => ({ + name: rt.name, + ...(rt.description ? { description: rt.description } : {}), + schema: parseResourceTypeSchema(rt.schema) + })), + example_scripts: (derived?.top_scripts ?? []).slice(0, MAX_INTEGRATION_EXAMPLES).map((s) => ({ + path: s.path, + summary: s.summary, + ...(s.description ? { description: s.description } : {}), + ...(s.language ? { language: s.language } : {}) + })) + }) + } +} satisfies SessionTool<{}> + export const createSearchHubScriptsTool = (withContent: boolean = false) => ({ requires: NONE, def: searchHubScriptsToolDef, planModeSafe: true, fn: async ({ args, toolId, toolCallbacks }) => { - toolCallbacks.setToolStatus(toolId, { - content: 'Searching for hub scripts related to "' + args.query + '"...' - }) const parsedArgs = searchHubScriptsSchema.parse(args) - const scripts = await ScriptService.queryHubScripts({ - text: parsedArgs.query, - kind: 'script' - }) + // The hub's own query param is `app`; the tool exposes it as `integration`, + // since `app` already means a Windmill app everywhere else in this surface. + const { query, integration: app } = parsedArgs + if (!query && !app) { + return 'Pass query (what the script should do), integration (a slug to list), or both.' + } + const subject = query ? `"${query}"` : `the ${app} integration` + toolCallbacks.setToolStatus(toolId, { content: `Searching hub scripts for ${subject}...` }) + // Started alongside the search, not after it: this is where the model learns a + // slug exists, so it is where it has to learn how much get_hub_integration has + // to add for that slug. One cached fetch per session either way. + const documented = documentedIntegrations() + + // Listing an integration goes through the hub's top-scripts endpoint rather + // than the semantic one: it takes no query, and it applies no similarity + // floor, so the near-misses worth reading as examples of how the integration + // is used survive instead of being cut. + const scripts: HubScriptHit[] = query + ? await ScriptService.queryHubScripts({ text: query, kind: 'script', app }) + : (( + await ScriptService.getTopHubScripts({ + app, + kind: 'script', + limit: MAX_BROWSED_HUB_SCRIPTS + }) + ).asks ?? []) + + if (scripts.length === 0) { + // A whiffed search still leaves the integration browsable, which is what + // turns "no exact match" into a worked example to follow. Suggest against + // the slug too, so a browse for a misremembered one lands on the real name + // instead of dead-ending on an empty list. + const suggested = await suggestHubIntegrations([app, query].filter(Boolean).join(' ')) + toolCallbacks.setToolStatus(toolId, { content: `No hub script found for ${subject}` }) + return JSON.stringify({ results: [], suggested_integrations: suggested }) + } + // Each result costs a content fetch, so cap the fan-out when content is wanted. - const matches = withContent ? scripts.slice(0, 3) : scripts + const matches = withContent ? scripts.slice(0, MAX_FETCHED_HUB_SCRIPTS) : scripts toolCallbacks.setToolStatus(toolId, { - content: `Found ${matches.length} script${matches.length === 1 ? '' : 's'} in the hub related to "${parsedArgs.query}"` + content: `Found ${matches.length} hub script${matches.length === 1 ? '' : 's'} for ${subject}` }) + const documentedSlugs = await documented const results = await Promise.all( matches.map(async (s) => { const path = hubScriptPath(s) + const base = { + path, + summary: s.summary, + integration: s.app, + ...(documentedSlugs.has(s.app.toLowerCase()) ? { documented: true } : {}), + ...(s.description ? { description: s.description } : {}) + } if (!withContent) { - return { path, summary: s.summary } + return base } // The content fetch, not the listing above: these are the few candidates // the AI pulled to choose between, which is the closest signal we have. @@ -1567,18 +1853,17 @@ export const createSearchHubScriptsTool = (withContent: boolean = false) => ({ // get_full, not the raw content endpoint: callers are told to match the // script's language, which raw content does not carry. const hub = await ScriptService.getHubScriptByPath({ path }) - return { path, summary: s.summary, language: hub.language, content: hub.content } + return { ...base, language: hub.language, content: hub.content } } catch (err) { // One unreachable script must not sink the whole search. return { - path, - summary: s.summary, + ...base, error: `Could not fetch content: ${err instanceof Error ? err.message : String(err)}` } } }) ) - return JSON.stringify(results) + return JSON.stringify({ results }) } }) From 6fe992aebe55f2fdbe137010e06722ddf1a3cca5 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 1 Oct 2026 07:03:53 +0200 Subject: [PATCH 16/31] make RunForm schedule props optional and expect 403 for operator drafts (#11449) Co-authored-by: Claude Opus 5.5 (1M context) --- backend/windmill-api-integration-tests/tests/drafts.rs | 2 +- frontend/src/lib/components/RunForm.svelte | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/windmill-api-integration-tests/tests/drafts.rs b/backend/windmill-api-integration-tests/tests/drafts.rs index b3bac8d010..f45ffb3f8e 100644 --- a/backend/windmill-api-integration-tests/tests/drafts.rs +++ b/backend/windmill-api-integration-tests/tests/drafts.rs @@ -165,7 +165,7 @@ async fn test_update_draft_write_authorization(db: Pool) -> anyhow::Re .json(&json!({ "value": { "a": 1 } })) .send() .await?; - assert_eq!(r.status(), 401, "operator rejected"); + assert_eq!(r.status(), 403, "operator rejected"); Ok(()) } diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index e5b4e2780a..e9a96d222d 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -116,9 +116,9 @@ loading?: boolean noVariablePicker?: boolean viewKeybinding?: boolean - scheduledForStr: string | undefined - invisible_to_owner: boolean | undefined - overrideTag: string | undefined + scheduledForStr?: string | undefined + invisible_to_owner?: boolean | undefined + overrideTag?: string | undefined overrideTagNote?: string args?: Record jsonView?: boolean From 1d2e032224acfbb2d45ebfae5fd128fc743e5671 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 1 Oct 2026 00:27:09 -0700 Subject: [PATCH 17/31] feat: the raw app editor fills the band like the other three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was the one editor route still rendering its own bar under the band, so it showed two header rows and the band named the app "Apps raw" — `navPageFor`'s first-segment fallback, because nothing on the route registered an item. Its top bar now becomes the page header the way the script, flow and low-code app editors' do: the path and summary are the breadcrumb's, the file-sidebar toggle, autosave cloud, presence mark, menu, Diff, Jobs, export, AI and Deploy are the header's actions, and the path is a plain label since this editor renames from its deploy drawer. The actions render under the band, outside the subtree `RawAppEditor` declares the operating workspace in, and `OpenInSessionButton` asks that context who the acting user is — so the workspace travels with them. `operatingWorkspaceContextEntry()` hands it over without exporting the key. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/operatingWorkspace.svelte.ts | 8 + .../components/raw_apps/RawAppEditor.svelte | 4 + .../raw_apps/RawAppEditorHeader.svelte | 332 +++++++++++------- .../apps_raw/edit/[...path]/+page.svelte | 1 + 4 files changed, 218 insertions(+), 127 deletions(-) diff --git a/frontend/src/lib/components/operatingWorkspace.svelte.ts b/frontend/src/lib/components/operatingWorkspace.svelte.ts index 93a7f2c0de..70f1c63c1c 100644 --- a/frontend/src/lib/components/operatingWorkspace.svelte.ts +++ b/frontend/src/lib/components/operatingWorkspace.svelte.ts @@ -22,6 +22,14 @@ export function setOperatingWorkspace(resolve: () => string | undefined): void { setContext(KEY, outer ? () => resolve() ?? outer() : resolve) } +/** This subtree's operating workspace as an entry for a `ContextBridge` contexts map. A snippet + * handed to the page header's band renders outside the subtree that declared the workspace, so + * without this it would quietly act on the navigation workspace instead. Reads context: call + * during component initialisation. */ +export function operatingWorkspaceContextEntry(): [any, any] { + return [KEY, getContext(KEY)] +} + /** The workspace this component acts on, as a store. Reads context: call during component * initialisation. Falls back to the navigation workspace where no host set one, or where the * host's resolves to nothing. */ diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index fd0bd23c53..c7c2e8699e 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -187,6 +187,8 @@ // EditorHeader's path/breadcrumb row dropped (summary only). Used by the // session preview to save vertical room. condensedHeader?: boolean + /** True for the route's own editor: its top bar becomes the page header. */ + ownsPageHeader?: boolean } let { @@ -226,6 +228,7 @@ onRestore, onSavedNewAppPath, condensedHeader = false, + ownsPageHeader = false, version = undefined, onTakeLatest = undefined, draftBaseVersion = undefined @@ -2454,6 +2457,7 @@ sidebarCollapsed={sidebarCollapsed.val} onToggleSidebar={() => (sidebarCollapsed.val = !sidebarCollapsed.val)} {condensedHeader} + {ownsPageHeader} /> void liveEditorDraftStoragePath?: string /** Indicator-only overrides for the sessions preview: the AutosaveIndicator @@ -223,6 +229,7 @@ sidebarCollapsed = false, onToggleSidebar = undefined, condensedHeader = false, + ownsPageHeader = false, onNavigate = undefined, liveEditorDraftStoragePath = undefined, autosaveWorkspace = undefined, @@ -308,11 +315,29 @@ // Top-bar responsive collapse — container width, not viewport. let topbarWidth = $state(0) - const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720) + // In the page header the buttons share the row with the breadcrumb, so they collapse sooner: + // the trail and the summary take ~430px of the same row, and the whole group — the menu, Diff, + // Jobs, Export, AI and Deploy — is ~560px. + const compactBelow = $derived(ownsPageHeader ? 1350 : 720) + const compactTopbar = $derived.by(() => { + const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth + return w > 0 && w < compactBelow + }) + // A phone's bar holds the trail and Deploy. The file sidebar's toggle, Jobs and the export + // join the menu that compact already shows. + const phoneTopbar = $derived( + ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR + ) // Top-bar button size + bar height. Condensed (session preview) uses the // smallest well-supported unified size (`sm`) so the bar is thinner. - const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md') + // In the page header the buttons ride a 44px band, the same size the condensed session-pane + // header uses. + const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md') + + // The actions render under the band, outside the subtree RawAppEditor declared the operating + // workspace in, so it has to travel with them: OpenInSessionButton asks who the acting user is. + const headerContexts = new Map([operatingWorkspaceContextEntry()]) function closeSaveDrawer() { saveDrawerOpen = false @@ -903,121 +928,174 @@ errorByComponent={{}} /> -
- + +{:else} +
+ -
- {#if onToggleSidebar} -
+{/if} + +{#snippet sidebarToggle()} + {#if onToggleSidebar} + - {/snippet} - +{/snippet} - + (summary = v.trim())} + textClass="text-xs font-medium text-emphasis leading-tight" + class="max-w-full min-w-0" + /> +{/snippet} + +{#snippet rawAppHeaderActions()} + + {@render sidebarToggle()} + {@render autosaveIndicator()} + {@render awarenessMark()} + {@render rawAppActions()} +{/snippet} + +{#snippet rawAppActions()} + + {#snippet buttonReplacement()} + + {/snippet} + + + -
+ -
+ Diff + +
-
- -
+
({jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})
+
+ + + + + + {#if !phoneTopbar} {#snippet fallback()} - - + } + ] + : []) + ] + : undefined} + > + Deploy + +{/snippet} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 399e28f9dc..6a33c177c6 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -606,6 +606,7 @@
{ draftSync.remove() goto(`/apps_raw/edit/${savedPath}`) From f2768de00c357dd1bfcbb5a17685d38f6a85e720 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 1 Oct 2026 00:27:21 -0700 Subject: [PATCH 18/31] fix: keep the folders a half-typed path already names The trailing-separator guard gave up on `f/demo/` entirely, which dropped the whole trail to the kind chip and brought `f/demo` back on the first character of the name. The levels the path does name come back now; only the leaf waits. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/components/EditorHeader.svelte | 2 ++ .../src/lib/components/breadcrumbPath.test.ts | 13 ++++++++++--- frontend/src/lib/components/breadcrumbPath.ts | 16 ++++++++++------ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/EditorHeader.svelte b/frontend/src/lib/components/EditorHeader.svelte index 82be29b658..e0f6ac04fb 100644 --- a/frontend/src/lib/components/EditorHeader.svelte +++ b/frontend/src/lib/components/EditorHeader.svelte @@ -156,6 +156,8 @@ onPick={handlePickerSelect} /> {/each} + {/if} + {#if segments?.leaf} {@const leafKey = leafKeyFor(kind, segments.leaf.fullPath)} {@const leafParent = segments.dirs[segments.dirs.length - 1]?.fullPath} diff --git a/frontend/src/lib/components/breadcrumbPath.test.ts b/frontend/src/lib/components/breadcrumbPath.test.ts index 987403e2e2..427b153800 100644 --- a/frontend/src/lib/components/breadcrumbPath.test.ts +++ b/frontend/src/lib/components/breadcrumbPath.test.ts @@ -12,9 +12,16 @@ describe('splitItemPath', () => { }) }) - it('gives up on a path that names nothing yet, trailing separator included', () => { - // The editor binds the path live, so every keystroke reaches the breadcrumb. + it('gives up before there is a scope', () => { expect(splitItemPath('f/demo')).toBeUndefined() - expect(splitItemPath('f/demo/')).toBeUndefined() + }) + + it('keeps the folders while the name is still being typed', () => { + // The editor binds the path live, so every keystroke reaches the breadcrumb: the trail must + // not drop out and come back on the first character of the name. + expect(splitItemPath('f/demo/')).toEqual({ + dirs: [{ name: 'f/demo', fullPath: 'f/demo' }], + leaf: undefined + }) }) }) diff --git a/frontend/src/lib/components/breadcrumbPath.ts b/frontend/src/lib/components/breadcrumbPath.ts index 25834181ad..880d558360 100644 --- a/frontend/src/lib/components/breadcrumbPath.ts +++ b/frontend/src/lib/components/breadcrumbPath.ts @@ -6,21 +6,25 @@ export type PathDir = { name: string; fullPath: string } * `u/`), then each subfolder, then the item itself. Each level carries the cumulative path, * which is what the picker opens and highlights. * - * Returns undefined for anything shorter than scope + name, such as a path still being typed. + * Returns undefined before there is even a scope. The leaf is undefined while the path names a + * folder but no item yet (`f/demo/`) — the levels it does name still come back. */ -export function splitItemPath(path: string): { dirs: PathDir[]; leaf: PathDir } | undefined { +export function splitItemPath( + path: string +): { dirs: PathDir[]; leaf: PathDir | undefined } | undefined { const parts = path.split('/') if (parts.length < 3) return undefined const scope = parts.slice(0, 2).join('/') const slug = parts.slice(2) - // `f/demo/` is three parts but names nothing yet, and a leaf with an empty name draws a blank - // segment. The editor's path is bound live, so the breadcrumb sees every keystroke. - if (slug[slug.length - 1] === '') return undefined const dirs: PathDir[] = [{ name: scope, fullPath: scope }] let acc = scope for (let i = 0; i < slug.length - 1; i++) { acc = `${acc}/${slug[i]}` dirs.push({ name: slug[i], fullPath: acc }) } - return { dirs, leaf: { name: slug[slug.length - 1], fullPath: path } } + // A leaf with an empty name draws a blank segment. The editor binds the path live, so the + // breadcrumb sees every keystroke: the folders stay put while the name is being typed rather + // than the whole trail dropping out and coming back. + const name = slug[slug.length - 1] + return { dirs, leaf: name ? { name, fullPath: path } : undefined } } From 379858a97d509d4ca9c619abdcd2ec0539cb3a6d Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 1 Oct 2026 00:27:21 -0700 Subject: [PATCH 19/31] fix: pin holds the first slot, and the nav table says what it shares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning brings the sidebar's handle back, and from behind it the unpin button sat one slot over from where the pin had just been clicked — so a second click in the same place opened the sidebar instead. An operator is detached by default, which makes that the default state of the feature rather than an edge case. `NAV_PAGES` claimed one table stops a page being called one thing in the nav and another in the header, which holds for the seven entries the sidebar's main group is built from and not for the four grouped separately — `/routes` was "Triggers" in the header and "HTTP" in the nav. The comment now says which is which, and that entry is named for the page rather than its group. `sidebarPageAllowed` gets a table test: it decides what an operator sees, and its `=== true` is what a future `Option`-style default would silently change. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/components/PageHeaderBar.svelte | 57 ++++++++++--------- .../src/lib/components/sidebar/navPages.ts | 12 +++- .../components/sidebar/operatorRoutes.test.ts | 26 +++++++++ 3 files changed, 65 insertions(+), 30 deletions(-) create mode 100644 frontend/src/lib/components/sidebar/operatorRoutes.test.ts diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index d011b291fd..e75a95bf51 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -92,6 +92,36 @@ The row's height matches the sidebar's own header row, so the two read as one ba 'shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_0_-1px_0_0_#374151] [html.github-dark_&]:shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))]' )} > + + {#if onPin} + + + {/if} + + {#if onUnpin} + + {/if} + {#if (navDetached.val || panelled) && !navHidden && !hideNavHandle} - - {/if} - - {#if onUnpin} - - - {/if} - ` placeholder, and the trail would name that instead of the path Deploy + // will create. Same chain the rename sites read, and what the editor's own bar showed. + kind: 'app', + path: newEditedPath || $appPath || newPath || undefined, + summaryContent: appSummary + }} actions={appHeaderActions} contexts={headerContexts} /> @@ -979,16 +986,19 @@ {#snippet appSummary()} - ($summary = v.trim())} - textClass="text-xs font-medium text-emphasis leading-tight" - class="max-w-full min-w-0" - /> + breadcrumb, which this editor renames from its deploy drawer. `title` so a summary the + band truncates can still be read, the way the editor's own bar did it. --> +
+ ($summary = v.trim())} + textClass="text-xs font-medium text-emphasis leading-tight" + class="max-w-full min-w-0" + /> +
{/snippet} {#snippet appHeaderActions()} @@ -1213,11 +1223,10 @@
- -
- -
+ - - - - + + {#if !phoneTopbar} {#snippet fallback()} diff --git a/frontend/src/lib/components/sidebar/navPages.ts b/frontend/src/lib/components/sidebar/navPages.ts index 1c4668a229..b9dc2eff63 100644 --- a/frontend/src/lib/components/sidebar/navPages.ts +++ b/frontend/src/lib/components/sidebar/navPages.ts @@ -17,10 +17,11 @@ import type { OperatorPageKey } from './operatorRoutes' * The names the page header's breadcrumb gives the workspace's own pages, and the rows the * sidebar's main group is built from. * - * Every entry names a page for the breadcrumb. Only the entries `SidebarContent`'s - * `MAIN_MENU_EXTRAS` also lists become sidebar links from here — Schedules, HTTP routes, Workers - * and Audit logs are grouped differently there and carry their own row, so for those four the - * `icon` and `operatorKey` below go unread. Keep the labels in step with those rows by hand. + * Every entry names a page for the breadcrumb, and its `icon` goes there too. Only the entries + * `SidebarContent`'s `MAIN_MENU_EXTRAS` also lists become sidebar links from here — Schedules, + * HTTP routes, Workers and Audit logs are grouped differently there and carry their own row, so + * for those four the `operatorKey` below goes unread. Keep the labels in step with those rows by + * hand. */ export type NavPage = { label: string From 5ffb103856303d68ac559dfb2eaa1619c0441d0b Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 1 Oct 2026 00:50:56 -0700 Subject: [PATCH 21/31] fix: an item the picker files under no kind keeps its name in the band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge brought main's agents detail page, which reaches DetailPageHeader with no `errorHandlerKind` — and the band's item required one. `WorkspaceItemKindIcon` falls through to the script glyph for anything it does not know, so defaulting would have labelled an agent a script; the kind is optional now and the trail names the item without claiming it is one of the three. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/components/NavBreadcrumb.svelte | 6 ++++-- frontend/src/lib/components/details/DetailPageHeader.svelte | 4 +--- frontend/src/lib/components/pageHeaderRegistry.svelte.ts | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte index 70054e143e..22e43f1ecd 100644 --- a/frontend/src/lib/components/NavBreadcrumb.svelte +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -148,7 +148,9 @@ same weight, size and icon size, so the line reads as one control rather than fo {/snippet} {#snippet itemKindIcon()} - {#if item}{/if} + + {#if item?.kind}{/if} {/snippet} {#snippet sectionKindIcon()} @@ -304,7 +306,7 @@ same weight, size and icon size, so the line reads as one control rather than fo {/if} - {:else} + {:else if item.kind} {@render itemKindIcon()} {KIND_LABEL_LOWER[item.kind]} diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index 2be076dca0..062a2f91d0 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -35,9 +35,7 @@ // An agent's page has no triggers, and so no context for them. const triggerContext = getContext('TriggerContext') - const { triggersCount, triggersState } = $state( - triggerContext ?? ({} as Partial) - ) + const { triggersCount, triggersState } = $state(triggerContext ?? ({} as Partial)) // The buttons below render in the page header, away from this page's tree. const headerContexts = new Map([['TriggerContext', triggerContext]]) diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts index f074c6ff06..ccd954b6ec 100644 --- a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -3,7 +3,9 @@ import type { WorkspaceItemKind } from './workspacePicker' /** An item the page is working on: the breadcrumb walks its path and ends with its summary. */ export type PageHeaderItem = { - kind: WorkspaceItemKind + /** Absent for an item the picker does not file under a kind — an agent's detail page. The + * breadcrumb then names the item without claiming it is one of the three. */ + kind?: WorkspaceItemKind /** Shown whole, and copied on click. */ path: string | undefined summary?: string From 1da8d16740b9aaef2d604acf06ac6451f2a10c28 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 1 Oct 2026 00:50:56 -0700 Subject: [PATCH 22/31] fix: the file sidebar's toggle leaves the band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band is the workspace's header, not the raw editor's layout controls. ⌘B still folds the file sidebar, and the editor's own bar keeps the button where it draws one — a session pane. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/raw_apps/RawAppEditorHeader.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 567c8cebd6..38049205bc 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -323,9 +323,9 @@ const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth return w > 0 && w < compactBelow }) - // A phone's bar keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down — - // the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already - // in the menu, Jobs from `compactTopbar` (true at any phone width) and the export always. + // A phone's bar keeps the trail, Diff and Deploy. AI stands down — the sidebar's own Ask AI is + // the way in at that width — while Jobs and the export are already in the menu, Jobs from + // `compactTopbar` (true at any phone width) and the export always. const phoneTopbar = $derived( ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR ) @@ -1034,9 +1034,9 @@ {/snippet} {#snippet rawAppHeaderActions()} - - {@render sidebarToggle()} + {@render autosaveIndicator()} {@render awarenessMark()} {@render rawAppActions()} From 97834677c5094c84439054418049d3fd8d740815 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 1 Oct 2026 01:16:35 -0700 Subject: [PATCH 23/31] fix: the scroll edge stops measuring, and the comments describe the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band's edge listener captures every scroller in the app — Monaco, the audit table, a run's logs — about once a frame while any of them moves, and it took two `getBoundingClientRect()` reads before looking at `scrollTop`. It now answers from `scrollTop` first and only measures when that answer differs from what the band draws, which is the transition rather than the steady state. The rect check still authorizes every change, so a scroller that does not start at the content's top still cannot claim the edge. The two agent routes main just added size themselves to the viewport, which is 44px more than the box the band leaves them. They measure 856 either way — the layout's `min-h-0` column clamps them — so this is the sweep the rest of the branch already had, making the height follow the box rather than depend on a parent's clamp. Six comments described the conversion rather than the code, which AGENTS.md calls out by name. Each now states the constraint a reader could break: why the summary keeps its popover here, why the audit hint is `afterName` and not an action, why a run registers a `section` rather than an item, why the Runs filters are `actionsFlexible`. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/components/PageHeaderBar.svelte | 9 ++++++++- frontend/src/lib/components/RunsPage.svelte | 4 ++-- .../lib/components/apps/editor/AppEditorHeader.svelte | 2 +- .../components/apps/editor/InWorkspaceAppViewer.svelte | 2 +- .../src/lib/components/details/DetailPageHeader.svelte | 5 ++--- .../lib/components/raw_apps/RawAppEditorHeader.svelte | 2 +- .../(root)/(logged)/agents/edit/[...path]/+page.svelte | 2 +- .../(root)/(logged)/agents/get/[...path]/+page.svelte | 2 +- .../src/routes/(root)/(logged)/audit_logs/+page.svelte | 5 ++--- .../src/routes/(root)/(logged)/run/[...run]/+page.svelte | 4 ++-- 10 files changed, 21 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index e75a95bf51..b525654a61 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -64,12 +64,19 @@ The row's height matches the sidebar's own header row, so the two read as one ba if (!el || el.nodeType !== 1) return const box = document.getElementById('content') if (!box || !box.contains(el)) return + // The answer this event would give, before measuring anything. While it matches what the + // band already draws there is nothing to decide — which is the steady state of a scroll, + // and the two rects below each force a synchronous layout. This listener captures every + // scroller in the app (Monaco, the audit table, a run's logs), about once a frame while any + // of them moves, so the common path has to stay off the layout. + const next = el.scrollTop > 0 + if (next === scrolledUnder) return // Only a box that starts where the content does has anything passing under the band. The // audit page's list begins 285px lower, behind its own chart and column headers, and a // select's option list floats wherever it opens: neither moves anything under the header. const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top if (top > AT_THE_TOP_PX) return - scrolledUnder = el.scrollTop > 0 + scrolledUnder = next } // A page left while scrolled would otherwise hand its edge to the next one, which may have // nothing to scroll at all. diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index 9d1bb41b25..4cc9bf6a83 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -875,8 +875,8 @@ {:else}
- + diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 9bf35711a6..f6b2faee5d 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -946,7 +946,7 @@ item={{ // The path being edited, not the stored one: a brand-new app is parked at a // `draft_` placeholder, and the trail would name that instead of the path Deploy - // will create. Same chain the rename sites read, and what the editor's own bar showed. + // will create. Same fallback chain the rename sites read. kind: 'app', path: newEditedPath || $appPath || newPath || undefined, summaryContent: appSummary diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index 12de836650..dbc0655422 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -166,7 +166,7 @@ + floats over the canvas there instead. --> {#if showEdit && menuHidden}
{@render editAction()} diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index 062a2f91d0..7db1e37c45 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -212,9 +212,8 @@ {/each} {/snippet} - + ` placeholder, and the trail would name that instead of the path Deploy - // will create — which is why `newEditedPath` refuses the placeholder above. + // will create — which is why `newEditedPath` refuses the placeholder. kind: 'app', path: newEditedPath || appPath || newPath || undefined, summaryContent: appSummary diff --git a/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte index b4539d0a8e..f78192c34c 100644 --- a/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte @@ -17,7 +17,7 @@ }) -
+
Every flow that links {path} will fail at its agent step once it is deleted. -
+
{:else}
- + {#snippet auditHint()} diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index ed3d80bf80..48d667cc35 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -819,8 +819,8 @@ 1.5 ]} /> - + {#snippet runActions()} From 522e5ce096a608fb4b85d8fd6f3840d71b3873f0 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 1 Oct 2026 01:24:45 -0700 Subject: [PATCH 24/31] Revert "fix: the file sidebar's toggle leaves the band" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1da8d16740b9aaef2d604acf06ac6451f2a10c28. A folded file sidebar renders nothing of its own, so the toggle on the bar is the only way back short of knowing ⌘B. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/raw_apps/RawAppEditorHeader.svelte | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 5edbd2b7da..35b1d6ab56 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -323,9 +323,9 @@ const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth return w > 0 && w < compactBelow }) - // A phone's bar keeps the trail, Diff and Deploy. AI stands down — the sidebar's own Ask AI is - // the way in at that width — while Jobs and the export are already in the menu, Jobs from - // `compactTopbar` (true at any phone width) and the export always. + // A phone's bar keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down — + // the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already + // in the menu, Jobs from `compactTopbar` (true at any phone width) and the export always. const phoneTopbar = $derived( ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR ) @@ -1034,9 +1034,10 @@ {/snippet} {#snippet rawAppHeaderActions()} - + + {@render sidebarToggle()} {@render autosaveIndicator()} {@render awarenessMark()} {@render rawAppActions()} From 4f3804cf11b81ff56decbb2ea327865397dbef77 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 1 Oct 2026 01:38:15 -0700 Subject: [PATCH 25/31] feat: the editors name their item in the band, and a pen renames it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band showed each editor's summary as an inline field, which put editing where the page names what it is working on. The summary is text there now, with its `title` for one the band truncates, and the pen that opens the path for editing sits after it — the same popover the editors' own bar carries, at the end of the name it renames. Every editor already has a field for the summary itself: the script's settings, the flow's Settings tab, and both app editors' deploy drawer. The popover moves into `PathEditPopover` rather than being written out four more times. It owns the open state and hands back the path as it was when it opened: the band's trail reads that while the popover is open, so a path growing under the pointer does not drag the pen — and floating-ui the popover — along with it. `EditorHeader` renders the same component, so the session pane and the band cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/components/EditorHeader.svelte | 109 +++------------- .../src/lib/components/FlowBuilder.svelte | 44 +++++-- .../src/lib/components/PathEditPopover.svelte | 123 ++++++++++++++++++ .../src/lib/components/ScriptBuilder.svelte | 42 ++++-- .../apps/editor/AppEditorHeader.svelte | 33 +++-- .../raw_apps/RawAppEditorHeader.svelte | 38 +++--- 6 files changed, 236 insertions(+), 153 deletions(-) create mode 100644 frontend/src/lib/components/PathEditPopover.svelte diff --git a/frontend/src/lib/components/EditorHeader.svelte b/frontend/src/lib/components/EditorHeader.svelte index e0f6ac04fb..9b4aeb96e5 100644 --- a/frontend/src/lib/components/EditorHeader.svelte +++ b/frontend/src/lib/components/EditorHeader.svelte @@ -1,10 +1,7 @@ + + open, setOpen} +> + {#snippet trigger()} +