mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-15 00:02:56 +00:00
Merge branch 'main' into enable-operator-ai-chat-nav
Five files conflicted, all around the detail pages and the app viewer: - DetailPageHeader: main's #11458 renders a flow's and a script's detail page inside a session's preview pane, which has a band of its own. The header therefore claims the page header only on the route that owns it (`ownsPageHeader`), and collapses on either reason — the host saying its box is narrow, or the band measuring itself below 1150px. - DetailPageLayout: took main's, which makes the same `h-full` change and adds the `wide` snippet parameter the header now reads. - InWorkspaceAppViewer, PublicApp, sessions/+page: main's per-workspace membership, deployed-tab reloads and tab-strip padding, beside this branch's band registration and Edit placement. Two changes in the same files ride along, since they cannot be separated from the resolution: - the deployed app's header is an ordinary band again — the hover reveal, the corner handle and the pin preference are gone, and a deployed raw app now sits under a 44px band like every other page. - the agent editor carries the same pen and popover as the script, flow and app editors (summary and resource path), and an agent's deployed page claims the band instead of drawing a second header row under it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: local-review-codex
|
||||
description: Run the CI Codex PR review locally against this branch's unpushed work (committed + uncommitted) before pushing. Same policy and reasoning effort as the codex-pr-review GitHub action, on a newer model.
|
||||
description: Run the CI Codex PR review locally against this branch's unpushed work (committed + uncommitted) before pushing. Same policy, model and reasoning effort as the codex-pr-review GitHub action.
|
||||
---
|
||||
|
||||
# Local Codex Review (pre-push)
|
||||
@@ -11,18 +11,18 @@ before the PR exists. Use this before `git push` on a non-trivial change.
|
||||
|
||||
**Correspondence with CI** — identical:
|
||||
- Policy: `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test coverage).
|
||||
- Model: `gpt-6.1-sol`.
|
||||
- Reasoning effort: `model_reasoning_effort="xhigh"`.
|
||||
- Output: markdown starting with `## Codex Review`, findings tagged P0 / P1 / P2 with file:line.
|
||||
|
||||
**Differences from CI** — local-only:
|
||||
- Model is `gpt-6-astra`; CI stays on `gpt-5.6-sol`. Not an oversight to reconcile: `gpt-6-astra` is confirmed on the ChatGPT auth `codex login` uses locally, while CI authenticates with `OPENAI_API_KEY` (`codex-pr-review.yml` prefers it over `CODEX_AUTH_JSON`) and that tier is unverified for the model. Move CI once API access is confirmed, or once CI switches to `CODEX_AUTH_JSON`.
|
||||
- Scope is the current branch vs `main` at the merge-base, **including uncommitted changes** (CI reviews a pushed PR diff).
|
||||
- Sandbox is `read-only` (CI uses `danger-full-access` on an ephemeral runner). Codex reads the diff and files but cannot modify your working tree.
|
||||
- Fresh context is inherent: `codex exec` is a separate cold process, so it does not anchor on the current chat session — the same reason `local-review` insists on a subagent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `codex` CLI **>= 0.153.4** installed and authed via `codex login` (an `OPENAI_API_KEY` in the environment takes priority and may not reach `gpt-6-astra` — see the model note above). Older CLIs reject the model with "requires a newer version of Codex"; `run.sh` checks the version up front. Upgrade with `npm install --global @openai/codex@0.153.4` (may need `sudo` for a global install). This matches the pin in `.github/workflows/codex-pr-review.yml` — the CLI version is the same on both sides, only the model differs.
|
||||
- `codex` CLI **>= 0.159.3** installed and authed via `codex login` or an `OPENAI_API_KEY` in the environment (which takes priority). Older CLIs reject the model with "requires a newer version of Codex"; `run.sh` checks the version up front. Upgrade with `npm install --global @openai/codex@0.159.3` (may need `sudo` for a global install). This matches the pin in `.github/workflows/codex-pr-review.yml` — the CLI version is the same on both sides.
|
||||
- `git fetch` the base ref if it's stale, so the merge-base is accurate.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local Codex review — mirrors the .github/workflows/codex-pr-review.yml CI job,
|
||||
# but scoped to this branch's unpushed work (committed + uncommitted) so you can
|
||||
# review before pushing. Same policy (REVIEW.md) and reasoning effort (xhigh) as CI.
|
||||
#
|
||||
# The model deliberately differs from CI: gpt-6-astra is confirmed available on the
|
||||
# ChatGPT auth `codex login` uses here, but CI authenticates with OPENAI_API_KEY and
|
||||
# that tier is unverified for it, so codex-pr-review.yml stays on gpt-5.6-sol.
|
||||
# review before pushing. Same policy (REVIEW.md), model and reasoning effort (xhigh)
|
||||
# as CI.
|
||||
#
|
||||
# Usage: run.sh [BASE_REF] (BASE_REF defaults to "main")
|
||||
set -euo pipefail
|
||||
|
||||
MODEL="gpt-6-astra"
|
||||
CODEX_MIN="0.153.4"
|
||||
MODEL="gpt-6.1-sol"
|
||||
CODEX_MIN="0.159.3"
|
||||
|
||||
BASE_REF="${1:-main}"
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
mkdir -p results
|
||||
# One cheap model per provider (anthropic/openai/googleai/deepseek).
|
||||
fail=0
|
||||
for m in haiku 4o gemini-3-flash-preview deepseek-v4-flash; do
|
||||
for m in haiku gpt-6-luna gemini-3.8-flash deepseek-flash; do
|
||||
echo "::group::global-test1-script-create ($m)"
|
||||
if ! bun run cli -- run global global-test1-script-create \
|
||||
--model "$m" --execution-only --output "$PWD/results/ci-$m.json"; then
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
allowed_bots: 'windmill-internal-app[bot]'
|
||||
trigger_phrase: '/plan'
|
||||
claude_args: |
|
||||
--model claude-opus-5
|
||||
--model claude-opus-5-5
|
||||
--system-prompt "# Claude Planning Mode
|
||||
|
||||
You are operating in PLANNING MODE ONLY. Your role is to create detailed, structured plans without making any code changes.
|
||||
|
||||
@@ -93,4 +93,4 @@ jobs:
|
||||
}
|
||||
claude_args: |
|
||||
--allowedTools "Bash,WebFetch,WebSearch"
|
||||
--model claude-opus-5
|
||||
--model claude-opus-5-5
|
||||
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
|
||||
- name: Install Codex CLI
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
run: npm install --global @openai/codex@0.153.4
|
||||
run: npm install --global @openai/codex@0.159.3
|
||||
|
||||
- name: Configure Codex auth
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
# e.g. a GitHub Action's index.js, which then runs with our credentials.
|
||||
codex exec \
|
||||
-C "$GITHUB_WORKSPACE" \
|
||||
-m gpt-5.6-sol \
|
||||
-m gpt-6.1-sol \
|
||||
-c 'model_reasoning_effort="xhigh"' \
|
||||
-s "$SANDBOX_MODE" \
|
||||
-o "$RUNNER_TEMP/codex-final-message.md" \
|
||||
|
||||
@@ -208,4 +208,4 @@ jobs:
|
||||
${{ env.REVIEW_PROMPT }}
|
||||
claude_args: |
|
||||
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
|
||||
--model claude-opus-5
|
||||
--model claude-opus-5-5
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build test image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/DockerfileExtra
|
||||
|
||||
@@ -49,7 +49,7 @@ Open-source platform for internal tools, workflows, API integrations, background
|
||||
- **Backend patterns**: use the `rust-backend` skill when writing Rust code
|
||||
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill.
|
||||
- **Frontend UUIDs**: do not call `crypto.randomUUID()` in frontend code. Import `randomUUID` from `$lib/utils/uuid` instead.
|
||||
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy and `xhigh` reasoning, on `gpt-6-astra` rather than the action's `gpt-5.6-sol`; requires the `codex` CLI >= 0.153.4.
|
||||
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy, `gpt-6.1-sol` model and `xhigh` reasoning as the action; requires the `codex` CLI >= 0.159.3.
|
||||
- **Domain guides**: `.claude/skills/native-trigger/`
|
||||
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
|
||||
- **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does.
|
||||
|
||||
@@ -34,6 +34,10 @@ describe("resolveEvalModel", () => {
|
||||
|
||||
it("supports DeepSeek aliases for frontend evals", () => {
|
||||
expect(resolveEvalModel("flow", "deepseek").frontend).toEqual({
|
||||
provider: "deepseek",
|
||||
model: "deepseek-flash",
|
||||
});
|
||||
expect(resolveEvalModel("flow", "deepseek-v4-flash").frontend).toEqual({
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-flash",
|
||||
});
|
||||
|
||||
+10
-1
@@ -192,12 +192,21 @@ export const EVAL_MODELS: EvalModelSpec[] = [
|
||||
{
|
||||
id: "deepseek-v4-flash",
|
||||
label: "DeepSeek V4 Flash",
|
||||
aliases: ["deepseek", "deepseek-v4", "deepseek-v4-flash"],
|
||||
aliases: ["deepseek-v4", "deepseek-v4-flash"],
|
||||
frontend: {
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-flash",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deepseek-flash",
|
||||
label: "DeepSeek V4.1 Flash",
|
||||
aliases: ["deepseek", "deepseek-flash", "deepseek-v4.1-flash"],
|
||||
frontend: {
|
||||
provider: "deepseek",
|
||||
model: "deepseek-flash",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-pro",
|
||||
label: "DeepSeek V4 Pro",
|
||||
|
||||
+6
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1 AND workspace_id = $2\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n LEFT JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ",
|
||||
"query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1 AND workspace_id = $2\n )\n SELECT\n q.id AS \"id!\",\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n FOR UPDATE OF q\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -10,31 +10,26 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flow_status",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "suspend!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"ordinal": 2,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 3,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 4,
|
||||
"name": "is_flow_level!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"ordinal": 5,
|
||||
"name": "is_wac!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
@@ -47,7 +42,6 @@
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
@@ -55,5 +49,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092"
|
||||
"hash": "0a18205b64e68529b7f90bc1c5db2cbea9ee5eaa962970b65a92597fd5e62707"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE up AS (\n SELECT id, parent_job, kind, runnable_id, runnable_path, 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_id, p.runnable_path, u.depth + 1\n FROM v2_job p JOIN up u ON p.id = u.parent_job\n WHERE p.workspace_id = $2 AND u.kind <> 'flow' AND u.depth < 100\n )\n SELECT EXISTS (SELECT 1 FROM up WHERE kind = 'flow'\n AND runnable_id = (SELECT f.versions[array_upper(f.versions, 1)]\n FROM flow f WHERE f.path = up.runnable_path AND f.workspace_id = $2))\n AND NOT EXISTS (SELECT 1 FROM up WHERE kind <> 'flow' AND (\n EXISTS (SELECT 1 FROM v2_job_completed c WHERE c.id = up.id\n AND jsonb_typeof(c.flow_status->'restarted_from') = 'object')\n OR EXISTS (SELECT 1 FROM v2_job_status st WHERE st.id = up.id\n AND jsonb_typeof(st.flow_status->'restarted_from') = 'object')))\n AS \"current!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "current!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "141725e44ccd3f9cc0b5eb070ea3de94b89a4940901e11c48c5f2014ff28086a"
|
||||
}
|
||||
+8
-2
@@ -1,12 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path",
|
||||
"query": "DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path, hash",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "hash",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -16,8 +21,9 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "819c4d78f10979a83529155c3f101f42f6958742bc68ab384944fa3c5cba094c"
|
||||
"hash": "2d73fe76e45becbcae7a9d4384bbb2f9739cab232b8f3e357e27dc32bb35c3a8"
|
||||
}
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE lineage AS (\n SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger,\n permissioned_as, args, 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind,\n p.trigger, p.permissioned_as, p.args, l.depth + 1\n FROM v2_job p JOIN lineage l ON p.id = l.parent_job\n WHERE p.workspace_id = $2 AND l.depth < 100\n )\n SELECT id AS \"id!\", parent_job, kind AS \"kind!: JobKind\", runnable_path,\n trigger_kind::text AS trigger_kind, permissioned_as AS \"permissioned_as!\",\n -- A restart takes its flow version from the request, so a trusted flow path\n -- can carry another flow's code: the version must belong to that path.\n CASE kind\n WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv\n WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)\n WHEN 'script' THEN EXISTS (SELECT 1 FROM script s\n WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2)\n ELSE true\n END AS \"origin_verified!\",\n -- Only deployed-app runs are stamped with their app; an app editor preview\n -- runs app code at an app path it does not have to own.\n COALESCE(trigger_kind = 'app' AND starts_with(runnable_path, trigger || '/'), false)\n AS \"app_stamped!\",\n -- A preview's modules come from its args, which its parent may have taken from\n -- the caller.\n COALESCE(jsonb_typeof(args->'_MODULES') = 'object', false) AS \"args_modules!\"\n FROM lineage ORDER BY depth",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "parent_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "kind!: JobKind",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "job_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"preview",
|
||||
"flow",
|
||||
"dependencies",
|
||||
"flowpreview",
|
||||
"script_hub",
|
||||
"identity",
|
||||
"flowdependencies",
|
||||
"http",
|
||||
"graphql",
|
||||
"postgresql",
|
||||
"noop",
|
||||
"appdependencies",
|
||||
"deploymentcallback",
|
||||
"singlestepflow",
|
||||
"flowscript",
|
||||
"flownode",
|
||||
"appscript",
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "runnable_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "trigger_kind",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "permissioned_as!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "origin_verified!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "app_stamped!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "args_modules!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5176c354a8c14b8acfbcc7e32da2fcd5013543d600a07a36f3451ddb00cad6aa"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT content AS \"content!: String\",\n lock AS \"lock: String\", language AS \"language: Option<ScriptLang>\", envs AS \"envs: Vec<String>\", schema AS \"schema: String\", schema_validation AS \"schema_validation: bool\", codebase LIKE '%.tar' as use_tar, codebase LIKE '%.esm%' as is_esm, modules AS \"modules: serde_json::Value\" FROM script WHERE hash = $1 LIMIT 1",
|
||||
"query": "SELECT content AS \"content!: String\",\n lock AS \"lock: String\", language AS \"language: Option<ScriptLang>\", envs AS \"envs: Vec<String>\", schema AS \"schema: String\", schema_validation AS \"schema_validation: bool\", codebase LIKE '%.tar' as use_tar, codebase LIKE '%.esm%' as is_esm, modules AS \"modules: serde_json::Value\", deleted FROM script WHERE hash = $1 ORDER BY deleted LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -80,6 +80,11 @@
|
||||
"ordinal": 8,
|
||||
"name": "modules: serde_json::Value",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "deleted",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -96,8 +101,9 @@
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b44afcf1b9c047ac525638f0952c2cb01d65b1b46693331ac157dfca0dab6824"
|
||||
"hash": "65e33a933ed8568c82f9b4e121b9ba4280123fe22a959d4d3d978ca767aeed29"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_queue SET suspend = $1 WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "84ad6f24aa9ccdc6f6e705b7de1bb707637a9bb1475711f9f9f3f2a6e28cb169"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a35164456ade8e79cb8f5418c8fe82c45be45409f881292f0d4a3316362ba1f4"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS (SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 AND running)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ee769c78ec0a782de18911cdcb655aad93cf23321cd0f42f7ffa5348bfbea26e"
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE lineage AS (\n SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger,\n permissioned_as, args, 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind,\n p.trigger, p.permissioned_as, p.args, l.depth + 1\n FROM v2_job p JOIN lineage l ON p.id = l.parent_job\n WHERE p.workspace_id = $2 AND l.depth < 100\n )\n SELECT id AS \"id!\", parent_job, kind AS \"kind!: JobKind\", runnable_path, runnable_id,\n trigger_kind::text AS trigger_kind, permissioned_as AS \"permissioned_as!\",\n -- A restart takes its flow version from the request, so a trusted flow path\n -- can carry another flow's code: the version must belong to that path.\n CASE kind\n WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv\n WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)\n WHEN 'script' THEN EXISTS (SELECT 1 FROM script s\n WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2\n AND NOT s.deleted)\n ELSE true\n END AS \"origin_verified!\",\n -- What `run/f` and `run/p` resolve the path to now; for scripts the predicate of\n -- `get_latest_deployed_script_hash`.\n COALESCE(CASE kind\n WHEN 'flow' THEN runnable_id = (SELECT f.versions[array_upper(f.versions, 1)]\n FROM flow f WHERE f.path = runnable_path AND f.workspace_id = $2)\n WHEN 'script' THEN runnable_id = (SELECT s.hash FROM script s\n WHERE s.path = runnable_path AND s.workspace_id = $2 AND NOT s.deleted\n AND s.lock IS NOT NULL AND s.lock_error_logs IS NULL\n ORDER BY s.created_at DESC LIMIT 1)\n END, false) AS \"current_version!\",\n EXISTS (SELECT 1 FROM v2_job_status st WHERE st.id = lineage.id\n AND jsonb_typeof(st.flow_status->'restarted_from') = 'object')\n OR EXISTS (SELECT 1 FROM v2_job_completed c WHERE c.id = lineage.id\n AND jsonb_typeof(c.flow_status->'restarted_from') = 'object')\n AS \"restarted!\",\n COALESCE(\n (SELECT st.flow_status->'restarted_from'->>'flow_job_id' FROM v2_job_status st\n WHERE st.id = lineage.id),\n (SELECT c.flow_status->'restarted_from'->>'flow_job_id' FROM v2_job_completed c\n WHERE c.id = lineage.id)\n ) AS restarted_from,\n -- Only deployed-app runs are stamped with their app; an app editor preview\n -- runs app code at an app path it does not have to own.\n COALESCE(trigger_kind = 'app' AND starts_with(runnable_path, trigger || '/'), false)\n AS \"app_stamped!\",\n -- A preview's modules come from its args, which its parent may have taken from\n -- the caller.\n COALESCE(jsonb_typeof(args->'_MODULES') = 'object', false) AS \"args_modules!\"\n FROM lineage ORDER BY depth",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "parent_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "kind!: JobKind",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "job_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"preview",
|
||||
"flow",
|
||||
"dependencies",
|
||||
"flowpreview",
|
||||
"script_hub",
|
||||
"identity",
|
||||
"flowdependencies",
|
||||
"http",
|
||||
"graphql",
|
||||
"postgresql",
|
||||
"noop",
|
||||
"appdependencies",
|
||||
"deploymentcallback",
|
||||
"singlestepflow",
|
||||
"flowscript",
|
||||
"flownode",
|
||||
"appscript",
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "runnable_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "runnable_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "trigger_kind",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "permissioned_as!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "origin_verified!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "current_version!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "restarted!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "restarted_from",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "app_stamped!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "args_modules!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ee92100fcd681515ba81f23970e64af7f33464879bef4d1eff682126dffeef64"
|
||||
}
|
||||
+4
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
"query": "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING hash",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
"name": "hash",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -19,5 +19,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0e7d95f4913e5775651971d741a3b5c1ef5dfe079be5325abe2866d39a7fe5fb"
|
||||
"hash": "fa85a22c77844bb2c538b802b0984d4d69aecdd8a65db02e9495b0d6011c5c1f"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
40ac1c5f8cbce3843b582d9b392d3f3cc7eca3e6
|
||||
ff4d04f17721d84fb9a1f655af50ff7e4e7dcbc7
|
||||
|
||||
+86
-28
@@ -18,7 +18,32 @@
|
||||
"slack": {
|
||||
"auth_url": "https://slack.com/oauth/v2/authorize",
|
||||
"token_url": "https://slack.com/api/oauth.v2.access",
|
||||
"scopes": ["chat:write", "chat:write.public", "channels:join", "files:write"]
|
||||
"scopes": [
|
||||
"chat:write",
|
||||
"chat:write.public",
|
||||
"channels:join",
|
||||
"files:write",
|
||||
"users:read",
|
||||
"users:read.email",
|
||||
"channels:read",
|
||||
"channels:history"
|
||||
],
|
||||
"user_scopes": [
|
||||
"channels:history",
|
||||
"groups:history",
|
||||
"im:history",
|
||||
"mpim:history",
|
||||
"search:read",
|
||||
"users:read",
|
||||
"chat:write",
|
||||
"users:read.email",
|
||||
"channels:read",
|
||||
"groups:read",
|
||||
"im:read",
|
||||
"mpim:read",
|
||||
"files:read"
|
||||
],
|
||||
"token_response_path": "authed_user"
|
||||
},
|
||||
"supabase_wizard": {
|
||||
"auth_url": "https://api.supabase.com/v1/oauth/authorize",
|
||||
@@ -30,8 +55,8 @@
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/spreadsheets"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/spreadsheets.readonly"
|
||||
{ "value": "https://www.googleapis.com/auth/spreadsheets", "label": "Read, edit, create and delete spreadsheets" },
|
||||
{ "value": "https://www.googleapis.com/auth/spreadsheets.readonly", "label": "Read spreadsheets" }
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
@@ -43,9 +68,9 @@
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/drive"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/drive.file",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/drive"
|
||||
{ "value": "https://www.googleapis.com/auth/drive.file", "label": "Only files created or opened with this connection" },
|
||||
{ "value": "https://www.googleapis.com/auth/drive.readonly", "label": "Read all Drive files" },
|
||||
{ "value": "https://www.googleapis.com/auth/drive", "label": "Read, edit, create and delete all Drive files" }
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
@@ -57,11 +82,11 @@
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/gmail.send"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.compose",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/gmail.labels"
|
||||
{ "value": "https://www.googleapis.com/auth/gmail.send", "label": "Send mail only" },
|
||||
{ "value": "https://www.googleapis.com/auth/gmail.readonly", "label": "Read and search mail" },
|
||||
{ "value": "https://www.googleapis.com/auth/gmail.compose", "label": "Draft and send mail" },
|
||||
{ "value": "https://www.googleapis.com/auth/gmail.modify", "label": "Read, search, label, draft and send mail" },
|
||||
{ "value": "https://www.googleapis.com/auth/gmail.labels", "label": "Create, edit and delete labels" }
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
@@ -73,10 +98,10 @@
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/calendar.events"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/calendar.events.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
"https://www.googleapis.com/auth/calendar"
|
||||
{ "value": "https://www.googleapis.com/auth/calendar.events", "label": "Read and edit events on all calendars" },
|
||||
{ "value": "https://www.googleapis.com/auth/calendar.events.readonly", "label": "Read events on all calendars" },
|
||||
{ "value": "https://www.googleapis.com/auth/calendar.readonly", "label": "Read all calendars and events" },
|
||||
{ "value": "https://www.googleapis.com/auth/calendar", "label": "Read, edit, share and delete all calendars" }
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
@@ -88,10 +113,43 @@
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/forms"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/forms",
|
||||
"https://www.googleapis.com/auth/forms.body",
|
||||
"https://www.googleapis.com/auth/forms.body.readonly",
|
||||
"https://www.googleapis.com/auth/forms.responses.readonly"
|
||||
{ "value": "https://www.googleapis.com/auth/forms", "label": "Read and manage forms in Drive" },
|
||||
{ "value": "https://www.googleapis.com/auth/forms.body", "label": "Read, edit, create and delete forms" },
|
||||
{ "value": "https://www.googleapis.com/auth/forms.body.readonly", "label": "Read forms" },
|
||||
{ "value": "https://www.googleapis.com/auth/forms.responses.readonly", "label": "Read form responses" }
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
}
|
||||
},
|
||||
"gdocs": {
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/documents"],
|
||||
"scope_options": [
|
||||
{ "value": "https://www.googleapis.com/auth/documents", "label": "Read, edit, create and delete documents" },
|
||||
{ "value": "https://www.googleapis.com/auth/documents.readonly", "label": "Read documents" },
|
||||
{ "value": "https://www.googleapis.com/auth/drive.file", "label": "Only files created or opened with this connection" }
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
}
|
||||
},
|
||||
"gchat": {
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": [
|
||||
"https://www.googleapis.com/auth/chat.spaces.readonly",
|
||||
"https://www.googleapis.com/auth/chat.messages.create"
|
||||
],
|
||||
"scope_options": [
|
||||
{ "value": "https://www.googleapis.com/auth/chat.spaces.readonly", "label": "Read spaces" },
|
||||
{ "value": "https://www.googleapis.com/auth/chat.messages", "label": "Read, send, edit and delete messages" },
|
||||
{ "value": "https://www.googleapis.com/auth/chat.messages.readonly", "label": "Read messages" },
|
||||
{ "value": "https://www.googleapis.com/auth/chat.messages.create", "label": "Send messages only" },
|
||||
{ "value": "https://www.googleapis.com/auth/chat.memberships.readonly", "label": "Read space members" }
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
@@ -103,8 +161,8 @@
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/cloud-platform"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/cloud-platform.read-only"
|
||||
{ "value": "https://www.googleapis.com/auth/cloud-platform", "label": "Read and manage all Google Cloud resources" },
|
||||
{ "value": "https://www.googleapis.com/auth/cloud-platform.read-only", "label": "Read Google Cloud resources" }
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
@@ -121,13 +179,13 @@
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit"
|
||||
],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/admin.directory.user",
|
||||
"https://www.googleapis.com/auth/admin.directory.user.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.group",
|
||||
"https://www.googleapis.com/auth/admin.directory.group.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit",
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.user.security"
|
||||
{ "value": "https://www.googleapis.com/auth/admin.directory.user", "label": "Read and manage users" },
|
||||
{ "value": "https://www.googleapis.com/auth/admin.directory.user.readonly", "label": "Read users" },
|
||||
{ "value": "https://www.googleapis.com/auth/admin.directory.group", "label": "Read and manage groups" },
|
||||
{ "value": "https://www.googleapis.com/auth/admin.directory.group.readonly", "label": "Read groups" },
|
||||
{ "value": "https://www.googleapis.com/auth/admin.directory.orgunit", "label": "Read and manage organizational units" },
|
||||
{ "value": "https://www.googleapis.com/auth/admin.directory.orgunit.readonly", "label": "Read organizational units" },
|
||||
{ "value": "https://www.googleapis.com/auth/admin.directory.user.security", "label": "Manage user security settings" }
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
|
||||
@@ -1232,6 +1232,11 @@ Windmill Community Edition {GIT_VERSION}
|
||||
default_base_internal_url.clone()
|
||||
};
|
||||
|
||||
// Deletions are delivered by `notify_event` to running processes only, so one that
|
||||
// was down when a version was deleted would keep its code on disk: start from an
|
||||
// empty script cache, refilled from the database.
|
||||
windmill_common::cache::script::clear();
|
||||
|
||||
initial_load(
|
||||
&conn,
|
||||
killpill_tx.clone(),
|
||||
@@ -1884,6 +1889,12 @@ async fn process_notify_event(
|
||||
);
|
||||
windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE.remove(payload);
|
||||
}
|
||||
windmill_common::SCRIPT_VERSION_DELETED_CHANNEL => {
|
||||
match serde_json::from_str(payload) {
|
||||
Ok(deleted) => windmill_api_scripts::scripts::evict_deleted_script_versions(deleted),
|
||||
Err(e) => tracing::error!("Invalid script version deletion payload {payload}: {e}"),
|
||||
}
|
||||
}
|
||||
"notify_runnable_version_change" => {
|
||||
tracing::info!("Runnable version change detected: {}", payload);
|
||||
match payload.split(':').collect::<Vec<&str>>().as_slice() {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Script data is cached by hash, and the hash leaves out the workspace, so forks and clones
|
||||
//! share it: a deleted copy in one workspace must not stop the live copy in another.
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::{cache, scripts::ScriptHash, worker::Connection};
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn a_deleted_copy_does_not_shadow_a_live_one(db: Pool<Postgres>) {
|
||||
const SHARED: i64 = 0x5de1_e7ed_0002;
|
||||
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('fork', 'fork', 'test-user')")
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO script (workspace_id, hash, path, summary, description, content,
|
||||
created_by, language, lock, archived, deleted)
|
||||
VALUES ('fork', $1, 'f/infra/tool', '', '', '', 'test-user', 'bash', '', true, true),
|
||||
('test-workspace', $1, 'f/infra/tool', '', '', 'echo shared', 'test-user',
|
||||
'bash', '', false, false)",
|
||||
)
|
||||
.bind(SHARED)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let conn = Connection::Sql(db.clone());
|
||||
let (data, _) = cache::script::fetch(&conn, ScriptHash(SHARED))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(data.code, "echo shared");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn a_large_deletion_is_split_across_events(db: Pool<Postgres>) {
|
||||
let deleted = windmill_common::DeletedScriptVersions::new(
|
||||
"test-workspace",
|
||||
(0..501).map(|h| (format!("f/infra/s{}", h % 2), h)),
|
||||
);
|
||||
let mut conn = db.acquire().await.unwrap();
|
||||
deleted.notify(&mut conn).await.unwrap();
|
||||
|
||||
let events: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT payload FROM notify_event WHERE channel = $1 ORDER BY id",
|
||||
)
|
||||
.bind(windmill_common::SCRIPT_VERSION_DELETED_CHANNEL)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
let events: Vec<windmill_common::DeletedScriptVersions> =
|
||||
events.iter().map(|e| serde_json::from_str(e).unwrap()).collect();
|
||||
assert_eq!(events.len(), 2);
|
||||
let hashes: Vec<i64> = events.iter().flat_map(|e| e.hashes.clone()).collect();
|
||||
let paths: Vec<String> = events.iter().flat_map(|e| e.paths.clone()).collect();
|
||||
assert_eq!(hashes, deleted.hashes);
|
||||
assert_eq!(paths, deleted.paths);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
-- Add-on fixture for job_provenance.rs (combine with `base.sql` and `job_provenance.sql`):
|
||||
-- past versions of f/t/tool and f/t/agent, a deleted version of f/t/tool, and flow steps
|
||||
-- of f/t/agent under a regular run and under restarts.
|
||||
|
||||
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, created_at, archived)
|
||||
VALUES ('test-workspace', 1111111110, 'f/t/tool', 'echo old tool', 'bash', 'script', 'test-user', '{}', '', '', '', NOW() - interval '1 day', true);
|
||||
|
||||
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, created_at, archived, deleted)
|
||||
VALUES ('test-workspace', 1111111109, 'f/t/tool', '', 'bash', 'script', 'test-user', '{}', '', '', '', NOW() - interval '2 days', true, true);
|
||||
|
||||
INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by, created_at)
|
||||
VALUES (2222222221, 'test-workspace', 'f/t/agent', '{"modules":[]}', '{}', 'test-user', NOW() - interval '1 day');
|
||||
|
||||
UPDATE flow SET versions = ARRAY[2222222221::bigint, 2222222222::bigint]
|
||||
WHERE workspace_id = 'test-workspace' AND path = 'f/t/agent';
|
||||
|
||||
INSERT INTO v2_job (id, workspace_id, kind, runnable_path, runnable_id, parent_job, created_by, permissioned_as, permissioned_as_email)
|
||||
VALUES
|
||||
-- the past tool version, run under the current flow version
|
||||
('3bb0c0de-0000-4000-8000-000000000101', 'test-workspace', 'script', 'f/t/tool', 1111111110, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
-- the current tool version, run under the past flow version
|
||||
('3bb0c0de-0000-4000-8000-000000000102', 'test-workspace', 'flow', 'f/t/agent', 2222222221, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
('3bb0c0de-0000-4000-8000-000000000103', 'test-workspace', 'script', 'f/t/tool', 1111111111, '3bb0c0de-0000-4000-8000-000000000102', 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
-- the deleted tool version
|
||||
('3bb0c0de-0000-4000-8000-000000000104', 'test-workspace', 'script', 'f/t/tool', 1111111109, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
-- under the current flow version: an inline step, a loop body and a step of that body
|
||||
('3bb0c0de-0000-4000-8000-000000000105', 'test-workspace', 'flowscript', 'f/t/agent/a', 4444444442, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
('3bb0c0de-0000-4000-8000-000000000106', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444444, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
('3bb0c0de-0000-4000-8000-000000000107', 'test-workspace', 'flowscript', 'f/t/agent/loop-0/b', 4444444446, '3bb0c0de-0000-4000-8000-000000000106', 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
-- an inline step under the past flow version
|
||||
('3bb0c0de-0000-4000-8000-000000000108', 'test-workspace', 'flowscript', 'f/t/agent/a', 4444444441, '3bb0c0de-0000-4000-8000-000000000102', 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
-- a loop body restarted on its own, and a step of it
|
||||
('3bb0c0de-0000-4000-8000-000000000109', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444443, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
('3bb0c0de-0000-4000-8000-000000000110', 'test-workspace', 'flowscript', 'f/t/agent/loop-0/b', 4444444449, '3bb0c0de-0000-4000-8000-000000000109', 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
-- a body kept by a nested restart under the current flow version, and a step of it
|
||||
('3bb0c0de-0000-4000-8000-000000000111', 'test-workspace', 'flownode', 'f/t/agent/branchone-1', 4444444445, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
('3bb0c0de-0000-4000-8000-000000000112', 'test-workspace', 'flowscript', 'f/t/agent/branchone-1/c', 4444444447, '3bb0c0de-0000-4000-8000-000000000111', 'test-user', 'u/test-user', 'test@windmill.dev');
|
||||
|
||||
-- An inline body (a flow deployed before flow nodes runs it as a preview) kept by a nested
|
||||
-- restart under the current flow version, and a step of it.
|
||||
INSERT INTO v2_job (id, workspace_id, kind, runnable_path, parent_job, created_by, permissioned_as, permissioned_as_email)
|
||||
VALUES
|
||||
('3bb0c0de-0000-4000-8000-000000000113', 'test-workspace', 'flowpreview', 'f/t/agent/branchone-0', '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
('3bb0c0de-0000-4000-8000-000000000114', 'test-workspace', 'preview', 'f/t/agent/branchone-0/c', '3bb0c0de-0000-4000-8000-000000000113', 'test-user', 'u/test-user', 'test@windmill.dev');
|
||||
|
||||
-- The restarted bodies: two still running, one completed.
|
||||
INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running)
|
||||
VALUES
|
||||
('3bb0c0de-0000-4000-8000-000000000111', 'test-workspace', NOW(), true),
|
||||
('3bb0c0de-0000-4000-8000-000000000113', 'test-workspace', NOW(), true);
|
||||
|
||||
INSERT INTO v2_job_status (id, flow_status)
|
||||
VALUES
|
||||
('3bb0c0de-0000-4000-8000-000000000111',
|
||||
'{"restarted_from": {"flow_job_id": "3bb0c0de-0000-4000-8000-0000000000ff", "step_id": "br"}}'),
|
||||
('3bb0c0de-0000-4000-8000-000000000113',
|
||||
'{"restarted_from": {"flow_job_id": "3bb0c0de-0000-4000-8000-0000000000fd", "step_id": "br"}}');
|
||||
|
||||
INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, flow_status)
|
||||
VALUES ('3bb0c0de-0000-4000-8000-000000000109', 'test-workspace', 0, 'success',
|
||||
'{"restarted_from": {"flow_job_id": "3bb0c0de-0000-4000-8000-0000000000fe", "step_id": "l"}}');
|
||||
|
||||
-- Loop bodies restarted on their own from completed runs: one of the current flow version,
|
||||
-- one of the past version, and one that was itself a restart; with a step of the first.
|
||||
INSERT INTO v2_job (id, workspace_id, kind, runnable_path, runnable_id, parent_job, created_by, permissioned_as, permissioned_as_email)
|
||||
VALUES
|
||||
('3bb0c0de-0000-4000-8000-000000000115', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444444, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
('3bb0c0de-0000-4000-8000-000000000116', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444444, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
('3bb0c0de-0000-4000-8000-000000000117', 'test-workspace', 'flowscript', 'f/t/agent/loop-0/b', 4444444446, '3bb0c0de-0000-4000-8000-000000000116', 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
('3bb0c0de-0000-4000-8000-000000000118', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444443, '3bb0c0de-0000-4000-8000-000000000102', 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
('3bb0c0de-0000-4000-8000-000000000119', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444443, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'),
|
||||
('3bb0c0de-0000-4000-8000-000000000120', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444443, NULL, 'test-user', 'u/test-user', 'test@windmill.dev');
|
||||
|
||||
INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status)
|
||||
VALUES
|
||||
('3bb0c0de-0000-4000-8000-000000000115', 'test-workspace', 0, 'success'),
|
||||
('3bb0c0de-0000-4000-8000-000000000118', 'test-workspace', 0, 'success');
|
||||
|
||||
INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running)
|
||||
VALUES
|
||||
('3bb0c0de-0000-4000-8000-000000000116', 'test-workspace', NOW(), true),
|
||||
('3bb0c0de-0000-4000-8000-000000000119', 'test-workspace', NOW(), true),
|
||||
('3bb0c0de-0000-4000-8000-000000000120', 'test-workspace', NOW(), true);
|
||||
|
||||
INSERT INTO v2_job_status (id, flow_status)
|
||||
VALUES
|
||||
('3bb0c0de-0000-4000-8000-000000000116',
|
||||
'{"restarted_from": {"flow_job_id": "3bb0c0de-0000-4000-8000-000000000115", "step_id": "b"}}'),
|
||||
('3bb0c0de-0000-4000-8000-000000000119',
|
||||
'{"restarted_from": {"flow_job_id": "3bb0c0de-0000-4000-8000-000000000118", "step_id": "b"}}'),
|
||||
('3bb0c0de-0000-4000-8000-000000000120',
|
||||
'{"restarted_from": {"flow_job_id": "3bb0c0de-0000-4000-8000-000000000109", "step_id": "b"}}');
|
||||
@@ -3,8 +3,9 @@
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_common::job_provenance::{
|
||||
job_provenance, ClaimedItem, ClaimedPath, JobProvenance,
|
||||
use windmill_common::{
|
||||
job_provenance::{job_provenance, ClaimedItem, ClaimedPath, JobProvenance},
|
||||
scripts::ScriptHash,
|
||||
};
|
||||
|
||||
async fn provenance(db: &Pool<Postgres>, job: &str) -> JobProvenance {
|
||||
@@ -101,3 +102,51 @@ async fn claimed_paths_are_the_request_supplied_origins(db: Pool<Postgres>) {
|
||||
[claim("f/prod/deploy", ClaimedItem::Script)]
|
||||
);
|
||||
}
|
||||
|
||||
/// Any past version can be run by hash or flow version id: it stays deployed, but only
|
||||
/// the versions the paths currently resolve to are latest.
|
||||
#[sqlx::test(fixtures("base", "job_provenance", "job_provenance_versions"))]
|
||||
async fn only_current_versions_are_latest(db: Pool<Postgres>) {
|
||||
let current = provenance(&db, "3bb0c0de-0000-4000-8000-000000000002").await;
|
||||
assert!(current.deployed && current.latest);
|
||||
let current_hash = ScriptHash(1111111111).to_string();
|
||||
assert_eq!(current.version.as_deref(), Some(current_hash.as_str()));
|
||||
assert_eq!(current.root_version.as_deref(), Some("2222222222"));
|
||||
|
||||
let past_tool = provenance(&db, "3bb0c0de-0000-4000-8000-000000000101").await;
|
||||
assert!(past_tool.deployed && !past_tool.latest);
|
||||
|
||||
let under_past_flow = provenance(&db, "3bb0c0de-0000-4000-8000-000000000103").await;
|
||||
assert!(under_past_flow.deployed && !under_past_flow.latest);
|
||||
assert_eq!(under_past_flow.root_version.as_deref(), Some("2222222221"));
|
||||
|
||||
assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000104").await);
|
||||
}
|
||||
|
||||
/// Flow steps run what the version of the flow above them defines, except under a
|
||||
/// restart, which rebuilds a body from a past run, under the current flow version or with
|
||||
/// no flow above it at all: that body is current only if the run it came from was.
|
||||
#[sqlx::test(fixtures("base", "job_provenance", "job_provenance_versions"))]
|
||||
async fn flow_steps_are_latest_only_under_a_current_unrestarted_flow(db: Pool<Postgres>) {
|
||||
for (job, latest) in [
|
||||
("105", true),
|
||||
("106", true),
|
||||
("107", true),
|
||||
("108", false),
|
||||
("109", false),
|
||||
("110", false),
|
||||
("111", false),
|
||||
("112", false),
|
||||
("00a", true),
|
||||
("113", false),
|
||||
("114", false),
|
||||
("116", true),
|
||||
("117", true),
|
||||
("119", false),
|
||||
("120", false),
|
||||
] {
|
||||
let p = provenance(&db, &format!("3bb0c0de-0000-4000-8000-000000000{job}")).await;
|
||||
assert!(p.deployed, "job {job}");
|
||||
assert_eq!(p.latest, latest, "job {job}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,4 +961,118 @@ mod suspend_resume {
|
||||
assert_eq!(result, json!(42));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Approvals that arrive concurrently for a step waiting on several events must each
|
||||
/// decrement the suspend counter; a lost decrement parks the flow until its timeout.
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn concurrent_approvals_all_decrement_suspend(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
const APPROVALS: u32 = 8;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let flow: FlowValue = serde_json::from_value(json!({
|
||||
"modules": [
|
||||
{
|
||||
"id": "a",
|
||||
"suspend": { "required_events": APPROVALS, "timeout": 86400 },
|
||||
"value": {
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export async function main() { return 1 }",
|
||||
"input_transforms": {},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"value": {
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main(resumes) { return resumes.length }",
|
||||
"input_transforms": {
|
||||
"resumes": { "type": "javascript", "expr": "resumes" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let flow =
|
||||
RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
let completed = listen_for_completed_jobs(&db).await;
|
||||
let queue = listen_for_queue(&db).await;
|
||||
let db_ = db.clone();
|
||||
|
||||
in_test_worker(
|
||||
&db,
|
||||
async move {
|
||||
let db = db_;
|
||||
wait_until_flow_suspends(flow, queue, &db).await;
|
||||
|
||||
let step: Uuid = sqlx::query_scalar(
|
||||
"SELECT (flow_status->'modules'->0->>'job')::uuid \
|
||||
FROM v2_job_status WHERE id = $1",
|
||||
)
|
||||
.bind(flow)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let token = windmill_common::auth::create_token_for_owner(
|
||||
&db, "test-workspace", "u/test-user", "", 100, "", &Uuid::nil(), None, None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let client = reqwest::Client::new();
|
||||
let mut urls = vec![];
|
||||
for resume_id in 0..APPROVALS {
|
||||
let secret = client
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/jobs/job_signature/{step}/{resume_id}?token={token}"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
urls.push(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/jobs_u/resume/{step}/{resume_id}/{secret}"
|
||||
));
|
||||
}
|
||||
|
||||
let responses = futures::future::join_all(
|
||||
urls.iter().map(|url| client.post(url).json(&json!(null)).send()),
|
||||
)
|
||||
.await;
|
||||
for r in responses {
|
||||
r.unwrap().error_for_status().unwrap();
|
||||
}
|
||||
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
completed.find(&flow),
|
||||
)
|
||||
.await
|
||||
.expect("flow stayed suspended after every approval was recorded")
|
||||
.unwrap();
|
||||
},
|
||||
port,
|
||||
)
|
||||
.await;
|
||||
|
||||
server.close().await.unwrap();
|
||||
|
||||
let result = completed_job(flow, &db).await.json_result().unwrap();
|
||||
assert_eq!(result, json!(APPROVALS));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,17 @@ fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
async fn last_script_deletion(db: &Pool<Postgres>) -> windmill_common::DeletedScriptVersions {
|
||||
let payload: String = sqlx::query_scalar(
|
||||
"SELECT payload FROM notify_event WHERE channel = $1 ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(windmill_common::SCRIPT_VERSION_DELETED_CHANNEL)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_str(&payload).unwrap()
|
||||
}
|
||||
|
||||
async fn authed_get(port: u16, endpoint: &str, path: &str) -> reqwest::Response {
|
||||
authed(client().get(script_url(port, endpoint, path)))
|
||||
.send()
|
||||
@@ -366,6 +377,12 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
// Every process evicts deleted versions from its script caches on this event.
|
||||
let deleted = last_script_deletion(&db).await;
|
||||
let another: windmill_common::scripts::ScriptHash =
|
||||
serde_json::from_value(json!(another_hash))?;
|
||||
assert_eq!(deleted.paths, ["u/test-user/another_script"]);
|
||||
assert_eq!(deleted.hashes, [another.0]);
|
||||
|
||||
// --- delete_bulk ---
|
||||
let resp = authed(client().delete(format!("{base}/delete_bulk")))
|
||||
@@ -374,6 +391,9 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "delete_bulk: {}", resp.text().await?);
|
||||
let deleted = last_script_deletion(&db).await;
|
||||
assert_eq!(deleted.paths, ["u/test-user/test_script"]);
|
||||
assert!(!deleted.hashes.is_empty(), "{deleted:?}");
|
||||
|
||||
let resp = authed_get(port, "exists/p", "u/test-user/test_script").await;
|
||||
assert_eq!(resp.json::<bool>().await?, false);
|
||||
|
||||
@@ -17,7 +17,7 @@ use windmill_common::{
|
||||
utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT},
|
||||
webhook::{WebhookMessage, WebhookShared},
|
||||
workspaces::{check_deploy_rules, RuleCheckResult},
|
||||
DB,
|
||||
DeletedScriptVersions, DB,
|
||||
};
|
||||
use windmill_queue::schedule::clear_schedule;
|
||||
|
||||
@@ -818,6 +818,16 @@ fn invalidate_script_path_caches(w_id: &str, script_path: &str) {
|
||||
RAW_SCRIPT_LATEST_HASH_CACHE.remove(&format!("{w_id}:{script_path}"));
|
||||
}
|
||||
|
||||
/// [`DeletedScriptVersions::evict`], plus this crate's own path -> hash cache. Run by the
|
||||
/// deleting process after its commit and by every process on the deletion event.
|
||||
pub fn evict_deleted_script_versions(deleted: DeletedScriptVersions) {
|
||||
deleted.evict_with(|deleted| {
|
||||
for path in &deleted.paths {
|
||||
RAW_SCRIPT_LATEST_HASH_CACHE.remove(&format!("{}:{path}", deleted.workspace_id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What a script deploy still has to do once its transaction has committed.
|
||||
enum PostCommitDeploy {
|
||||
/// Everything, for a deploy with no dependency job to hand it to.
|
||||
@@ -4161,6 +4171,8 @@ async fn delete_script_by_hash(
|
||||
// the script was never a pipeline member.
|
||||
clear_script_triggers(&mut *tx, &w_id, &script.path, AssetUsageKind::Script).await?;
|
||||
clear_macro_registry(&mut *tx, &w_id, &script.path).await?;
|
||||
let deleted = DeletedScriptVersions::new(&w_id, [(script.path.clone(), hash.0)]);
|
||||
deleted.notify(&mut *tx).await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -4173,6 +4185,7 @@ async fn delete_script_by_hash(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
evict_deleted_script_versions(deleted);
|
||||
|
||||
webhook.send_message(
|
||||
w_id.clone(),
|
||||
@@ -4240,14 +4253,24 @@ async fn delete_script_by_path(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let script = sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
let deleted_hashes = sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING hash",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?;
|
||||
if deleted_hashes.is_empty() {
|
||||
return Err(Error::NotFound(format!(
|
||||
"script {path} not found in {w_id}"
|
||||
)));
|
||||
}
|
||||
let script = path.to_string();
|
||||
let deleted = DeletedScriptVersions::new(
|
||||
&w_id,
|
||||
deleted_hashes.into_iter().map(|h| (script.clone(), h)),
|
||||
);
|
||||
|
||||
// After the DELETE, never before: every dbt writer locks the `script` row
|
||||
// first, so taking a sidecar ahead of it deadlocks one of the pair. The
|
||||
@@ -4287,6 +4310,7 @@ async fn delete_script_by_path(
|
||||
// the script was never a pipeline member.
|
||||
clear_script_triggers(&mut *tx, &w_id, path, AssetUsageKind::Script).await?;
|
||||
clear_macro_registry(&mut *tx, &w_id, path).await?;
|
||||
deleted.notify(&mut *tx).await?;
|
||||
|
||||
if !query.keep_captures.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
@@ -4317,6 +4341,7 @@ async fn delete_script_by_path(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
evict_deleted_script_versions(deleted);
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
@@ -4412,14 +4437,21 @@ async fn delete_scripts_bulk(
|
||||
}
|
||||
}
|
||||
|
||||
let mut deleted_paths = sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path",
|
||||
w_id,
|
||||
&request.paths
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("deleting scripts in bulk {w_id}: {e:#}")))?;
|
||||
let deleted = DeletedScriptVersions::new(
|
||||
&w_id,
|
||||
sqlx::query!(
|
||||
"DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path, hash",
|
||||
w_id,
|
||||
&request.paths
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("deleting scripts in bulk {w_id}: {e:#}")))?
|
||||
.into_iter()
|
||||
.map(|r| (r.path, r.hash)),
|
||||
);
|
||||
deleted.notify(&mut *tx).await?;
|
||||
let deleted_paths = deleted.paths.clone();
|
||||
|
||||
// Same reason as the single-path delete, over every requested path rather
|
||||
// than the deleted ones: a path that had no script left can still hold state.
|
||||
@@ -4428,10 +4460,6 @@ async fn delete_scripts_bulk(
|
||||
windmill_common::dbt_manifest::clear_dbt_editor_graphs(&mut tx, &w_id, p).await?;
|
||||
}
|
||||
|
||||
// remove duplicates from deleted_paths
|
||||
deleted_paths.sort();
|
||||
deleted_paths.dedup();
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE workspace_id = $1 AND path = ANY($2) AND typ = 'script'",
|
||||
w_id,
|
||||
@@ -4472,6 +4500,7 @@ async fn delete_scripts_bulk(
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
evict_deleted_script_versions(deleted);
|
||||
|
||||
try_join_all(deleted_paths.iter().map(|path| {
|
||||
handle_deployment_metadata(
|
||||
|
||||
@@ -14367,18 +14367,27 @@ async fn prune_versions(
|
||||
|
||||
let pruned = match req.resource_type.as_str() {
|
||||
"scripts" => {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM script
|
||||
WHERE workspace_id = $1 AND hash NOT IN (
|
||||
SELECT DISTINCT ON (path) hash FROM script
|
||||
WHERE workspace_id = $1 AND deleted = false
|
||||
ORDER BY path, created_at DESC
|
||||
)",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
result.rows_affected()
|
||||
let mut tx = db.begin().await?;
|
||||
let deleted = windmill_common::DeletedScriptVersions::new(
|
||||
&w_id,
|
||||
sqlx::query_as::<_, (String, i64)>(
|
||||
"DELETE FROM script
|
||||
WHERE workspace_id = $1 AND hash NOT IN (
|
||||
SELECT DISTINCT ON (path) hash FROM script
|
||||
WHERE workspace_id = $1 AND deleted = false
|
||||
ORDER BY path, created_at DESC
|
||||
)
|
||||
RETURNING path, hash",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?,
|
||||
);
|
||||
deleted.notify(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
let pruned = deleted.hashes.len() as u64;
|
||||
deleted.evict();
|
||||
pruned
|
||||
}
|
||||
"flows" => {
|
||||
let deleted = sqlx::query(
|
||||
|
||||
@@ -4788,15 +4788,7 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
.await?;
|
||||
|
||||
if is_wac {
|
||||
// WAC: directly decrement suspend counter
|
||||
if flow.suspend > 0 {
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1",
|
||||
flow.id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
decrement_suspend(flow.id, &mut tx).await?;
|
||||
} else {
|
||||
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
|
||||
}
|
||||
@@ -4947,12 +4939,7 @@ async fn resume_suspended(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else if is_wac {
|
||||
if flow.suspend > 0 {
|
||||
sqlx::query("UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1")
|
||||
.bind(&flow.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
decrement_suspend(flow.id, &mut tx).await?;
|
||||
} else {
|
||||
resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?;
|
||||
}
|
||||
@@ -5430,8 +5417,13 @@ async fn resume_suspended_job_internal(
|
||||
let value = value.unwrap_or(serde_json::Value::Null);
|
||||
verify_suspended_secret(&w_id, &db, job_id, resume_id, &approver, secret).await?;
|
||||
|
||||
// Get flow info - works for step-level, flow-level, and WAC approval
|
||||
let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &w_id, &db).await?;
|
||||
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
|
||||
|
||||
// Get flow info - works for step-level, flow-level, and WAC approval. This locks the
|
||||
// queue row until commit, so resumes serialize with each other and with the worker
|
||||
// entering the suspend.
|
||||
let (flow_info, is_flow_level, is_wac) =
|
||||
get_flow_info_for_resume(job_id, &w_id, &mut tx).await?;
|
||||
|
||||
// HMAC secret = full capability. Skip approval_conditions checks: possession of the full
|
||||
// resume URL is the authorization (it is only disclosed to intended approvers, e.g. when a
|
||||
@@ -5444,7 +5436,7 @@ async fn resume_suspended_job_internal(
|
||||
"#,
|
||||
Uuid::from_u128(job_id.as_u128() ^ resume_id as u128),
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -5462,7 +5454,6 @@ async fn resume_suspended_job_internal(
|
||||
} else {
|
||||
authed.as_ref().map(|x| x.username.clone())
|
||||
};
|
||||
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
|
||||
|
||||
// Inside the transaction that inserts the row and moves the suspend counter:
|
||||
// validating earlier would let the workflow resolve this step and suspend on the
|
||||
@@ -5491,20 +5482,11 @@ async fn resume_suspended_job_internal(
|
||||
.await?;
|
||||
} else if is_wac {
|
||||
// WAC approval: decrement suspend counter directly on the WAC parent job.
|
||||
// `flow_info.suspend` was read before this transaction took the queue-row
|
||||
// lock, so gating on it would skip the decrement for a workflow that
|
||||
// suspended in between and leave the approval parked until timeout.
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) \
|
||||
WHERE id = $1 AND suspend > 0",
|
||||
flow_info.id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
decrement_suspend(flow_info.id, &mut tx).await?;
|
||||
} else if is_flow_level {
|
||||
// For flow-level resumes, decrement the suspend counter if the flow is currently suspended
|
||||
// The approval will be matched when the worker checks for resumes (both step-level and flow-level)
|
||||
resume_immediately_for_flow_level(&flow_info, &mut tx).await?;
|
||||
// Flow-level resumes don't match a step: the approval is picked up when the worker
|
||||
// checks for resumes (both step-level and flow-level).
|
||||
decrement_suspend(flow_info.id, &mut tx).await?;
|
||||
} else {
|
||||
// For step-level resumes, try to resume immediately if the step is waiting
|
||||
resume_immediately_if_relevant(flow_info, job_id, &mut tx).await?;
|
||||
@@ -5582,42 +5564,27 @@ async fn resume_immediately_if_relevant<'c>(
|
||||
job_id: Uuid,
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
) -> error::Result<()> {
|
||||
Ok(
|
||||
if let Some(suspend) = (0 < flow.suspend).then(|| flow.suspend - 1) {
|
||||
let status =
|
||||
serde_json::from_value::<FlowStatus>(flow.flow_status.context("no flow status")?)
|
||||
.context("deserialize flow status")?;
|
||||
if matches!(status.current_step(), Some(FlowStatusModule::WaitingForEvents { job, .. }) if job == &job_id)
|
||||
{
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET suspend = $1 WHERE id = $2",
|
||||
suspend,
|
||||
flow.id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
},
|
||||
)
|
||||
Ok(if 0 < flow.suspend {
|
||||
let status =
|
||||
serde_json::from_value::<FlowStatus>(flow.flow_status.context("no flow status")?)
|
||||
.context("deserialize flow status")?;
|
||||
if matches!(status.current_step(), Some(FlowStatusModule::WaitingForEvents { job, .. }) if job == &job_id)
|
||||
{
|
||||
decrement_suspend(flow.id, tx).await?;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// For flow-level resumes, decrement the suspend counter if the flow is currently suspended.
|
||||
/// Unlike step-level resumes, we don't check if the job_id matches - we just need the flow
|
||||
/// to be in a suspended state.
|
||||
async fn resume_immediately_for_flow_level<'c>(
|
||||
flow: &FlowInfo,
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
) -> error::Result<()> {
|
||||
if flow.suspend > 0 {
|
||||
let new_suspend = flow.suspend - 1;
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET suspend = $1 WHERE id = $2",
|
||||
new_suspend,
|
||||
flow.id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
/// Relative rather than `suspend = <read value> - 1`, so concurrent approvals each remove one
|
||||
/// event from the count instead of overwriting each other's decrement.
|
||||
async fn decrement_suspend(flow_id: Uuid, tx: &mut Transaction<'_, Postgres>) -> error::Result<()> {
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) \
|
||||
WHERE id = $1 AND suspend > 0",
|
||||
flow_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5666,10 +5633,10 @@ struct FlowInfo {
|
||||
async fn get_flow_info_for_resume(
|
||||
job_id: Uuid,
|
||||
w_id: &str,
|
||||
db: &DB,
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
) -> error::Result<(FlowInfo, bool, bool)> {
|
||||
// Single query that determines if job_id is a flow, step, or WAC job,
|
||||
// and fetches the appropriate suspended job info.
|
||||
// and locks the appropriate suspended job's queue row.
|
||||
// For WAC jobs (no parent, not a flow), the job itself is the suspended target.
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
@@ -5680,7 +5647,6 @@ async fn get_flow_info_for_resume(
|
||||
)
|
||||
SELECT
|
||||
q.id AS "id!",
|
||||
s.flow_status,
|
||||
q.suspend AS "suspend!",
|
||||
j.runnable_path AS script_path,
|
||||
j.permissioned_as_email AS email,
|
||||
@@ -5692,13 +5658,12 @@ async fn get_flow_info_for_resume(
|
||||
ELSE COALESCE(ji.parent_job, ji.id)
|
||||
END
|
||||
JOIN v2_job j ON j.id = q.id
|
||||
LEFT JOIN v2_job_status s ON s.id = q.id
|
||||
FOR UPDATE OF q
|
||||
"#,
|
||||
job_id,
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
@@ -5706,9 +5671,18 @@ async fn get_flow_info_for_resume(
|
||||
))
|
||||
})?;
|
||||
|
||||
// Read in its own statement: a statement that waited on the queue-row lock above re-reads
|
||||
// only the locked row, so a status joined into it could predate the worker's commit.
|
||||
let flow_status: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1")
|
||||
.bind(result.id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let flow_info = FlowInfo {
|
||||
id: result.id,
|
||||
flow_status: result.flow_status,
|
||||
flow_status,
|
||||
suspend: result.suspend,
|
||||
script_path: result.script_path,
|
||||
email: Some(result.email),
|
||||
@@ -5721,6 +5695,13 @@ async fn get_suspended_flow_info<'c>(
|
||||
job_id: Uuid,
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
) -> error::Result<(FlowInfo, Uuid, bool)> {
|
||||
// Lock before reading: a single statement that waited on this lock would re-read only
|
||||
// the queue row, pairing a fresh suspend counter with a stale flow status.
|
||||
sqlx::query("SELECT 1 FROM v2_job_queue WHERE id = $1 FOR UPDATE")
|
||||
.bind(job_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let flow = sqlx::query_as!(
|
||||
FlowInfo,
|
||||
r#"
|
||||
|
||||
@@ -687,8 +687,9 @@ pub mod script {
|
||||
schema_validation AS \"schema_validation: bool\", \
|
||||
codebase LIKE '%.tar' as use_tar, \
|
||||
codebase LIKE '%.esm%' as is_esm, \
|
||||
modules AS \"modules: serde_json::Value\" \
|
||||
FROM script WHERE hash = $1 LIMIT 1",
|
||||
modules AS \"modules: serde_json::Value\", \
|
||||
deleted \
|
||||
FROM script WHERE hash = $1 ORDER BY deleted LIMIT 1",
|
||||
hash.0
|
||||
)
|
||||
.fetch_optional(db)
|
||||
@@ -696,6 +697,14 @@ pub mod script {
|
||||
.map_err(Into::into)
|
||||
.and_then(unwrap_or_error(&loc, "Script", hash))
|
||||
.and_then(|r| {
|
||||
// The hash leaves out the workspace, so forks and clones share it, and a deleted
|
||||
// copy keeps its row with the content wiped. Any live copy holds the same code;
|
||||
// only when every copy is deleted is there nothing left to run.
|
||||
if r.deleted {
|
||||
return Err(error::Error::NotFound(format!(
|
||||
"Script version {hash} was deleted"
|
||||
)));
|
||||
}
|
||||
Ok(RawScript {
|
||||
content: r.content,
|
||||
lock: r.lock,
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{db::DB, error::Result, jobs::JobKind};
|
||||
use crate::{db::DB, error::Result, jobs::JobKind, scripts::ScriptHash};
|
||||
|
||||
/// Where a job's code comes from, as far as its chain of parents can prove it.
|
||||
pub struct JobProvenance {
|
||||
/// True only when the job and every ancestor run code at a path taken from a
|
||||
/// deployed version, never from the request that created the job.
|
||||
pub deployed: bool,
|
||||
/// Deployed, and every script and flow in the chain runs the version its path
|
||||
/// currently resolves to. Any past version still counts as deployed, and a run started
|
||||
/// before a redeploy stops being latest once the redeploy lands. App runs never count:
|
||||
/// app scripts are not tied to an app version.
|
||||
pub latest: bool,
|
||||
/// The job's own script hash (hex) or flow version id; `None` for other kinds.
|
||||
pub version: Option<String>,
|
||||
/// The chain reached a parent it cannot read (other workspace, depth cap), so
|
||||
/// nothing above that point is known.
|
||||
pub unproven_ancestry: bool,
|
||||
@@ -21,6 +28,7 @@ pub struct JobProvenance {
|
||||
pub root_path: Option<String>,
|
||||
pub root_kind: JobKind,
|
||||
pub root_trigger_kind: Option<String>,
|
||||
pub root_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -48,9 +56,14 @@ struct LineageJob {
|
||||
parent_job: Option<Uuid>,
|
||||
kind: JobKind,
|
||||
runnable_path: Option<String>,
|
||||
runnable_id: Option<i64>,
|
||||
trigger_kind: Option<String>,
|
||||
permissioned_as: String,
|
||||
origin_verified: bool,
|
||||
current_version: bool,
|
||||
restarted: bool,
|
||||
/// The job a restart rebuilt this one from.
|
||||
restarted_from: Option<String>,
|
||||
app_stamped: bool,
|
||||
args_modules: bool,
|
||||
}
|
||||
@@ -69,7 +82,7 @@ pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result<Option
|
||||
FROM v2_job p JOIN lineage l ON p.id = l.parent_job
|
||||
WHERE p.workspace_id = $2 AND l.depth < 100
|
||||
)
|
||||
SELECT id AS "id!", parent_job, kind AS "kind!: JobKind", runnable_path,
|
||||
SELECT id AS "id!", parent_job, kind AS "kind!: JobKind", runnable_path, runnable_id,
|
||||
trigger_kind::text AS trigger_kind, permissioned_as AS "permissioned_as!",
|
||||
-- A restart takes its flow version from the request, so a trusted flow path
|
||||
-- can carry another flow's code: the version must belong to that path.
|
||||
@@ -77,9 +90,31 @@ pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result<Option
|
||||
WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv
|
||||
WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)
|
||||
WHEN 'script' THEN EXISTS (SELECT 1 FROM script s
|
||||
WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2)
|
||||
WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2
|
||||
AND NOT s.deleted)
|
||||
ELSE true
|
||||
END AS "origin_verified!",
|
||||
-- What `run/f` and `run/p` resolve the path to now; for scripts the predicate of
|
||||
-- `get_latest_deployed_script_hash`.
|
||||
COALESCE(CASE kind
|
||||
WHEN 'flow' THEN runnable_id = (SELECT f.versions[array_upper(f.versions, 1)]
|
||||
FROM flow f WHERE f.path = runnable_path AND f.workspace_id = $2)
|
||||
WHEN 'script' THEN runnable_id = (SELECT s.hash FROM script s
|
||||
WHERE s.path = runnable_path AND s.workspace_id = $2 AND NOT s.deleted
|
||||
AND s.lock IS NOT NULL AND s.lock_error_logs IS NULL
|
||||
ORDER BY s.created_at DESC LIMIT 1)
|
||||
END, false) AS "current_version!",
|
||||
EXISTS (SELECT 1 FROM v2_job_status st WHERE st.id = lineage.id
|
||||
AND jsonb_typeof(st.flow_status->'restarted_from') = 'object')
|
||||
OR EXISTS (SELECT 1 FROM v2_job_completed c WHERE c.id = lineage.id
|
||||
AND jsonb_typeof(c.flow_status->'restarted_from') = 'object')
|
||||
AS "restarted!",
|
||||
COALESCE(
|
||||
(SELECT st.flow_status->'restarted_from'->>'flow_job_id' FROM v2_job_status st
|
||||
WHERE st.id = lineage.id),
|
||||
(SELECT c.flow_status->'restarted_from'->>'flow_job_id' FROM v2_job_completed c
|
||||
WHERE c.id = lineage.id)
|
||||
) AS restarted_from,
|
||||
-- Only deployed-app runs are stamped with their app; an app editor preview
|
||||
-- runs app code at an app path it does not have to own.
|
||||
COALESCE(trigger_kind = 'app' AND starts_with(runnable_path, trigger || '/'), false)
|
||||
@@ -103,8 +138,21 @@ pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result<Option
|
||||
// Walk from the root down: whether a job runs stored code can depend on its parent.
|
||||
let mut claimed_paths = vec![];
|
||||
let mut all_stored = true;
|
||||
let mut all_current = true;
|
||||
let mut restart_origins = vec![];
|
||||
let mut parent: Option<(&LineageJob, bool)> = None;
|
||||
for job in lineage.iter().rev() {
|
||||
// A restart rebuilds a job from a past run, under a current flow or with nothing
|
||||
// above it, so it runs that run's code: a flow's own version can still be checked,
|
||||
// anything else is current only if the run it was rebuilt from is.
|
||||
if job.restarted && job.kind != JobKind::Flow {
|
||||
match job.restarted_from.as_deref().map(Uuid::parse_str) {
|
||||
Some(Ok(origin)) => restart_origins.push(origin),
|
||||
_ => all_current = false,
|
||||
}
|
||||
} else {
|
||||
all_current &= runs_current_version(job);
|
||||
}
|
||||
let stored = runs_stored_code(job, parent);
|
||||
if !stored {
|
||||
all_stored = false;
|
||||
@@ -117,8 +165,19 @@ pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result<Option
|
||||
parent = Some((job, stored));
|
||||
}
|
||||
|
||||
let deployed = !unproven_ancestry && all_stored;
|
||||
if deployed && all_current {
|
||||
for origin in restart_origins {
|
||||
if !restart_origin_is_current(db, &origin, w_id).await? {
|
||||
all_current = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(JobProvenance {
|
||||
deployed: !unproven_ancestry && all_stored,
|
||||
deployed,
|
||||
latest: deployed && all_current,
|
||||
version: version(&lineage[0]),
|
||||
unproven_ancestry,
|
||||
claimed_paths,
|
||||
permissioned_as: lineage[0].permissioned_as.clone(),
|
||||
@@ -127,9 +186,80 @@ pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result<Option
|
||||
root_path: root.runnable_path.clone(),
|
||||
root_kind: root.kind,
|
||||
root_trigger_kind: root.trigger_kind.clone(),
|
||||
root_version: version(root),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether the completed job a restart rebuilt a job from ran current code: its nearest
|
||||
/// flow ancestor runs the current version of its path, and nothing between them was itself
|
||||
/// rebuilt by a restart (which would carry an older run's code again).
|
||||
async fn restart_origin_is_current(db: &DB, origin: &Uuid, w_id: &str) -> Result<bool> {
|
||||
let current = sqlx::query_scalar!(
|
||||
r#"WITH RECURSIVE up AS (
|
||||
SELECT id, parent_job, kind, runnable_id, runnable_path, 0 AS depth
|
||||
FROM v2_job WHERE id = $1 AND workspace_id = $2
|
||||
UNION ALL
|
||||
SELECT p.id, p.parent_job, p.kind, p.runnable_id, p.runnable_path, u.depth + 1
|
||||
FROM v2_job p JOIN up u ON p.id = u.parent_job
|
||||
WHERE p.workspace_id = $2 AND u.kind <> 'flow' AND u.depth < 100
|
||||
)
|
||||
SELECT EXISTS (SELECT 1 FROM up WHERE kind = 'flow'
|
||||
AND runnable_id = (SELECT f.versions[array_upper(f.versions, 1)]
|
||||
FROM flow f WHERE f.path = up.runnable_path AND f.workspace_id = $2))
|
||||
AND NOT EXISTS (SELECT 1 FROM up WHERE kind <> 'flow' AND (
|
||||
EXISTS (SELECT 1 FROM v2_job_completed c WHERE c.id = up.id
|
||||
AND jsonb_typeof(c.flow_status->'restarted_from') = 'object')
|
||||
OR EXISTS (SELECT 1 FROM v2_job_status st WHERE st.id = up.id
|
||||
AND jsonb_typeof(st.flow_status->'restarted_from') = 'object')))
|
||||
AS "current!""#,
|
||||
origin,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
fn version(job: &LineageJob) -> Option<String> {
|
||||
match job.kind {
|
||||
JobKind::Script => job.runnable_id.map(|h| ScriptHash(h).to_string()),
|
||||
JobKind::Flow => job.runnable_id.map(|v| v.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Only meaningful under `deployed`, which already excludes every kind that runs
|
||||
/// request-supplied code.
|
||||
fn runs_current_version(job: &LineageJob) -> bool {
|
||||
match job.kind {
|
||||
JobKind::Script | JobKind::Flow => job.current_version,
|
||||
// A flow node is a loop or branch body taken from the version of the flow above
|
||||
// it, which is checked itself.
|
||||
JobKind::FlowNode => job.parent_job.is_some(),
|
||||
// An app script is keyed by its content, not by an app version, so a past
|
||||
// deployment's script cannot be told apart from the current one's.
|
||||
JobKind::AppScript => false,
|
||||
JobKind::Preview if job.app_stamped => false,
|
||||
// They run no code of their own beyond what checked jobs above them define, or
|
||||
// (hub) the path names the version.
|
||||
JobKind::Script_Hub
|
||||
| JobKind::SingleStepFlow
|
||||
| JobKind::FlowScript
|
||||
| JobKind::AIAgent
|
||||
| JobKind::Preview
|
||||
| JobKind::FlowPreview
|
||||
| JobKind::Dependencies
|
||||
| JobKind::FlowDependencies
|
||||
| JobKind::AppDependencies
|
||||
| JobKind::Identity
|
||||
| JobKind::Noop
|
||||
| JobKind::DeploymentCallback
|
||||
| JobKind::UnassignedScript
|
||||
| JobKind::UnassignedFlow
|
||||
| JobKind::UnassignedSinglestepFlow => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the job's code and its path are both fixed by what was deployed (or, for a
|
||||
/// hub script, published), rather than supplied by the request that created the job.
|
||||
/// `parent` is the job's parent and whether it runs stored code.
|
||||
|
||||
@@ -2479,6 +2479,91 @@ pub fn invalidate_deployed_script_hash_cache(w_id: &str, script_path: &str) {
|
||||
DEPLOYED_SCRIPT_HASH_CACHE.remove(&(w_id.to_string(), script_path.to_string()));
|
||||
}
|
||||
|
||||
pub const SCRIPT_VERSION_DELETED_CHANNEL: &str = "notify_script_version_deleted";
|
||||
|
||||
/// The payload of a [`SCRIPT_VERSION_DELETED_CHANNEL`] event. `paths` and `hashes` are the
|
||||
/// deleted versions' paths and hashes as independent sets, not paired by position.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DeletedScriptVersions {
|
||||
pub workspace_id: String,
|
||||
pub paths: Vec<String>,
|
||||
pub hashes: Vec<i64>,
|
||||
}
|
||||
|
||||
/// Bounds each event's payload: pruning a workspace deletes every past version of every path
|
||||
/// in one call, and every process logs each event it receives.
|
||||
const DELETED_SCRIPT_VERSIONS_PER_EVENT: usize = 500;
|
||||
|
||||
impl DeletedScriptVersions {
|
||||
pub fn new(workspace_id: &str, deleted: impl IntoIterator<Item = (String, i64)>) -> Self {
|
||||
let (mut paths, hashes): (Vec<String>, Vec<i64>) = deleted.into_iter().unzip();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
Self { workspace_id: workspace_id.to_string(), paths, hashes }
|
||||
}
|
||||
|
||||
/// Tell every replica to drop these versions from its caches, in the transaction that
|
||||
/// deletes them. Authorization is the caller's: only call it for versions the caller was
|
||||
/// allowed to delete.
|
||||
pub async fn notify(&self, db: &mut sqlx::PgConnection) -> error::Result<()> {
|
||||
let per_event = DELETED_SCRIPT_VERSIONS_PER_EVENT;
|
||||
let events = self.paths.len().max(self.hashes.len()).div_ceil(per_event);
|
||||
for i in 0..events {
|
||||
let range = i * per_event..(i + 1) * per_event;
|
||||
let event = Self {
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
paths: self
|
||||
.paths
|
||||
.get(range.start..range.end.min(self.paths.len()))
|
||||
.unwrap_or_default()
|
||||
.to_vec(),
|
||||
hashes: self
|
||||
.hashes
|
||||
.get(range.start..range.end.min(self.hashes.len()))
|
||||
.unwrap_or_default()
|
||||
.to_vec(),
|
||||
};
|
||||
sqlx::query("INSERT INTO notify_event (channel, payload) VALUES ($1, $2)")
|
||||
.bind(SCRIPT_VERSION_DELETED_CHANNEL)
|
||||
.bind(serde_json::to_string(&event)?)
|
||||
.execute(&mut *db)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Self::evict_with`] for this crate's caches only.
|
||||
pub fn evict(self) {
|
||||
self.evict_with(|_| {});
|
||||
}
|
||||
|
||||
/// Script data is cached by hash, memory and disk, with no expiry: without this, a
|
||||
/// process that ran a version before its deletion keeps running that version's code,
|
||||
/// and a path keeps resolving to its deleted latest version until its cache expires.
|
||||
/// `also` drops the entries of caches other crates own, on both passes. Needs no
|
||||
/// authorization: it only drops cache entries, refilled from the database. Must run
|
||||
/// inside a Tokio runtime.
|
||||
pub fn evict_with(self, also: impl Fn(&Self) + Send + 'static) {
|
||||
let evict = move || {
|
||||
for hash in &self.hashes {
|
||||
cache::script::invalidate(ScriptHash(*hash));
|
||||
DEPLOYED_SCRIPT_INFO_CACHE.remove(&(self.workspace_id.clone(), *hash));
|
||||
}
|
||||
for path in &self.paths {
|
||||
invalidate_latest_script_hash_caches(&self.workspace_id, path);
|
||||
}
|
||||
also(&self);
|
||||
};
|
||||
evict();
|
||||
// A fill that read a row before the deletion committed can still be writing it to
|
||||
// the cache: a second pass, once such a fill has had time to finish, removes it.
|
||||
spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
evict();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Same, for a new version row, which also moves the import-side answer (that one has no lock
|
||||
/// predicate, so only a new row moves it).
|
||||
pub fn invalidate_latest_script_hash_caches(w_id: &str, script_path: &str) {
|
||||
|
||||
@@ -70,6 +70,10 @@ pub struct JobClaim {
|
||||
pub root_path: Option<String>,
|
||||
pub root_job_kind: String,
|
||||
pub root_trigger_kind: Option<String>,
|
||||
pub latest: bool,
|
||||
pub version: Option<String>,
|
||||
pub root_version: Option<String>,
|
||||
pub jti: String,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
|
||||
@@ -1175,6 +1175,7 @@ pub struct TypeScriptAnnotations {
|
||||
pub native: bool,
|
||||
pub nobundling: bool,
|
||||
pub sandbox: bool,
|
||||
pub no_network: bool,
|
||||
}
|
||||
|
||||
#[annotations("--")]
|
||||
|
||||
@@ -68,6 +68,7 @@ pub struct ClientWithScopes {
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub userinfo_url: Option<String>,
|
||||
pub grant_types: Vec<String>,
|
||||
pub token_response_path: Option<String>,
|
||||
/// Resolved token endpoint, exposed so the connect dialog can prefill and
|
||||
/// persist it on client-credentials accounts.
|
||||
pub token_url: String,
|
||||
@@ -88,10 +89,11 @@ pub struct OAuthConfig {
|
||||
#[serde(default = "empty_string")]
|
||||
pub token_url: String,
|
||||
pub userinfo_url: Option<String>,
|
||||
/// The registry JSON may also carry two frontend-only keys for the connect
|
||||
/// The registry JSON may also carry frontend-only keys for the connect
|
||||
/// dialog, deliberately not modelled here: `scope_options`, a scope pick
|
||||
/// list, and `resource_fields`, the fields of the resource type the dialog
|
||||
/// asks for once the token is in (Snowflake's database and warehouse).
|
||||
/// list, `resource_fields`, the fields of the resource type the dialog
|
||||
/// asks for once the token is in (Snowflake's database and warehouse), and
|
||||
/// `user_scopes`, Slack's user-token scopes, sent as `user_scope`.
|
||||
pub scopes: Option<Vec<String>>,
|
||||
/// Default scopes for the client-credentials (2-legged) flow. These differ
|
||||
/// from the authorization-code `scopes` for most providers (member/consent
|
||||
@@ -103,6 +105,11 @@ pub struct OAuthConfig {
|
||||
pub extra_params: Option<HashMap<String, String>>,
|
||||
pub extra_params_callback: Option<HashMap<String, String>>,
|
||||
pub req_body_auth: Option<bool>,
|
||||
/// Key of a nested object holding the token when the response has none at
|
||||
/// the top level: Slack v2 puts a user token (`user_scope`) under
|
||||
/// `authed_user`, and a bot token, when one was asked for, at the top.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token_response_path: Option<String>,
|
||||
#[serde(default = "default_grant_types")]
|
||||
pub grant_types: Vec<String>,
|
||||
/// Optional URL overrides for the provider's sandbox environment. When
|
||||
@@ -793,6 +800,7 @@ pub async fn exchange_token(
|
||||
extra_params_callback: Option<&HashMap<String, String>>,
|
||||
http_client: &reqwest::Client,
|
||||
scopes: Option<&[String]>,
|
||||
token_response_path: Option<&str>,
|
||||
) -> Result<TokenResponse, Error> {
|
||||
let token_json = match grant_type {
|
||||
"authorization_code" | "" => {
|
||||
@@ -831,12 +839,24 @@ pub async fn exchange_token(
|
||||
}
|
||||
};
|
||||
|
||||
let token = serde_json::from_value::<TokenResponse>(token_json.clone()).map_err(|e| {
|
||||
parse_token_response(token_json, token_response_path)
|
||||
}
|
||||
|
||||
/// Deserialize a token endpoint response, reading the token from the object at
|
||||
/// `token_response_path` when the top level carries none.
|
||||
pub fn parse_token_response(
|
||||
token_json: serde_json::Value,
|
||||
token_response_path: Option<&str>,
|
||||
) -> Result<TokenResponse, Error> {
|
||||
let token_json = match token_response_path.and_then(|p| token_json.get(p)) {
|
||||
Some(nested) if token_json.get("access_token").is_none() => nested.clone(),
|
||||
_ => token_json,
|
||||
};
|
||||
serde_json::from_value::<TokenResponse>(token_json.clone()).map_err(|e| {
|
||||
Error::BadConfig(format!(
|
||||
"Error deserializing response as a new token: {e}\nresponse:{token_json}"
|
||||
))
|
||||
})?;
|
||||
Ok(token)
|
||||
})
|
||||
}
|
||||
|
||||
/// Pre-fetched account fields needed for token refresh.
|
||||
@@ -992,6 +1012,14 @@ pub async fn refresh_token_for_account<'c>(
|
||||
extra_params_callback.as_ref(),
|
||||
http_client,
|
||||
Some(effective_scopes),
|
||||
oauth_client_info
|
||||
.as_ref()
|
||||
.and_then(|i| i.token_response_path.as_deref())
|
||||
.or_else(|| {
|
||||
cc_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.token_response_path.as_deref())
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1207,6 +1235,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_token_response_nested() {
|
||||
let bot = serde_json::json!({
|
||||
"ok": true,
|
||||
"access_token": "xoxb-bot",
|
||||
"authed_user": {"id": "U1"}
|
||||
});
|
||||
let token = parse_token_response(bot, Some("authed_user")).unwrap();
|
||||
assert_eq!(&*token.access_token, "xoxb-bot");
|
||||
|
||||
let user = serde_json::json!({
|
||||
"ok": true,
|
||||
"authed_user": {"access_token": "xoxp-user", "refresh_token": "xoxe-1", "expires_in": 43200}
|
||||
});
|
||||
let token = parse_token_response(user, Some("authed_user")).unwrap();
|
||||
assert_eq!(&*token.access_token, "xoxp-user");
|
||||
assert_eq!(&*token.refresh_token.unwrap(), "xoxe-1");
|
||||
|
||||
let refresh =
|
||||
serde_json::json!({"ok": true, "access_token": "xoxe.xoxp-new", "token_type": "user"});
|
||||
let token = parse_token_response(refresh, Some("authed_user")).unwrap();
|
||||
assert_eq!(&*token.access_token, "xoxe.xoxp-new");
|
||||
}
|
||||
|
||||
fn sample_oauth_config(with_sandbox: bool) -> OAuthConfig {
|
||||
OAuthConfig {
|
||||
auth_url: "https://account.example.com/oauth/auth".to_string(),
|
||||
@@ -1217,6 +1269,7 @@ mod tests {
|
||||
extra_params: None,
|
||||
extra_params_callback: None,
|
||||
req_body_auth: None,
|
||||
token_response_path: None,
|
||||
grant_types: default_grant_types(),
|
||||
sandbox: with_sandbox.then(|| OAuthSandboxOverride {
|
||||
auth_url: Some("https://account-d.example.com/oauth/auth".to_string()),
|
||||
|
||||
@@ -79,16 +79,50 @@ deno_core::extension!(
|
||||
|
||||
// ── Permission container ─────────────────────────────────────────────
|
||||
|
||||
pub struct PermissionsContainer;
|
||||
/// `no_network` is what `//no_network` promises: no outbound traffic of any kind,
|
||||
/// the Windmill API included (the client module reaches it through `fetch`).
|
||||
/// Enforced here and by disabling the deno_net ops, never in JS: user code can
|
||||
/// call `Deno.core.ops.*` directly and skip any JS-level guard.
|
||||
pub struct PermissionsContainer {
|
||||
pub no_network: bool,
|
||||
}
|
||||
|
||||
const NO_NETWORK_ANNOTATION: &str = "no_network";
|
||||
|
||||
/// Every op deno_net registers, see `create_nativets_runtime`. Read from the
|
||||
/// extension itself so an upgrade that adds an op cannot leave it enabled.
|
||||
static NO_NETWORK_DISABLED_OPS: LazyLock<std::collections::HashSet<&'static str>> =
|
||||
LazyLock::new(|| {
|
||||
deno_net::deno_net::init::<PermissionsContainer>(None, None)
|
||||
.ops
|
||||
.iter()
|
||||
.map(|op| op.name)
|
||||
.collect()
|
||||
});
|
||||
|
||||
impl PermissionsContainer {
|
||||
fn deny_net(&self, target: &str) -> Result<(), deno_permissions::PermissionCheckError> {
|
||||
if self.no_network {
|
||||
Err(deno_permissions::PermissionDeniedError::Fatal {
|
||||
access: format!(
|
||||
"net access to {target} (the script is annotated //{NO_NETWORK_ANNOTATION})"
|
||||
),
|
||||
}
|
||||
.into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FetchPermissions for PermissionsContainer {
|
||||
#[inline(always)]
|
||||
fn check_net_url(
|
||||
&mut self,
|
||||
_url: &deno_core::url::Url,
|
||||
url: &deno_core::url::Url,
|
||||
_api_name: &str,
|
||||
) -> Result<(), deno_permissions::PermissionCheckError> {
|
||||
Ok(())
|
||||
self.deny_net(&format!("\"{}\"", url.host_str().unwrap_or_default()))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
@@ -114,11 +148,11 @@ impl FetchPermissions for PermissionsContainer {
|
||||
#[inline(always)]
|
||||
fn check_net_vsock(
|
||||
&mut self,
|
||||
_cid: u32,
|
||||
_port: u32,
|
||||
cid: u32,
|
||||
port: u32,
|
||||
_api_name: &str,
|
||||
) -> Result<(), deno_permissions::PermissionCheckError> {
|
||||
Ok(())
|
||||
self.deny_net(&format!("vsock {cid}:{port}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +169,7 @@ impl NetPermissions for PermissionsContainer {
|
||||
p: &str,
|
||||
_api_name: &str,
|
||||
) -> Result<PathBuf, deno_permissions::PermissionCheckError> {
|
||||
self.deny_net(&format!("unix socket \"{p}\""))?;
|
||||
Ok(PathBuf::from(p))
|
||||
}
|
||||
|
||||
@@ -143,15 +178,16 @@ impl NetPermissions for PermissionsContainer {
|
||||
p: &str,
|
||||
_api_name: &str,
|
||||
) -> Result<PathBuf, deno_permissions::PermissionCheckError> {
|
||||
self.deny_net(&format!("unix socket \"{p}\""))?;
|
||||
Ok(PathBuf::from(p))
|
||||
}
|
||||
|
||||
fn check_net<T: AsRef<str>>(
|
||||
&mut self,
|
||||
_host: &(T, Option<u16>),
|
||||
host: &(T, Option<u16>),
|
||||
_api_name: &str,
|
||||
) -> Result<(), deno_permissions::PermissionCheckError> {
|
||||
Ok(())
|
||||
self.deny_net(&format!("\"{}\"", host.0.as_ref()))
|
||||
}
|
||||
|
||||
fn check_write_path<'a>(
|
||||
@@ -159,16 +195,17 @@ impl NetPermissions for PermissionsContainer {
|
||||
p: Cow<'a, std::path::Path>,
|
||||
_api_name: &str,
|
||||
) -> Result<Cow<'a, std::path::Path>, deno_permissions::PermissionCheckError> {
|
||||
self.deny_net(&format!("unix socket \"{}\"", p.display()))?;
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn check_vsock(
|
||||
&mut self,
|
||||
_cid: u32,
|
||||
_port: u32,
|
||||
cid: u32,
|
||||
port: u32,
|
||||
_api_name: &str,
|
||||
) -> Result<(), deno_permissions::PermissionCheckError> {
|
||||
Ok(())
|
||||
self.deny_net(&format!("vsock {cid}:{port}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +227,9 @@ pub struct NativeAnnotation {
|
||||
/// [`default_fetch_response_timeout_secs`]. `Some(0)` disables it for this
|
||||
/// script; `None` leaves the default in force.
|
||||
pub fetch_response_timeout_secs: Option<u64>,
|
||||
/// `//no_network`: the isolate may open no connection at all, see
|
||||
/// [`PermissionsContainer`].
|
||||
pub no_network: bool,
|
||||
}
|
||||
|
||||
/// How long `fetch()` waits for a response to begin, in seconds; `0` disables.
|
||||
@@ -432,6 +472,11 @@ pub fn get_annotation(inner_content: &str) -> NativeAnnotation {
|
||||
.map(|x| x.to_string().trim_start_matches("//").trim().to_string())
|
||||
.collect_vec();
|
||||
|
||||
// Read by the same parser that decides `//native`, so the two cannot
|
||||
// disagree on where the leading comment block ends.
|
||||
res.no_network =
|
||||
windmill_common::worker::TypeScriptAnnotations::parse(inner_content).no_network;
|
||||
|
||||
for ann in anns.iter() {
|
||||
if ann.starts_with("useragent") {
|
||||
res.useragent = Some(ann.trim_start_matches("useragent").trim().to_string());
|
||||
@@ -581,8 +626,23 @@ pub(crate) fn create_nativets_runtime(
|
||||
ann: NativeAnnotation,
|
||||
initial_args: Vec<Option<Box<RawValue>>>,
|
||||
) -> anyhow::Result<CreatedRuntime> {
|
||||
let no_network = ann.no_network;
|
||||
let ops = vec![op_get_static_args(), op_log()];
|
||||
let ext = Extension { name: "windmill", ops: ops.into(), ..Default::default() };
|
||||
// Under `no_network` the whole deno_net surface is disabled, not left to its
|
||||
// permission checks: some of its ops do I/O before checking, e.g.
|
||||
// `op_quic_endpoint_create` resolves the hostname (a DNS query that can carry
|
||||
// data out) and only then calls `check_net`. The middleware sees every
|
||||
// extension's ops, which the snapshot does not constrain.
|
||||
let middleware_fn: Option<Box<deno_core::OpMiddlewareFn>> = no_network.then(|| {
|
||||
Box::new(|op: deno_core::OpDecl| {
|
||||
if NO_NETWORK_DISABLED_OPS.contains(op.name) {
|
||||
op.disable()
|
||||
} else {
|
||||
op
|
||||
}
|
||||
}) as Box<deno_core::OpMiddlewareFn>
|
||||
});
|
||||
let ext = Extension { name: "windmill", ops: ops.into(), middleware_fn, ..Default::default() };
|
||||
|
||||
// deno_web's setTimeout puts its delay through `webidl.converters.long`,
|
||||
// which wraps at 32 bits: past i32::MAX ms (~24.8 days) the delay comes out
|
||||
@@ -659,7 +719,7 @@ pub(crate) fn create_nativets_runtime(
|
||||
{
|
||||
let op_state = js_runtime.op_state();
|
||||
let mut op_state = op_state.borrow_mut();
|
||||
op_state.put(PermissionsContainer {});
|
||||
op_state.put(PermissionsContainer { no_network });
|
||||
op_state.put(MainArgs { args: initial_args });
|
||||
op_state.put(LogString { s: log_sender });
|
||||
}
|
||||
@@ -800,6 +860,9 @@ pub async fn eval_fetch_timeout(
|
||||
}
|
||||
|
||||
let mut extra_logs = String::new();
|
||||
if ann.no_network {
|
||||
extra_logs.push_str("no_network: all network access is denied\n");
|
||||
}
|
||||
if ann.useragent.is_some() {
|
||||
extra_logs.push_str(&format!("useragent: {}\n", ann.useragent.as_ref().unwrap()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! `//no_network` denies every connection, including through the raw ops user
|
||||
//! code can reach as `Deno.core.ops`, which bypass any JS-level guard. The QUIC
|
||||
//! endpoint op resolves its hostname before its own permission check, so the
|
||||
//! deno_net ops must be disabled outright rather than trusted to check first.
|
||||
//!
|
||||
//! Hermetic: a loopback listener that counts accepted connections.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use windmill_runtime_nativets::{get_annotation, transpile_ts, PrewarmedIsolate};
|
||||
|
||||
async fn run(ts: &str) -> String {
|
||||
let js = transpile_ts(ts.to_string()).expect("transpile_ts failed");
|
||||
let mut iso = PrewarmedIsolate::spawn(String::new(), js, get_annotation(ts), vec![], None);
|
||||
iso.wait_ready().await.expect("isolate failed to pre-warm");
|
||||
let res = iso
|
||||
.start_execution("{}".to_string())
|
||||
.wait()
|
||||
.await
|
||||
.expect("isolate panicked");
|
||||
match res.result {
|
||||
Ok(raw) => raw.get().to_string(),
|
||||
Err(e) => panic!("script failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_counting_peer(accepted: Arc<AtomicUsize>) -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
accepted.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 8192];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
let _ = sock
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
fn script(annotations: &str, port: u16) -> String {
|
||||
format!(
|
||||
r#"{annotations}
|
||||
async function attempt(f) {{
|
||||
try {{ await f(); return "allowed"; }} catch (e) {{ return String(e); }}
|
||||
}}
|
||||
export async function main() {{
|
||||
return {{
|
||||
fetch: await attempt(() => fetch("http://127.0.0.1:{port}/").then((r) => r.text())),
|
||||
tcp: await attempt(() => Deno.core.ops.op_net_connect_tcp({{ hostname: "127.0.0.1", port: {port} }}, null, null)),
|
||||
unix: await attempt(() => Deno.core.ops.op_net_connect_unix("/var/run/docker.sock")),
|
||||
quic: await attempt(() => Deno.core.ops.op_quic_endpoint_create({{ hostname: "localhost", port: 0 }}, true)),
|
||||
}};
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn no_network_denies_fetch_and_raw_socket_ops() {
|
||||
let accepted = Arc::new(AtomicUsize::new(0));
|
||||
let port = spawn_counting_peer(accepted.clone()).await;
|
||||
|
||||
let out: serde_json::Value =
|
||||
serde_json::from_str(&run(&script("//native\n//no_network", port)).await).unwrap();
|
||||
let fetch = out["fetch"].as_str().unwrap();
|
||||
assert!(
|
||||
fetch.contains("no_network"),
|
||||
"fetch was not denied: {fetch}"
|
||||
);
|
||||
for key in ["tcp", "unix", "quic"] {
|
||||
let msg = out[key].as_str().unwrap();
|
||||
assert!(
|
||||
msg.contains("op is disabled"),
|
||||
"{key} was not disabled: {msg}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
accepted.load(Ordering::SeqCst),
|
||||
0,
|
||||
"a connection got through"
|
||||
);
|
||||
|
||||
// Without the annotation the same script reaches the peer, so the denial
|
||||
// above is the annotation's doing and not a broken harness.
|
||||
let out: serde_json::Value =
|
||||
serde_json::from_str(&run(&script("//native", port)).await).unwrap();
|
||||
assert_eq!(out["fetch"], "allowed", "{out}");
|
||||
assert_eq!(out["tcp"], "allowed", "{out}");
|
||||
assert!(accepted.load(Ordering::SeqCst) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_exact_annotation_in_the_leading_block_counts() {
|
||||
assert!(get_annotation("//native\n//no_network\n").no_network);
|
||||
assert!(get_annotation("// no_network\n").no_network);
|
||||
assert!(!get_annotation("//no_networking\n").no_network);
|
||||
assert!(!get_annotation("//native\nconst x = 1;\n//no_network\n").no_network);
|
||||
// `//native` routing skips blank lines in the header; `//no_network` must
|
||||
// too, or a script runs native with the network open.
|
||||
assert!(get_annotation("\n//native\n//a comment\n\n//no_network\n").no_network);
|
||||
}
|
||||
@@ -2362,7 +2362,11 @@ try {{
|
||||
|
||||
#[cfg(feature = "deno_core")]
|
||||
{
|
||||
let env_code = build_nativets_env_code(base_internal_url, &reserved_variables);
|
||||
let env_code = build_nativets_env_code(
|
||||
base_internal_url,
|
||||
&reserved_variables,
|
||||
annotation.no_network,
|
||||
);
|
||||
let js_code = read_file_content(&format!("{job_dir}/main.js")).await?;
|
||||
let started_at = Instant::now();
|
||||
let args = crate::common::build_args_map(job, client, conn)
|
||||
@@ -3807,11 +3811,15 @@ pub async fn get_common_bun_proc_envs(base_internal_url: Option<&str>) -> HashMa
|
||||
pub fn build_nativets_env_code(
|
||||
base_internal_url: &str,
|
||||
reserved_variables: &HashMap<String, String>,
|
||||
no_network: bool,
|
||||
) -> String {
|
||||
format!(
|
||||
"const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}",
|
||||
reserved_variables
|
||||
.iter()
|
||||
// Useless without network, and a `//no_network` script could still
|
||||
// hand it out through its result to whoever authored it.
|
||||
.filter(|(k, _)| !(no_network && k.as_str() == "WM_TOKEN"))
|
||||
.map(|(k, v)| {
|
||||
// The key is attacker-controllable (custom workspace env vars), so
|
||||
// escape it as a string literal too, not just the value.
|
||||
@@ -4206,7 +4214,11 @@ pub async fn start_worker(
|
||||
.iter()
|
||||
.map(|x| (x.name.clone(), x.value.clone()))
|
||||
.collect();
|
||||
let env_code = build_nativets_env_code(base_internal_url, &reserved_variables);
|
||||
let env_code = build_nativets_env_code(
|
||||
base_internal_url,
|
||||
&reserved_variables,
|
||||
annotation.no_network,
|
||||
);
|
||||
|
||||
return handle_dedicated_bunnative(
|
||||
inner_content,
|
||||
|
||||
@@ -6326,8 +6326,12 @@ pub async fn run_language_executor(
|
||||
)
|
||||
.await;
|
||||
|
||||
let reserved_variables =
|
||||
let mut reserved_variables =
|
||||
get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?;
|
||||
// Same reason as in `build_nativets_env_code`.
|
||||
if windmill_common::worker::TypeScriptAnnotations::parse(&code).no_network {
|
||||
reserved_variables.remove("WM_TOKEN");
|
||||
}
|
||||
|
||||
let env_code = format!(
|
||||
"const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}",
|
||||
|
||||
Generated
+12
@@ -1382,6 +1382,18 @@ export async function main(url: string) {
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Pure computation: \`//no_network\`
|
||||
|
||||
A native script that only transforms its inputs can declare \`//no_network\` in its leading comment block (right after \`//native\`). The runtime then refuses every connection the script attempts: \`fetch\` to any host, the Windmill API (so \`windmill-client\` calls fail too), raw sockets and unix sockets, and \`WM_TOKEN\` is not set. It only takes effect on native scripts: without \`//native\` it is ignored. Use it only when the script needs no external data beyond its arguments:
|
||||
|
||||
\`\`\`typescript
|
||||
//native
|
||||
//no_network
|
||||
export async function main(items: { price: number; qty: number }[]) {
|
||||
return items.reduce((sum, i) => sum + i.price * i.qty, 0);
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Windmill Client
|
||||
|
||||
\`windmill-client\` works on the native worker (its calls go over \`fetch\`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). It handles auth, the workspace, and the base URL for you. Reserve raw \`fetch\` for calling *external* HTTP APIs that aren't Windmill.
|
||||
|
||||
@@ -201,6 +201,38 @@
|
||||
*/
|
||||
let useClientCredentials = $state(false)
|
||||
|
||||
/** Slack v2 grants a user token for `user_scope` and a bot token for `scope`,
|
||||
* from the same app: the registry's `user_scopes` offers the choice. */
|
||||
let useUserToken = $state(false)
|
||||
/** Both kinds share one resource type, so the default path and description tell them apart. */
|
||||
let tokenKind = $derived(
|
||||
registryEntry()?.user_scopes && !useClientCredentials
|
||||
? useUserToken
|
||||
? 'user'
|
||||
: 'bot'
|
||||
: undefined
|
||||
)
|
||||
let defaultName = $derived(tokenKind ? `${resourceType}_${tokenKind}` : resourceType)
|
||||
/** Last description filled in from `tokenKind`, replaced on reconnect unless the user edited it. */
|
||||
let generatedDescription = ''
|
||||
|
||||
function fillGeneratedDescription() {
|
||||
if (description === generatedDescription) {
|
||||
description = ''
|
||||
}
|
||||
generatedDescription = tokenKind ? `${resourceType} ${tokenKind} token` : ''
|
||||
if (emptyString(description)) {
|
||||
description = generatedDescription
|
||||
}
|
||||
}
|
||||
|
||||
function selectUserToken(user: boolean) {
|
||||
if (user !== useUserToken) {
|
||||
scopes = user ? (registryEntry()?.user_scopes ?? []) : instanceScopes
|
||||
}
|
||||
useUserToken = user
|
||||
}
|
||||
|
||||
/**
|
||||
* Client credentials for resource-level OAuth
|
||||
*/
|
||||
@@ -241,6 +273,13 @@
|
||||
* list rather than "every other field" because most OAuth types also hold the fields of
|
||||
* another way in: ServiceNow's basic-auth password, Bitbucket's app password. */
|
||||
let resourceFields = $derived((registryEntry()?.resource_fields as string[] | undefined) ?? [])
|
||||
/** The grant's defaults are offered as checkboxes too, so unticking one drops it.
|
||||
* `scope_options` lists authorization-code scopes, invalid in a 2-legged request. */
|
||||
let scopeOptions = $derived(
|
||||
useClientCredentials
|
||||
? defaultCcScopes()
|
||||
: [...(registryEntry()?.scope_options ?? []), ...instanceScopes]
|
||||
)
|
||||
|
||||
/** Their slice of the resource type's schema, so they render with the type's own
|
||||
* descriptions; plain text inputs while the type is not synced from the hub. */
|
||||
@@ -286,6 +325,7 @@
|
||||
function resetClientCredentialsState() {
|
||||
supportsClientCredentials = false
|
||||
useClientCredentials = false
|
||||
useUserToken = false
|
||||
authCodeUnavailable = false
|
||||
ccInstanceConfigured = false
|
||||
ccBringYourOwn = false
|
||||
@@ -449,16 +489,15 @@
|
||||
|
||||
// Google's terms require its own button on the control that starts the sign-in, which is
|
||||
// the step-2 Connect: step 1 only picks a type, and a manual step 2 saves a resource
|
||||
// without ever reaching Google.
|
||||
// without ever reaching Google. Every registry provider that signs in through Google's
|
||||
// authorize endpoint counts; `google` itself is a login provider, not a registry entry.
|
||||
run(() => {
|
||||
isGoogleSignin =
|
||||
step == 2 &&
|
||||
!manual &&
|
||||
(resourceType == 'google' ||
|
||||
resourceType == 'gmail' ||
|
||||
resourceType == 'gcal' ||
|
||||
resourceType == 'gdrive' ||
|
||||
resourceType == 'gsheets')
|
||||
(registryEntryFor(resourceType)?.auth_url?.startsWith('https://accounts.google.com/') ??
|
||||
false))
|
||||
})
|
||||
|
||||
run(() => {
|
||||
@@ -606,16 +645,18 @@
|
||||
value = data.res.access_token!
|
||||
valueToken = data.res
|
||||
responseExtra = data.extra ?? {}
|
||||
fillGeneratedDescription()
|
||||
step = 4
|
||||
// `fillPath` decides the path as surely as express does, so neither stops here.
|
||||
if (fillPath || express) {
|
||||
path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
|
||||
path = fillPath ?? `u/${$userStore?.username}/${defaultName}_${new Date().getTime()}`
|
||||
next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getScopesAndParams() {
|
||||
useUserToken = false
|
||||
if (!connects?.includes(connectClient)) {
|
||||
// No instance OAuth client (registry-declared CC-only provider):
|
||||
// defaults come from the static registry instead.
|
||||
@@ -746,9 +787,10 @@
|
||||
...tokenResponse,
|
||||
grant_type: 'client_credentials' // Mark this token as client_credentials
|
||||
}
|
||||
fillGeneratedDescription()
|
||||
step = 4
|
||||
if (fillPath || express) {
|
||||
path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
|
||||
path = fillPath ?? `u/${$userStore?.username}/${defaultName}_${new Date().getTime()}`
|
||||
next()
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -764,7 +806,11 @@
|
||||
* Opens popup for user to authenticate with OAuth provider
|
||||
*/
|
||||
const url = new URL(`/api/oauth/connect/${connectClient}`, window.location.origin)
|
||||
url.searchParams.append('scopes', scopes.join('+'))
|
||||
// An empty `scopes` still overrides the instance's bot scopes.
|
||||
url.searchParams.append('scopes', useUserToken ? '' : scopes.join('+'))
|
||||
if (useUserToken) {
|
||||
url.searchParams.append('user_scope', scopes.join(','))
|
||||
}
|
||||
if (extra_params.length > 0) {
|
||||
extra_params.forEach(([key, value]) => url.searchParams.append(key, value))
|
||||
}
|
||||
@@ -1147,8 +1193,6 @@
|
||||
if (step !== 1) return
|
||||
highlight.onKeydown(e)
|
||||
}
|
||||
|
||||
let editScopes = $state(false)
|
||||
</script>
|
||||
|
||||
{#if !express}
|
||||
@@ -1557,24 +1601,29 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-emphasis flex gap-4"
|
||||
>Scopes <button
|
||||
onclick={() => {
|
||||
editScopes = !editScopes
|
||||
}}><Pen size={14} /></button
|
||||
></h3
|
||||
>
|
||||
|
||||
{#if editScopes}
|
||||
<OauthScopes bind:scopes options={registryEntry()?.scope_options} />
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each scopes as scope}
|
||||
<div class="py-0.5 pl-2 text-xs">- {scope}</div>
|
||||
{/each}
|
||||
{#if registryEntry()?.user_scopes && !useClientCredentials}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-sm font-semibold text-emphasis mb-1">Connect as</h3>
|
||||
<div class="flex flex-col gap-2" role="radiogroup" aria-label="Connect as">
|
||||
<RadioCard
|
||||
label="The app (bot token)"
|
||||
description="Acts as the app's bot user, in the channels it is added to."
|
||||
selected={!useUserToken}
|
||||
onSelect={() => selectUserToken(false)}
|
||||
/>
|
||||
<RadioCard
|
||||
label="Yourself (user token)"
|
||||
description="Acts as you, with access to what you can see: history, DMs and search."
|
||||
selected={useUserToken}
|
||||
onSelect={() => selectUserToken(true)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-emphasis">Scopes</h3>
|
||||
<OauthScopes bind:scopes options={scopeOptions} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1588,7 +1637,7 @@
|
||||
<Label label="Path">
|
||||
<Path
|
||||
initialPath=""
|
||||
namePlaceholder={resourceType}
|
||||
namePlaceholder={defaultName}
|
||||
bind:error={pathError}
|
||||
bind:path
|
||||
kind="resource"
|
||||
|
||||
@@ -253,6 +253,9 @@
|
||||
|
||||
let ignoreValueUndefined = $state(false)
|
||||
let error: string = $state('')
|
||||
// Parse error of the JSON editor below, which already displays it. Text that does not parse
|
||||
// never reaches `value`, so without this the field would stay valid on its last parsed value.
|
||||
let jsonError: string = $state('')
|
||||
let isListJson = $state(false)
|
||||
let hasIsListJsonChanged = $state(false)
|
||||
|
||||
@@ -417,9 +420,12 @@
|
||||
'^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$'
|
||||
|
||||
function validateInput(pattern: string | undefined, v: any, required: boolean): void {
|
||||
if (nullable && emptyString(v)) {
|
||||
if (jsonError) {
|
||||
error = ''
|
||||
valid && (valid = true)
|
||||
valid && (valid = false)
|
||||
} else if (nullable && emptyString(v)) {
|
||||
error = ''
|
||||
!valid && (valid = true)
|
||||
} else if (
|
||||
typeof v === 'string' &&
|
||||
(v.startsWith('$var:') || v.startsWith('$res:') || v.startsWith('$jsonvar:'))
|
||||
@@ -563,6 +569,7 @@
|
||||
|
||||
$effect(() => {
|
||||
extra?.['nonEmpty']
|
||||
jsonError
|
||||
let args = [pattern, value, required] as const
|
||||
untrack(() => validateInput(...args))
|
||||
})
|
||||
@@ -1262,6 +1269,7 @@
|
||||
dispatch('blur')
|
||||
}}
|
||||
code={rawValue}
|
||||
bind:error={jsonError}
|
||||
on:changeValue={(e) => {
|
||||
setNewValueFromCode(e.detail)
|
||||
}}
|
||||
@@ -1284,6 +1292,7 @@
|
||||
dispatch('blur')
|
||||
}}
|
||||
code={rawValue}
|
||||
bind:error={jsonError}
|
||||
on:change={(e) => {
|
||||
value = e.detail
|
||||
}}
|
||||
@@ -1373,6 +1382,7 @@
|
||||
dispatch('blur')
|
||||
}}
|
||||
code={rawValue}
|
||||
bind:error={jsonError}
|
||||
on:changeValue={(e) => {
|
||||
setNewValueFromCode(e.detail)
|
||||
}}
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
'gmail',
|
||||
'gcal',
|
||||
'gforms',
|
||||
'gdocs',
|
||||
'gchat',
|
||||
'gcloud',
|
||||
'gworkspace',
|
||||
'basecamp',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ScriptService, type CiTestResult } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { CircleCheck, CircleX, Loader2, FlaskConical } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
@@ -12,8 +12,10 @@
|
||||
|
||||
let { path, kind }: Props = $props()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
let ciTests = resource(
|
||||
() => ({ workspace: $workspaceStore!, path, kind }),
|
||||
() => ({ workspace: $operatingWorkspace!, path, kind }),
|
||||
async (args) => {
|
||||
if (!args.workspace || !args.path) return []
|
||||
return ScriptService.getCiTestResults({
|
||||
@@ -55,14 +57,14 @@
|
||||
<div class="w-3.5 h-3.5 rounded-full border border-tertiary"></div>
|
||||
{/if}
|
||||
<a
|
||||
href="{base}/scripts/get/{test.test_script_path}?workspace={$workspaceStore}"
|
||||
href="{base}/scripts/get/{test.test_script_path}?workspace={$operatingWorkspace}"
|
||||
class="text-secondary hover:text-primary truncate"
|
||||
>
|
||||
{test.test_script_path}
|
||||
</a>
|
||||
{#if test.job_id}
|
||||
<a
|
||||
href="{base}/run/{test.job_id}?workspace={$workspaceStore}"
|
||||
href="{base}/run/{test.job_id}?workspace={$operatingWorkspace}"
|
||||
class="text-tertiary hover:text-secondary text-2xs"
|
||||
>
|
||||
view run
|
||||
|
||||
@@ -28,9 +28,13 @@ leaves and ignores the current scope.
|
||||
resolveScope,
|
||||
scopeChain,
|
||||
type DrillBranch,
|
||||
type DrillIcon,
|
||||
type DrillLeaf,
|
||||
type DrillNode
|
||||
} from './drillPicker'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import type { BadgeColor } from '$lib/components/common/badge/model'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
|
||||
interface Props {
|
||||
tree: DrillNode<L>[]
|
||||
@@ -72,6 +76,18 @@ leaves and ignores the current scope.
|
||||
/** Full-text hover tooltip for a leaf row (rows truncate their text).
|
||||
* Return `undefined` to show none for that leaf. */
|
||||
rowTooltip?: (leaf: DrillLeaf<L>) => string | undefined
|
||||
/** A second thing a leaf row can do, offered on hover and on ⌘/Ctrl-pick
|
||||
* (the preview picker's "open the editor instead of the deployed page").
|
||||
* Return `undefined` for a leaf that has only one. The button renders as a
|
||||
* SIBLING of the row — a button inside a button is invalid markup — and
|
||||
* stays out of the tab order, because focus lives in the search field and
|
||||
* rows are reached with the arrow keys. */
|
||||
leafAction?: (
|
||||
leaf: DrillLeaf<L>
|
||||
) => { icon: DrillIcon; label: string; title: string; run: () => void } | undefined
|
||||
/** Status badge on the right of a leaf row. Returns `undefined` for a leaf with
|
||||
* nothing to report. The hover action, when there is one, covers it. */
|
||||
leafBadge?: (leaf: DrillLeaf<L>) => { label: string; color: BadgeColor } | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -88,7 +104,9 @@ leaves and ignores the current scope.
|
||||
onScopeChange,
|
||||
onFilterChange,
|
||||
rootLoading = false,
|
||||
rowTooltip
|
||||
rowTooltip,
|
||||
leafAction,
|
||||
leafBadge
|
||||
}: Props = $props()
|
||||
|
||||
let searchInput: TextInput | undefined = $state()
|
||||
@@ -277,8 +295,21 @@ leaves and ignores the current scope.
|
||||
if (mouseActive) highlightedKey = key
|
||||
}
|
||||
|
||||
function pick(leaf: DrillLeaf<L>) {
|
||||
if (leaf.current || leaf.disabled) return
|
||||
/** `alt` picks the row's second action (see `leafAction`) rather than the row
|
||||
* itself — ⌘/Ctrl-click and ⌘/Ctrl-Enter, the keyboard reach for the button
|
||||
* that only appears on hover. A leaf with no second action ignores it. */
|
||||
function pick(leaf: DrillLeaf<L>, alt = false) {
|
||||
if (leaf.disabled) return
|
||||
// The second action is the row's other destination (its editor, for the deployed page
|
||||
// this tab shows), so it stays available on the current row the plain pick skips.
|
||||
if (alt) {
|
||||
const action = leafAction?.(leaf)
|
||||
if (action) {
|
||||
action.run()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (leaf.current) return
|
||||
onPick(leaf)
|
||||
}
|
||||
|
||||
@@ -316,23 +347,23 @@ leaves and ignores the current scope.
|
||||
|
||||
$effect(() => () => clearTimeout(tooltipTimer))
|
||||
|
||||
function activate(key: string | undefined) {
|
||||
function activate(key: string | undefined, alt = false) {
|
||||
if (!key) return
|
||||
if (isSearching) {
|
||||
const found = (searchedItems ?? []).find((r) => r.leaf.key === key)
|
||||
if (found) pick(found.leaf)
|
||||
if (found) pick(found.leaf, alt)
|
||||
return
|
||||
}
|
||||
const entry = entryList.find((e) => e.key === key)
|
||||
if (!entry) return
|
||||
drill(entry)
|
||||
drill(entry, alt)
|
||||
}
|
||||
|
||||
function drill(entry: Entry) {
|
||||
function drill(entry: Entry, alt = false) {
|
||||
if (entry.type === 'branch') {
|
||||
scope = [...scope, entry.key]
|
||||
} else {
|
||||
pick(entry.node)
|
||||
pick(entry.node, alt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +393,7 @@ leaves and ignores the current scope.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
mouseActive = false
|
||||
activate(highlightedKey)
|
||||
activate(highlightedKey, e.metaKey || e.ctrlKey)
|
||||
} else if (
|
||||
(e.key === 'ArrowLeft' || e.key === 'Backspace') &&
|
||||
filter === '' &&
|
||||
@@ -476,42 +507,97 @@ leaves and ignores the current scope.
|
||||
{@const isHl = key === highlightedKey}
|
||||
{@const isCur = !!leaf.current}
|
||||
{@const tip = rowTooltip?.(leaf)}
|
||||
<button
|
||||
type="button"
|
||||
id={idFor(key)}
|
||||
role="option"
|
||||
aria-selected={isHl}
|
||||
data-nav-key={key}
|
||||
aria-current={isCur ? 'true' : undefined}
|
||||
aria-label={tip ? `${leaf.label} — ${tip}` : undefined}
|
||||
class="w-full text-left flex items-center gap-2 px-3 transition-colors {baseClass} {isHl
|
||||
{@const action = leaf.disabled ? undefined : leafAction?.(leaf)}
|
||||
{@const badge = leafBadge?.(leaf)}
|
||||
<!-- The row's action sits BESIDE the option rather than inside it: a button nested in
|
||||
a button is invalid markup. The wrapper carries the row's padding, highlight and
|
||||
hover group so the two still read as one row. -->
|
||||
<!-- Hover is tracked on the wrapper, not on the option button: the row's action is a
|
||||
sibling of that button, so entering it would otherwise read as leaving the row and
|
||||
drop the highlight out from under the pointer. -->
|
||||
<div
|
||||
class="group flex items-center transition-colors pr-3 {baseClass} {isHl
|
||||
? 'bg-surface-hover'
|
||||
: ''} {isCur ? 'cursor-default text-emphasis font-medium' : ''} {leaf.disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''}"
|
||||
disabled={leaf.disabled}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => pick(leaf)}
|
||||
onmouseenter={(e) => {
|
||||
setHoverHighlight(key)
|
||||
tooltipEnter(e.currentTarget as HTMLElement, leaf)
|
||||
}}
|
||||
onmouseleave={tooltipLeave}
|
||||
>
|
||||
{@render defaultLeafIcon(leaf)}
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if leaf.secondary}
|
||||
<div class="text-xs text-primary font-normal truncate">{leaf.label}</div>
|
||||
<div class="text-2xs text-hint font-normal font-mono truncate">
|
||||
{secondary ?? leaf.secondary}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-primary font-normal font-mono truncate">
|
||||
{secondary ?? leaf.label}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id={idFor(key)}
|
||||
role="option"
|
||||
aria-selected={isHl}
|
||||
data-nav-key={key}
|
||||
aria-current={isCur ? 'true' : undefined}
|
||||
aria-label={tip ? `${leaf.label} — ${tip}` : undefined}
|
||||
class="min-w-0 flex-1 text-left flex items-center gap-2 pl-3 {isCur
|
||||
? 'cursor-default text-emphasis font-medium'
|
||||
: ''} {leaf.disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
||||
disabled={leaf.disabled}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={(e) => pick(leaf, e.metaKey || e.ctrlKey)}
|
||||
>
|
||||
{@render defaultLeafIcon(leaf)}
|
||||
<!-- Centred in the row's full height so a one-line leaf sits where a two-line
|
||||
one's pair does, rather than riding high against the top. -->
|
||||
<div class="min-w-0 flex-1 flex flex-col justify-center min-h-[2.25rem]">
|
||||
{#if leaf.secondary}
|
||||
<div class="text-xs text-primary font-normal truncate">{leaf.label}</div>
|
||||
<div class="text-2xs text-hint font-normal font-mono truncate">
|
||||
{secondary ?? leaf.secondary}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-primary font-normal font-mono truncate">
|
||||
{secondary ?? leaf.label}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<!-- Keyed on the props rather than on this leaf's own badge or action, so a picker
|
||||
that has either holds the column open on every row: paths then truncate at one
|
||||
column and the button lands in the same place down the list. -->
|
||||
{#if leafBadge || leafAction}
|
||||
<!-- Both sit in one grid cell so they cross-fade in place rather than sitting
|
||||
side by side or shifting the row as one replaces the other. -->
|
||||
<div class="shrink-0 w-[4.5rem] grid justify-items-end items-center pl-2">
|
||||
{#if action}
|
||||
<!-- `invisible`, not `hidden`: display cannot be transitioned, while
|
||||
visibility both fades with opacity and keeps the button out of the tab
|
||||
order while it is out of sight — the picker's focus belongs to its
|
||||
search field, and rows are reached with the arrow keys.
|
||||
The classes sit on this wrapper, not on the Button: `wrapperClasses` is
|
||||
twMerged with the button's own `flex`, which drops a conflicting
|
||||
display and leaves it showing beside the badge. -->
|
||||
<div
|
||||
class="col-start-1 row-start-1 invisible opacity-0 transition-[opacity,visibility] duration-150 group-hover:visible group-hover:opacity-100"
|
||||
>
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
title={action.title}
|
||||
startIcon={{ icon: action.icon }}
|
||||
onClick={action.run}
|
||||
wrapperClasses="shrink-0"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if badge}
|
||||
<div
|
||||
class="col-start-1 row-start-1 transition-[opacity,visibility] duration-150 {action
|
||||
? 'group-hover:invisible group-hover:opacity-0'
|
||||
: ''}"
|
||||
>
|
||||
<Badge small color={badge.color}>{badge.label}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
|
||||
@@ -1831,9 +1831,15 @@
|
||||
scriptLang === 'bunnative' ||
|
||||
scriptLang === 'nativets')
|
||||
) {
|
||||
const resourceTypes = await ResourceService.listResourceType({
|
||||
workspace: $operatingWorkspace ?? ''
|
||||
})
|
||||
// The generated client resolves `undefined` for any response whose body it
|
||||
// cannot read — `getResponseBody` swallows a parse failure and returns
|
||||
// nothing, which `catchErrorCodes` passes through on a 2xx. Typing says
|
||||
// otherwise, so without this the editor dies on mount rather than losing
|
||||
// its resource-type autocomplete.
|
||||
const resourceTypes =
|
||||
(await ResourceService.listResourceType({
|
||||
workspace: $operatingWorkspace ?? ''
|
||||
})) ?? []
|
||||
|
||||
const namespace = formatResourceTypes(
|
||||
resourceTypes,
|
||||
|
||||
@@ -1136,10 +1136,11 @@
|
||||
onClick: () => void
|
||||
}> = []
|
||||
|
||||
// In a session pane every one of these leaves the session (details page, new
|
||||
// tab), so the deploy button carries no dropdown there — as in ScriptBuilder.
|
||||
if (untrack(() => customUi).topBar?.extraDeployOptions != false && !inSessionPane) {
|
||||
if (!newFlow) {
|
||||
if (untrack(() => customUi).topBar?.extraDeployOptions != false) {
|
||||
// The one option that stays in a session pane: the session hosts the details page
|
||||
// itself, so this flips the tab to it rather than leaving the session. Everything
|
||||
// below opens a details page or a new tab outside the session, so it stays hidden.
|
||||
if (!newFlow && savedFlow?.no_deployed !== true) {
|
||||
dropdownItems.push({
|
||||
label: 'Exit & see details',
|
||||
// Use the deployed path, not the live `$pathStore` — the latter
|
||||
@@ -1149,7 +1150,7 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (!untrack(() => newFlow)) {
|
||||
if (!untrack(() => newFlow) && !inSessionPane) {
|
||||
dropdownItems.push({
|
||||
label: 'Fork',
|
||||
onClick: () => window.open(`/flows/add?template=${initialPath}`)
|
||||
@@ -1158,6 +1159,7 @@
|
||||
|
||||
if (
|
||||
!untrack(() => newFlow) &&
|
||||
!inSessionPane &&
|
||||
!isCloudHosted() &&
|
||||
editInForkAllowed(opWorkspace, $userWorkspaces)
|
||||
) {
|
||||
|
||||
@@ -40,7 +40,12 @@
|
||||
// Identity for this editor's entry in the unparseable registry, so a caller about to
|
||||
// persist what is on screen can refuse rather than save the last value that parsed.
|
||||
const unparseableKey = {}
|
||||
onDestroy(() => setEditorUnparseable(unparseableKey, false))
|
||||
// Also clears a bound `error`: a parent gating validity on it would otherwise stay invalid
|
||||
// after the editor that reported the error is gone.
|
||||
onDestroy(() => {
|
||||
error = ''
|
||||
setEditorUnparseable(unparseableKey, false)
|
||||
})
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
|
||||
@@ -57,13 +62,17 @@
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
}
|
||||
setEditorUnparseable(unparseableKey, error !== '')
|
||||
setEditorUnparseable(unparseableKey, error !== '', rootEl)
|
||||
}
|
||||
let rootEl: HTMLElement | undefined = $state()
|
||||
$effect(() => {
|
||||
code != undefined && untrack(() => parseJson())
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Wraps both branches: the unparseable registry locates this editor by it, and the
|
||||
too-big placeholder must not drop an invalid editor out of its form's run gate. -->
|
||||
<div bind:this={rootEl} class="contents">
|
||||
{#if tooBig && !loadTooBigAnyway}
|
||||
<div class="flex-1 text-sm">
|
||||
JSON is too big
|
||||
@@ -100,3 +109,4 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import { createEventDispatcher, untrack } from 'svelte'
|
||||
import { createEventDispatcher, onDestroy, untrack } from 'svelte'
|
||||
import { setEditorUnparseable } from './pendingEditorFlush'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -31,6 +32,19 @@
|
||||
let userEdited = false
|
||||
let simpleEditor: SimpleEditor | undefined = $state(undefined)
|
||||
let focusTrap: HTMLElement | undefined = $state()
|
||||
let parseError = $state('')
|
||||
|
||||
// Text that does not parse dispatches `undefined`, which callers read as "keep the last args":
|
||||
// registering it lets whoever runs or persists those args refuse instead of using stale ones.
|
||||
const unparseableKey = {}
|
||||
onDestroy(() => setEditorUnparseable(unparseableKey, false))
|
||||
|
||||
let rootEl: HTMLElement | undefined = $state()
|
||||
|
||||
function setParseError(message: string) {
|
||||
parseError = message
|
||||
setEditorUnparseable(unparseableKey, message !== '', rootEl)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const next = initialCode
|
||||
@@ -48,17 +62,21 @@
|
||||
userEdited = false
|
||||
pendingJson = code
|
||||
simpleEditor?.setCode(code)
|
||||
setParseError('')
|
||||
}
|
||||
|
||||
function updatePayloadFromJson(jsonInput: string) {
|
||||
if (jsonInput === undefined || jsonInput === null || jsonInput.trim() === '') {
|
||||
setParseError('')
|
||||
dispatch('select', undefined)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(jsonInput)
|
||||
setParseError('')
|
||||
dispatch('select', parsed)
|
||||
} catch (error) {
|
||||
setParseError(error instanceof Error ? error.message : String(error))
|
||||
dispatch('select', undefined)
|
||||
}
|
||||
}
|
||||
@@ -88,31 +106,36 @@
|
||||
<!-- Add a hidden button that can receive focus -->
|
||||
<button bind:this={focusTrap} class="sr-only" tabindex="-1" aria-hidden="true">Focus trap</button>
|
||||
|
||||
<div class="h-full rounded-md border">
|
||||
<SimpleEditor
|
||||
bind:this={simpleEditor}
|
||||
on:input={() => (userEdited = true)}
|
||||
on:focus={() => {
|
||||
if (updateOnBlur) {
|
||||
dispatch('focus')
|
||||
updatePayloadFromJson(pendingJson)
|
||||
}
|
||||
}}
|
||||
on:blur={async () => {
|
||||
if (updateOnBlur) {
|
||||
dispatch('blur')
|
||||
}
|
||||
}}
|
||||
on:change={(e) => {
|
||||
if (e.detail?.code !== undefined) {
|
||||
updatePayloadFromJson(e.detail.code)
|
||||
}
|
||||
}}
|
||||
bind:code={pendingJson}
|
||||
lang="json"
|
||||
class="h-full json-inputs-editor"
|
||||
{placeholder}
|
||||
/>
|
||||
<div bind:this={rootEl} class="h-full flex flex-col">
|
||||
<div class="flex-1 min-h-0 rounded-md border">
|
||||
<SimpleEditor
|
||||
bind:this={simpleEditor}
|
||||
on:input={() => (userEdited = true)}
|
||||
on:focus={() => {
|
||||
if (updateOnBlur) {
|
||||
dispatch('focus')
|
||||
updatePayloadFromJson(pendingJson)
|
||||
}
|
||||
}}
|
||||
on:blur={async () => {
|
||||
if (updateOnBlur) {
|
||||
dispatch('blur')
|
||||
}
|
||||
}}
|
||||
on:change={(e) => {
|
||||
if (e.detail?.code !== undefined) {
|
||||
updatePayloadFromJson(e.detail.code)
|
||||
}
|
||||
}}
|
||||
bind:code={pendingJson}
|
||||
lang="json"
|
||||
class="h-full json-inputs-editor"
|
||||
{placeholder}
|
||||
/>
|
||||
</div>
|
||||
{#if parseError}
|
||||
<span class="text-red-600 dark:text-red-400 text-xs mt-1">Invalid JSON: {parseError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace, useOperatingUser } from './operatingWorkspace.svelte'
|
||||
import { Alert, Button, Drawer } from './common'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import Path from './Path.svelte'
|
||||
@@ -12,6 +12,12 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// A move is a write, so it has to land in the workspace the host acts on: inside an AI
|
||||
// session's editor or deployed view that is the session's (possibly forked) workspace,
|
||||
// while the navigation store still names the parent.
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
|
||||
type Kind = 'script' | 'resource' | 'schedule' | 'variable' | 'flow' | 'app'
|
||||
|
||||
/** The address the move endpoint takes: where a draft-only item's row lives, which
|
||||
@@ -30,7 +36,13 @@
|
||||
|
||||
let drawer = $state<Drawer>() as Drawer
|
||||
|
||||
let own = $state(false)
|
||||
// Derived rather than read when the drawer opens: the acting user in another workspace is
|
||||
// looked up asynchronously, so a snapshot taken at open time says "not owner" for a fork.
|
||||
const ownerKnown = $derived(operatingUser.resolved($operatingWorkspace))
|
||||
const own = $derived(
|
||||
!!operatingUser.current &&
|
||||
isOwner(draftOnly ? storagePath : initialPath, operatingUser.current, $operatingWorkspace!)
|
||||
)
|
||||
let onBehalfOfEmail = $state<string | undefined>(undefined)
|
||||
// Counts of triggers/schedules/etc. that reference this script or flow.
|
||||
// The backend cascades `script_path` on rename across all trigger tables
|
||||
@@ -93,13 +105,12 @@
|
||||
initialPath = initialPath_l
|
||||
initialSummary = summary_l ?? ''
|
||||
summary = summary_l
|
||||
loadOwner()
|
||||
drawer.openDrawer()
|
||||
if (draftOnly) {
|
||||
return
|
||||
}
|
||||
if (kind === 'flow') {
|
||||
onBehalfOfEmail = await checkFlowOnBehalfOf($workspaceStore!, initialPath_l)
|
||||
onBehalfOfEmail = await checkFlowOnBehalfOf($operatingWorkspace!, initialPath_l)
|
||||
}
|
||||
if (kind === 'script' || kind === 'flow') {
|
||||
void loadAttachedTriggers()
|
||||
@@ -108,7 +119,7 @@
|
||||
|
||||
async function loadAttachedTriggers() {
|
||||
try {
|
||||
const workspace = $workspaceStore!
|
||||
const workspace = $operatingWorkspace!
|
||||
attachedTriggers =
|
||||
kind === 'flow'
|
||||
? await FlowService.getTriggersCountOfFlow({ workspace, path: initialPath })
|
||||
@@ -119,21 +130,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function loadOwner() {
|
||||
own = isOwner(draftOnly ? storagePath : initialPath, $userStore!, $workspaceStore!)
|
||||
}
|
||||
|
||||
async function updatePath() {
|
||||
if (draftOnly && (kind === 'flow' || kind === 'script' || kind === 'app')) {
|
||||
await DraftService.moveDraft({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
kind: kind === 'app' && rawApp ? 'raw_app' : kind,
|
||||
path: storagePath,
|
||||
requestBody: { new_path: path ?? '', summary: summary ?? '' }
|
||||
})
|
||||
} else if (kind === 'flow' || kind === 'script' || kind === 'app') {
|
||||
await updateItemPathAndSummary({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
kind,
|
||||
initialPath,
|
||||
newPath: path ?? '',
|
||||
@@ -147,14 +154,16 @@
|
||||
|
||||
<Drawer bind:this={drawer}>
|
||||
<DrawerContent title="Move/Rename {initialPath}" on:close={drawer.closeDrawer}>
|
||||
{#if !own}
|
||||
<!-- Only once the acting user is known: while the lookup is in flight `own` is false,
|
||||
and stating "you do not own this" then would be a guess. -->
|
||||
{#if ownerKnown && !own}
|
||||
<Alert type="warning" title="Not owner" class="mb-4">
|
||||
Since you do not own this item, you cannot move this item (you can however fork it)
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if own && onBehalfOfEmail}
|
||||
<Alert type="info" title="Run on behalf of" class="mb-4">
|
||||
This flow will be redeployed on behalf of you ({$userStore?.email}) instead of {onBehalfOfEmail}
|
||||
This flow will be redeployed on behalf of you ({operatingUser.current?.email}) instead of {onBehalfOfEmail}
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if (kind === 'script' || kind === 'flow') && attachedTotal > 0}
|
||||
|
||||
@@ -6,13 +6,27 @@
|
||||
|
||||
interface Props {
|
||||
scopes?: string[]
|
||||
/** Scopes the provider is known to accept, offered as checkboxes. Anything
|
||||
* not in this list stays editable as free text below them. */
|
||||
options?: string[]
|
||||
/** Scopes the provider is known to accept, offered as checkboxes, either a
|
||||
* bare scope or one with a human label. Anything not in this list stays
|
||||
* editable as free text below them. */
|
||||
options?: (string | { value: string; label: string })[]
|
||||
}
|
||||
|
||||
let { scopes = $bindable(), options = [] }: Props = $props()
|
||||
|
||||
// First occurrence wins, so a labelled option keeps its label when the same
|
||||
// scope is also passed bare (e.g. as an instance default).
|
||||
let choices = $derived(
|
||||
options
|
||||
.map((o) =>
|
||||
typeof o == 'string'
|
||||
? { value: o, label: o }
|
||||
: { value: o.value, label: o.label || o.value }
|
||||
)
|
||||
.filter((c, i, all) => all.findIndex((d) => d.value == c.value) == i)
|
||||
)
|
||||
let values = $derived(choices.map((c) => c.value))
|
||||
|
||||
// Ticked options and free-text rows are kept apart from `scopes` (the only
|
||||
// value the parent binds) so a row can pass through an option's exact value
|
||||
// while typing (`…/calendar` on the way to `…/calendar.acls`) without ticking
|
||||
@@ -26,11 +40,11 @@
|
||||
if (!scopes) {
|
||||
scopes = []
|
||||
}
|
||||
const json = JSON.stringify([scopes, options])
|
||||
const json = JSON.stringify([scopes, values])
|
||||
if (json != lastWritten) {
|
||||
lastWritten = json
|
||||
ticked = scopes.filter((v) => options.includes(v))
|
||||
custom = scopes.filter((v) => !options.includes(v))
|
||||
ticked = scopes.filter((v) => values.includes(v))
|
||||
custom = scopes.filter((v) => !values.includes(v))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -38,7 +52,7 @@
|
||||
ticked = nextTicked
|
||||
custom = rows
|
||||
scopes = [...nextTicked.filter((o) => !rows.includes(o)), ...rows]
|
||||
lastWritten = JSON.stringify([scopes, options])
|
||||
lastWritten = JSON.stringify([scopes, values])
|
||||
}
|
||||
|
||||
// Ticking an option absorbs a free-text row holding the same value. The
|
||||
@@ -59,17 +73,23 @@
|
||||
|
||||
{#if options.length > 0}
|
||||
<div class="flex flex-col gap-1 mb-2">
|
||||
{#each options as option (option)}
|
||||
{#each choices as { value, label } (value)}
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={ticked.includes(option)}
|
||||
onChange={() => toggle(option, !ticked.includes(option))}
|
||||
checked={ticked.includes(value)}
|
||||
onChange={() => toggle(value, !ticked.includes(value))}
|
||||
/>
|
||||
<span class="font-mono break-all">{option}</span>
|
||||
{#if label == value}
|
||||
<span class="font-mono break-all">{value}</span>
|
||||
{:else}
|
||||
<span class="flex flex-col">
|
||||
<span>{label}</span>
|
||||
<span class="text-2xs text-hint font-mono break-all">{value}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="text-xs text-secondary">Custom scopes</span>
|
||||
{/if}
|
||||
|
||||
{#each custom as v, i (i)}
|
||||
@@ -104,11 +124,6 @@
|
||||
write(ticked, [...custom, ''])
|
||||
}}
|
||||
>
|
||||
Add item
|
||||
Add scope
|
||||
</Button>
|
||||
{#if custom.length > 0}
|
||||
<span class="ml-2 text-xs text-primary font-normal">
|
||||
({custom.length} item{custom.length > 1 ? 's' : ''})
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ section name — and the route's own buttons at the far end.
|
||||
The row's height matches the sidebar's own header row, so the two read as one band.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PanelLeft, PanelTop, PanelTopDashed } from 'lucide-svelte'
|
||||
import { PanelLeft } from 'lucide-svelte'
|
||||
import { navDetached } from './sidebar/navDetached.svelte'
|
||||
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
|
||||
import NavBreadcrumb from './NavBreadcrumb.svelte'
|
||||
@@ -18,24 +18,12 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
|
||||
let {
|
||||
navHidden = false,
|
||||
hideNavHandle = false,
|
||||
panelled = false,
|
||||
onPin,
|
||||
onUnpin
|
||||
panelled = false
|
||||
}: {
|
||||
navHidden?: boolean
|
||||
/** Drops the sidebar's handle, for a page whose own floating control reveals the sidebar
|
||||
* along with this band — two handles for one gesture would be one too many. */
|
||||
hideNavHandle?: boolean
|
||||
/** The sidebar is a panel over the page rather than a rail beside it — detached, or a
|
||||
* window too narrow to seat one. Either way this bar carries the switch that opens it. */
|
||||
panelled?: boolean
|
||||
/** Keeps the band on a page that owns the viewport. The corner handle that called the band
|
||||
* down is underneath it by the time the pointer arrives, so this bar has to carry the pin
|
||||
* itself — it lands on the same pixels, in place of the sidebar handle it hides. */
|
||||
onPin?: () => void
|
||||
/** Sends the band back behind its handle, on a page that owns the viewport. */
|
||||
onUnpin?: () => void
|
||||
} = $props()
|
||||
|
||||
const content = $derived(pageHeader.content)
|
||||
@@ -99,37 +87,7 @@ 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))]'
|
||||
)}
|
||||
>
|
||||
<!-- Pin before the sidebar's handle, so it holds the first slot whether or not the handle is
|
||||
there. Pinning brings the handle back — the band is no longer hidden — and from behind the
|
||||
handle the unpin button would sit one slot over from where the pin was just clicked, which
|
||||
for an operator (detached by default) means the second click opens the sidebar instead. -->
|
||||
{#if onPin}
|
||||
<!-- Same box and offsets as the corner handle it comes to rest over, so the pointer that
|
||||
called the band down is already on this button. -->
|
||||
<button
|
||||
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
|
||||
aria-label="Pin the header on deployed apps"
|
||||
title="Pin the header on deployed apps"
|
||||
onclick={() => onPin?.()}
|
||||
>
|
||||
<PanelTop size={16} class="flex-shrink-0 text-hint" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if onUnpin}
|
||||
<button
|
||||
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
|
||||
aria-label="Unpin the header from deployed apps"
|
||||
title="Unpin the header from deployed apps"
|
||||
onclick={() => onUnpin?.()}
|
||||
>
|
||||
<!-- Dashed while the band is shown, solid while it is away: the pair the sidebar's own
|
||||
toggle uses, so the two controls read as one idea. -->
|
||||
<PanelTopDashed size={16} class="flex-shrink-0 text-hint" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if (navDetached.val || panelled) && !navHidden && !hideNavHandle}
|
||||
{#if (navDetached.val || panelled) && !navHidden}
|
||||
<!-- Reveals the hidden sidebar, and only that: hovering slides the card in, clicking holds
|
||||
it there. Attaching it for good belongs to the toggle in the sidebar's own footer, where
|
||||
detaching it happened — a control that hides the thing it sits on cannot also be the
|
||||
|
||||
@@ -19,8 +19,7 @@ the header, so nesting is decided by the caller rather than guessed from context
|
||||
actionsFlexible,
|
||||
contexts,
|
||||
actingWorkspaceId,
|
||||
barRightInset,
|
||||
fullBleed
|
||||
barRightInset
|
||||
}: PageHeaderContent = $props()
|
||||
|
||||
onMount(() => {
|
||||
@@ -33,8 +32,7 @@ the header, so nesting is decided by the caller rather than guessed from context
|
||||
actionsFlexible,
|
||||
contexts,
|
||||
actingWorkspaceId,
|
||||
barRightInset,
|
||||
fullBleed
|
||||
barRightInset
|
||||
}))
|
||||
return () => pageHeader.release(id)
|
||||
})
|
||||
|
||||
@@ -7,13 +7,13 @@ popover is open this holds the path as it was when it opened, so the trail — a
|
||||
to it — does not reflow under the pointer as the user types, which floating-ui would follow.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte'
|
||||
import { Pencil } from 'lucide-svelte'
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import type { WorkspaceItemKind } from '$lib/components/workspacePicker'
|
||||
import { isOwner } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
import {
|
||||
@@ -36,7 +36,10 @@ to it — does not reflow under the pointer as the user types, which floating-ui
|
||||
path?: string
|
||||
/** The item's *saved* path on the server, so the popover can say a rename needs deploying. */
|
||||
savedPath?: string
|
||||
kind?: WorkspaceItemKind
|
||||
/** Scopes the path picker's validation, and names the item in the placeholder and the
|
||||
* deploy note. An agent lives at a resource path, so this is wider than the three
|
||||
* workspace item kinds. */
|
||||
kind?: ComponentProps<typeof Path>['kind']
|
||||
/** Workspace the path picker scopes to; defaults to the operating workspace. */
|
||||
workspaceId?: string
|
||||
/** When set, warns that a redeploy happens on behalf of the current user instead. */
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { Badge, Button, Drawer } from './common'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import { createEventDispatcher, onDestroy } from 'svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { base } from '$lib/base'
|
||||
import { displayDate, sleep, sendUserToast } from '$lib/utils'
|
||||
import TableCustom from './TableCustom.svelte'
|
||||
import { Hourglass, Loader2, Play, RefreshCw } from 'lucide-svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
let dispatch = createEventDispatcher()
|
||||
let drawer: Drawer | undefined = $state()
|
||||
@@ -38,7 +40,7 @@
|
||||
const timeStart = new Date().getTime()
|
||||
queuedJobsLoading = true
|
||||
let qjs = await JobService.listQueue({
|
||||
workspace: $workspaceStore ?? '',
|
||||
workspace: $operatingWorkspace ?? '',
|
||||
orderDesc: false,
|
||||
scriptPathExact: script?.path
|
||||
})
|
||||
@@ -70,7 +72,7 @@
|
||||
async function scaleToZero() {
|
||||
cancellingInProgress = true
|
||||
await JobService.cancelPersistentQueuedJobs({
|
||||
workspace: $workspaceStore ?? '',
|
||||
workspace: $operatingWorkspace ?? '',
|
||||
path: script?.path ?? '',
|
||||
requestBody: {
|
||||
reason: undefined
|
||||
@@ -139,7 +141,7 @@
|
||||
<td class="text-xs">
|
||||
<a
|
||||
class="pr-3"
|
||||
href="{base}/scripts/get/{scriptHash}?workspace={$workspaceStore}"
|
||||
href="{base}/scripts/get/{scriptHash}?workspace={$operatingWorkspace}"
|
||||
target="_blank"
|
||||
>
|
||||
{scriptHash}
|
||||
@@ -148,7 +150,7 @@
|
||||
<td class="text-xs">
|
||||
<a
|
||||
class="pr-3"
|
||||
href="{base}/run/{jobId}?workspace={$workspaceStore}"
|
||||
href="{base}/run/{jobId}?workspace={$operatingWorkspace}"
|
||||
target="_blank">{jobId.substring(24)}</a
|
||||
>
|
||||
</td>
|
||||
|
||||
@@ -22,10 +22,18 @@
|
||||
import { argsToJsonPayload } from '$lib/schema'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import { processSecretArgs } from './secretArgUtils'
|
||||
import { enforceDisabledDefaults, resetKeysToast } from './job_args'
|
||||
import PowerShellCommonParams from './PowerShellCommonParams.svelte'
|
||||
import { anyEditorUnparseable, flushAllPendingEditorChanges } from './pendingEditorFlush'
|
||||
import { useOperatingWorkspace } from './operatingWorkspace.svelte'
|
||||
|
||||
// The ephemeral secret variable a password argument mints has to be created in the same
|
||||
// workspace the job runs in: a form embedded in a session runs the job in the session's
|
||||
// workspace, and a `$var:` minted in the navigation workspace resolves to nothing there.
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
let formEl: HTMLElement | undefined = $state()
|
||||
|
||||
let reloadArgs = $state(0)
|
||||
let jsonEditor: JsonInputs | undefined = $state(undefined)
|
||||
@@ -33,6 +41,13 @@
|
||||
let showInputSelectedBadge = $state(false)
|
||||
let savedPreviousArgs: Record<string, any> | undefined = $state(undefined)
|
||||
let psCommonParams: Record<string, any> = $state({})
|
||||
// Reset on a view switch, where the editor that refused the run is gone, and on a form
|
||||
// validity change, where the field's own error and the disabled button take over.
|
||||
let blockedByUnparseable = $derived.by(() => {
|
||||
void jsonView
|
||||
void isValid
|
||||
return false
|
||||
})
|
||||
|
||||
function extractPsCommonParams(allArgs: Record<string, any>): {
|
||||
scriptArgs: Record<string, any>
|
||||
@@ -60,13 +75,22 @@
|
||||
}
|
||||
|
||||
export async function run(overrideScheduledForStr?: string | undefined | null) {
|
||||
// An editor whose text does not parse never wrote it to `args`, so running now would send
|
||||
// the last value that did parse. Flush first: a keystroke still inside the editor debounce
|
||||
// has not been parsed yet, and per-field editors parse it in an effect, hence the tick.
|
||||
flushAllPendingEditorChanges()
|
||||
await tick()
|
||||
blockedByUnparseable = anyEditorUnparseable(formEl)
|
||||
if (blockedByUnparseable) {
|
||||
return
|
||||
}
|
||||
let processedArgs: Record<string, any>
|
||||
const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema)
|
||||
if (resetKeys.length > 0) {
|
||||
sendUserToast(resetKeysToast(resetKeys))
|
||||
}
|
||||
try {
|
||||
processedArgs = await processSecretArgs(withDefaults, runnable?.schema)
|
||||
processedArgs = await processSecretArgs(withDefaults, runnable?.schema, $operatingWorkspace)
|
||||
} catch (e) {
|
||||
sendUserToast('Failed to process sensitive args: ' + e, true)
|
||||
return
|
||||
@@ -123,6 +147,11 @@
|
||||
args?: Record<string, any>
|
||||
jsonView?: boolean
|
||||
isValid?: boolean
|
||||
/** Mirror the current args into the page URL's fragment, which is what makes a
|
||||
* filled-in form shareable and what `Run again` reads back. Turn off wherever this
|
||||
* form is embedded in a page that is not the runnable's own — an AI session preview
|
||||
* tab — since there the fragment would land on an unrelated URL. */
|
||||
syncArgsToUrl?: boolean
|
||||
/** Controls beside the Run button of a form that cannot schedule, in the row a
|
||||
* schedulable one gives its Advanced options. */
|
||||
actions?: import('svelte').Snippet
|
||||
@@ -145,6 +174,7 @@
|
||||
args = $bindable(),
|
||||
jsonView = false,
|
||||
isValid = $bindable(true),
|
||||
syncArgsToUrl = true,
|
||||
actions = undefined
|
||||
}: Props = $props()
|
||||
|
||||
@@ -167,6 +197,7 @@
|
||||
let debounced: number | undefined = undefined
|
||||
|
||||
function onArgsChange(args: any) {
|
||||
if (!syncArgsToUrl) return
|
||||
try {
|
||||
debounced && clearTimeout(debounced)
|
||||
debounced = setTimeout(() => {
|
||||
@@ -245,7 +276,7 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<div class="max-w-3xl">
|
||||
<div bind:this={formEl} class="max-w-3xl">
|
||||
{#if detailed}
|
||||
{#if runnable}
|
||||
<div class="flex flex-row flex-wrap justify-between gap-4">
|
||||
@@ -303,6 +334,7 @@
|
||||
<JsonInputs
|
||||
bind:this={jsonEditor}
|
||||
on:select={(e) => {
|
||||
blockedByUnparseable = false
|
||||
if (e.detail) {
|
||||
args = enforceDisabledDefaults(e.detail, runnable?.schema).args
|
||||
}
|
||||
@@ -374,6 +406,7 @@
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
{@render unparseableError()}
|
||||
{#if overrideTag}
|
||||
<div class="flex-row-reverse flex w-full text-primary text-sm">
|
||||
tag override: {overrideTag}
|
||||
@@ -405,6 +438,7 @@
|
||||
</Button>
|
||||
<div>{@render actions()}</div>
|
||||
</div>
|
||||
{@render unparseableError()}
|
||||
{:else}
|
||||
<Button
|
||||
btnClasses="!px-6 !py-1 w-full"
|
||||
@@ -415,5 +449,14 @@
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
{@render unparseableError()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet unparseableError()}
|
||||
{#if blockedByUnparseable}
|
||||
<div class="flex-row-reverse flex w-full text-red-600 dark:text-red-400 text-xs mt-1">
|
||||
Some input is not valid JSON. Fix it before running.
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Button } from './common'
|
||||
import { WorkerService } from '$lib/gen'
|
||||
import DateTimeInput from './DateTimeInput.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
|
||||
interface Props {
|
||||
@@ -35,10 +36,21 @@
|
||||
invisible_to_owner = $bindable(),
|
||||
overrideTag = $bindable()
|
||||
}: Props = $props();
|
||||
// As in WorkerTagPicker: the shared `workerTags` cache is the navigation workspace's, so
|
||||
// tags of another operating workspace live in a local list rather than overwriting it.
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
let usesLocal = $derived($operatingWorkspace !== $workspaceStore)
|
||||
let localWorkerTags = $state<string[] | undefined>(undefined)
|
||||
let currentTags = $derived(usesLocal ? localWorkerTags : $workerTags)
|
||||
|
||||
loadWorkerGroups()
|
||||
|
||||
async function loadWorkerGroups() {
|
||||
if (!$workerTags) {
|
||||
if (usesLocal) {
|
||||
localWorkerTags ??= await WorkerService.getCustomTagsForWorkspace({
|
||||
workspace: $operatingWorkspace!
|
||||
})
|
||||
} else if (!$workerTags) {
|
||||
$workerTags = await WorkerService.getCustomTagsForWorkspace({ workspace: $workspaceStore! })
|
||||
}
|
||||
}
|
||||
@@ -76,7 +88,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if !$userStore?.operator}
|
||||
{#if $workerTags && $workerTags?.length > 0}
|
||||
{#if currentTags && currentTags.length > 0}
|
||||
<div class="w-full">
|
||||
<select
|
||||
placeholder="Worker group"
|
||||
@@ -96,7 +108,7 @@
|
||||
{:else}
|
||||
<option value="" disabled selected>Override Worker Group Tag</option>
|
||||
{/if}
|
||||
{#each $workerTags ?? [] as tag (tag)}
|
||||
{#each currentTags ?? [] as tag (tag)}
|
||||
<option value={tag}>{tag}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
@@ -917,9 +917,10 @@
|
||||
|
||||
// Inside an AI session pane (which injects an aiChatManager via context) the
|
||||
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
|
||||
// fork, Exit & See details, Export — don't make sense: the session always
|
||||
// stays put and is already scoped to a fork. Diff is exposed as a standalone
|
||||
// top-bar button (rendered independently of the session pane), not here.
|
||||
// fork, Export — don't make sense: the session always stays put and is already
|
||||
// scoped to a fork. `Exit & See details` is the exception: the session hosts the
|
||||
// details page itself, so it switches the tab instead of leaving. Diff is exposed
|
||||
// as a standalone top-bar button (rendered independently of the pane), not here.
|
||||
const inSessionPane = !!getContext('aiChatManager')
|
||||
|
||||
/** Names the version on the deployed side of the diff. Without it the reader is
|
||||
@@ -1091,10 +1092,7 @@
|
||||
: [])
|
||||
]
|
||||
: []),
|
||||
...(!inSessionPane &&
|
||||
savedScript?.no_deployed !== true &&
|
||||
script.kind === 'script' &&
|
||||
!script.auto_kind
|
||||
...(savedScript?.no_deployed !== true && script.kind === 'script' && !script.auto_kind
|
||||
? [
|
||||
{
|
||||
label: 'Exit & See details',
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
|
||||
|
||||
// A deployed app is the page, not a page inside Windmill's chrome, so it opens with the header out
|
||||
// of the way. Pinning brings the header back for good — one flag for every app rather than one per
|
||||
// app: someone who wants the breadcrumb over a dashboard wants it over the next one too.
|
||||
const pref = useLocalStorageValue<boolean>('app_header_pinned', false, 'boolean')
|
||||
|
||||
/** Whether the page header stays in flow on a deployed app's page. */
|
||||
export const appHeaderPinned = {
|
||||
get val(): boolean {
|
||||
return pref.val === true
|
||||
},
|
||||
set val(pinned: boolean) {
|
||||
pref.val = pinned
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,7 @@
|
||||
// UserDraft handle in that case, so the cleanup calls here must skip too
|
||||
// (otherwise we'd wipe a non-session tab's autosave at the same path). The
|
||||
// session-side equivalent is the View's `onDeploy` →
|
||||
// `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads
|
||||
// `runtime.itemDeployed`, which discards the fork draft + reloads
|
||||
// the preview to the deployed version.
|
||||
const inSessionPane = !!getContext('aiChatManager')
|
||||
|
||||
|
||||
@@ -10,30 +10,63 @@
|
||||
* scoped embed token / opaque isolation.
|
||||
*/
|
||||
import { base } from '$lib/base'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
|
||||
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { AppService, OpenAPI } from '$lib/gen'
|
||||
import { userStore } from '$lib/stores'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import { page } from '$app/state'
|
||||
import { isMenuHidden } from '$lib/components/sessions/sessionMode.svelte'
|
||||
import {
|
||||
setOperatingWorkspace,
|
||||
useOperatingUser
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
let {
|
||||
workspace,
|
||||
path
|
||||
path,
|
||||
onEdit,
|
||||
onLoadState,
|
||||
syncHashToUrl = true
|
||||
}: {
|
||||
workspace: string
|
||||
path: string
|
||||
/** Handle Edit in place instead of following `editHref`. An AI session shows
|
||||
* this viewer inside a preview tab, where a plain link would navigate the whole
|
||||
* page out of the session rather than flipping the tab to its editor. */
|
||||
onEdit?: () => void
|
||||
/** How the load ended, for a host that renders its own state around this viewer.
|
||||
* A 403 is deliberately neither: the app exists, this member just cannot open it. */
|
||||
onLoadState?: (state: 'loaded' | 'not_found') => void
|
||||
/** Whether a raw app's route lives in the page URL's hash; see `RawAppPreview`. */
|
||||
syncHashToUrl?: boolean
|
||||
} = $props()
|
||||
|
||||
// The app, its permission check and everything this viewer renders belong to `workspace`,
|
||||
// which on a session preview tab is the session's and not the one the nav points at.
|
||||
setOperatingWorkspace(() => workspace)
|
||||
const operatingUser = useOperatingUser()
|
||||
|
||||
/** This workspace's membership for the viewer, which is what the app's `ctx.username` /
|
||||
* `ctx.groups` must describe — they sit beside `ctx.workspace` in the same object. */
|
||||
let appUser: UserExt | undefined = $state(undefined)
|
||||
let app: any = $state(undefined)
|
||||
let notExists = $state(false)
|
||||
let noPermission = $state(false)
|
||||
let canWriteApp = $state(false)
|
||||
/** The app's own permissions, kept apart from the verdict: `loadPerms` resolves before the
|
||||
* acting user's `whoami` does in a workspace that is not the navigation one, and a verdict
|
||||
* computed there would stick at "cannot write" with nothing to recompute it. */
|
||||
let appPerms = $state<{ path: string; extraPerms: Record<string, boolean> } | undefined>(
|
||||
undefined
|
||||
)
|
||||
const canWriteApp = $derived(
|
||||
!!appPerms && canWrite(appPerms.path, appPerms.extraPerms, operatingUser.current)
|
||||
)
|
||||
/** Raw vs low-code, read from the app itself rather than from the route:
|
||||
* both kinds render here and either route serves either kind (links to a raw
|
||||
* app point at /apps/get all over the app), so only the app can say which
|
||||
@@ -82,11 +115,13 @@
|
||||
// getAppByPath returns bundle_secret + runnables for raw apps, which
|
||||
// PublicApp -> RawAppPreview needs.
|
||||
async function loadApp() {
|
||||
try {
|
||||
userStore.set(await getUserExt(workspace))
|
||||
} catch (e) {
|
||||
console.warn('Anonymous user')
|
||||
}
|
||||
// Kept local and handed to PublicApp rather than written to `userStore`: this is the
|
||||
// membership in `workspace`, and a session preview tab shows a workspace the rest of
|
||||
// the page is not on — writing it globally would answer every permission check on
|
||||
// that page (the sidebar, the session bar's fork button) for the wrong workspace.
|
||||
// The routes that mount this are all under `(logged)`, whose layout has already
|
||||
// populated `userStore` for the workspace the page *is* on.
|
||||
appUser = await getUserExt(workspace)
|
||||
try {
|
||||
const loaded: any = await AppService.getAppByPath({ workspace, path })
|
||||
// Raw apps need the bundle secret to load their bundle. getAppByPath
|
||||
@@ -106,10 +141,16 @@
|
||||
app = loaded
|
||||
noPermission = false
|
||||
notExists = false
|
||||
onLoadState?.('loaded')
|
||||
} catch (e: any) {
|
||||
if (e.status == 401) refresh?.()
|
||||
else if (e.status == 403) noPermission = true
|
||||
else notExists = true
|
||||
else {
|
||||
notExists = true
|
||||
// Only a 404 is "nothing deployed here"; anything else is a failure to say so.
|
||||
if (e.status == 404) onLoadState?.('not_found')
|
||||
else sendUserToast('Could not load app: ' + (e.body ?? e.message ?? e), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,10 +160,10 @@
|
||||
async function loadPerms() {
|
||||
try {
|
||||
const lite: any = await AppService.getAppLiteByPath({ workspace, path })
|
||||
canWriteApp = canWrite(lite?.path, lite?.extra_perms ?? {}, $userStore)
|
||||
appPerms = { path: lite?.path ?? path, extraPerms: lite?.extra_perms ?? {} }
|
||||
isRawApp = !!lite?.raw_app
|
||||
} catch (_) {
|
||||
canWriteApp = false
|
||||
appPerms = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,11 +185,13 @@
|
||||
<PublicApp
|
||||
{app}
|
||||
{workspace}
|
||||
user={appUser}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
jwtError={false}
|
||||
inWorkspace
|
||||
{hideRefreshBar}
|
||||
{syncHashToUrl}
|
||||
onLoginSuccess={() => loadApp()}
|
||||
></PublicApp>
|
||||
{/snippet}
|
||||
@@ -160,7 +203,6 @@
|
||||
<PageHeaderContent
|
||||
item={{ kind: 'app', path }}
|
||||
afterName={showEdit && !menuHidden ? editAction : undefined}
|
||||
fullBleed
|
||||
/>
|
||||
|
||||
<!-- With a band, Edit sits with the app's name rather than at the far end of the bar: on a page
|
||||
@@ -174,11 +216,14 @@
|
||||
{/if}
|
||||
|
||||
{#snippet editAction()}
|
||||
<!-- `onEdit` wins over the href: a host that embeds this viewer opens its own editor rather
|
||||
than navigating the frame to one. -->
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Pen }}
|
||||
variant="subtle"
|
||||
href={editHref}
|
||||
href={onEdit ? undefined : editHref}
|
||||
on:click={() => onEdit?.()}
|
||||
id="app-edit-btn">Edit</Button
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { applyDarkModeVariant } from '$lib/darkModeVariant'
|
||||
import { enterpriseLicense, userStore } from '$lib/stores'
|
||||
import { enterpriseLicense, userStore, type UserExt } from '$lib/stores'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/state'
|
||||
import Login from '$lib/components/Login.svelte'
|
||||
@@ -32,8 +32,10 @@
|
||||
onLoginSuccess,
|
||||
app,
|
||||
workspace,
|
||||
user = undefined,
|
||||
inWorkspace = false,
|
||||
hideRefreshBar = false
|
||||
hideRefreshBar = false,
|
||||
syncHashToUrl = true
|
||||
}: {
|
||||
notExists: boolean
|
||||
noPermission: boolean
|
||||
@@ -44,6 +46,11 @@
|
||||
onLoginSuccess: () => void
|
||||
app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined
|
||||
workspace: string | undefined
|
||||
/** The viewer's membership in `workspace`, for the app's `ctx`. Without it `ctx` falls
|
||||
* back to `userStore`, which describes the workspace the page is navigated to — the
|
||||
* same one on every standalone viewer route, but not inside an AI session's preview
|
||||
* tab, where the app belongs to the session's workspace. */
|
||||
user?: UserExt | undefined
|
||||
/**
|
||||
* In-workspace rendering (`/apps/get`, `/app_embed`): keep exact parity
|
||||
* with the pre-sandbox member viewer — no "Powered by Windmill" badge, no
|
||||
@@ -51,11 +58,17 @@
|
||||
*/
|
||||
inWorkspace?: boolean
|
||||
hideRefreshBar?: boolean
|
||||
/** Whether a raw app's route lives in the page URL's hash; see `RawAppPreview`. */
|
||||
syncHashToUrl?: boolean
|
||||
} = $props()
|
||||
|
||||
// Use workspace from props or from app.workspace_id (for custom path responses)
|
||||
let effectiveWorkspace = $derived(workspace ?? app?.workspace_id)
|
||||
|
||||
// `ctx.username` / `ctx.groups` are per-workspace, and they sit next to `ctx.workspace`
|
||||
// in the same object, so they have to come from the same workspace it names.
|
||||
let ctxUser = $derived(user ?? $userStore)
|
||||
|
||||
// On the public surfaces (untrusted distribution) runnable-authored html/svg needs
|
||||
// the viewer's approval before it renders, unless the app sandbox isolates it. The
|
||||
// in-workspace viewer renders it verbatim. See getAppMarkupTrust.
|
||||
@@ -156,10 +169,11 @@
|
||||
<div class={inWorkspace ? 'h-full w-full' : 'h-screen w-full'}>
|
||||
<RawAppPreview
|
||||
workspace={effectiveWorkspace}
|
||||
user={$userStore}
|
||||
user={ctxUser}
|
||||
secret={app.bundle_secret}
|
||||
path={app.path}
|
||||
runnables={(app.value?.runnables ?? {}) as Record<string, Runnable>}
|
||||
{syncHashToUrl}
|
||||
/>
|
||||
</div>
|
||||
{:else if app.raw_app && !effectiveWorkspace}
|
||||
@@ -188,10 +202,10 @@
|
||||
noBackend={false}
|
||||
{hideRefreshBar}
|
||||
context={{
|
||||
email: $userStore?.email,
|
||||
name: $userStore?.name,
|
||||
groups: $userStore?.groups,
|
||||
username: $userStore?.username,
|
||||
email: ctxUser?.email,
|
||||
name: ctxUser?.name,
|
||||
groups: ctxUser?.groups,
|
||||
username: ctxUser?.username,
|
||||
query: urlParamsToObject(page.url.searchParams, { stripReserved: true }),
|
||||
hash: page.url.hash.substring(1)
|
||||
}}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { aiChatManager } from './chat/AIChatManager.svelte'
|
||||
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
import { AIBtnClasses } from './chat/AIButtonStyle'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
const { onEditInstructions, instructions, runnableType, path }: Props = $props()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
// Anonymous counter for this card being acted on, keyed by what it sits above. The two
|
||||
// branches share one counter: they are the same intent, and which of them is on screen
|
||||
// follows the user's session gate rather than a choice made here. `beforeOpen` is the
|
||||
@@ -39,7 +41,7 @@
|
||||
path
|
||||
? {
|
||||
target: { kind: runnableType, path } as const,
|
||||
workspaceId: $workspaceStore ?? undefined,
|
||||
workspaceId: $operatingWorkspace ?? undefined,
|
||||
beforeOpen: logAsked,
|
||||
seedPrompt:
|
||||
`Run the deployed ${runnableType} \`${path}\` for me. Pick sensible inputs, ` +
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { PanelRight } from 'lucide-svelte'
|
||||
import { Eye, Pen } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import type { IconType } from '$lib/utils'
|
||||
@@ -15,9 +15,13 @@
|
||||
/** The kind icon says what the chip opens. A card that already names its own runnable
|
||||
* in the row above has said it, and repeating it there reads as a second subject. */
|
||||
kindIcon?: boolean
|
||||
/** Which side of the item the chip opens, shown as the same pen/eye the preview
|
||||
* tabs use. Defaults to the editor: a card sits on a write-tool result, and the
|
||||
* draft it just wrote is what the reader is being offered. */
|
||||
mode?: 'edit' | 'view'
|
||||
}
|
||||
|
||||
let { card, onOpen, title, kindIcon = true }: Props = $props()
|
||||
let { card, onOpen, title, kindIcon = true, mode = 'edit' }: Props = $props()
|
||||
|
||||
const kindLabel = $derived(card.kind === 'raw_app' ? 'app' : card.kind)
|
||||
|
||||
@@ -30,7 +34,7 @@
|
||||
}
|
||||
opening = true
|
||||
try {
|
||||
await runToolDisplayAction(openItemPreviewAction(card.kind, card.path))
|
||||
await runToolDisplayAction(openItemPreviewAction(card.kind, card.path, mode))
|
||||
} finally {
|
||||
opening = false
|
||||
}
|
||||
@@ -41,12 +45,15 @@
|
||||
variant="default"
|
||||
unifiedSize="2xs"
|
||||
disabled={opening}
|
||||
title={title ?? `Open ${kindLabel} preview: ${card.path}`}
|
||||
title={title ??
|
||||
(mode === 'view'
|
||||
? `Open the deployed ${kindLabel}: ${card.path}`
|
||||
: `Open ${kindLabel} preview: ${card.path}`)}
|
||||
onClick={open}
|
||||
startIcon={kindIcon
|
||||
? { icon: RowIcon as unknown as IconType, props: { kind: card.kind, size: 12 } }
|
||||
: undefined}
|
||||
endIcon={{ icon: PanelRight }}
|
||||
endIcon={{ icon: mode === 'view' ? Eye : Pen }}
|
||||
wrapperClasses="shrink-0"
|
||||
>
|
||||
<!-- The chip renders inside the tool row's font-mono scope; the label is UI text.
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
} from '$lib/gen'
|
||||
import { createTwoFilesPatch } from 'diff'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
import { promptSafe, type ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { $ScriptLang } from '$lib/gen/schemas.gen'
|
||||
import type {
|
||||
AppWithLastVersion,
|
||||
@@ -1160,9 +1161,34 @@ const openPreviewSchema = z.object({
|
||||
),
|
||||
path: z
|
||||
.string()
|
||||
.describe('Workspace path of the item to preview, or the folder name when kind is "pipeline".')
|
||||
.describe('Workspace path of the item to preview, or the folder name when kind is "pipeline".'),
|
||||
mode: z
|
||||
.enum(['edit', 'view'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Which side to show. Defaults to "edit", the editor for the draft you just wrote — which is what this tool is usually for. Pass "view" for the deployed page instead: its run form, triggers and past runs, for showing the user an item that already exists rather than your own changes to it. Ignored for kind="pipeline", which has only an editor.'
|
||||
)
|
||||
})
|
||||
|
||||
/** Advertised `mode` for a session that cannot write drafts: the editor is not a side
|
||||
* it can open, so it is not offered. Per-PATH write permission is unknowable here —
|
||||
* a workspace capability says nothing about `f/finance/*` — and is resolved in the
|
||||
* handler instead. Mirrors `open_page` re-narrowing its page enum per user. */
|
||||
const openPreviewViewOnlySchema = openPreviewSchema.extend({
|
||||
mode: z
|
||||
.literal('view')
|
||||
.optional()
|
||||
.describe(
|
||||
'Only "view" — the deployed page with its run form and triggers. You cannot open editors in this workspace.'
|
||||
)
|
||||
})
|
||||
|
||||
const OPEN_PREVIEW_DESCRIPTION =
|
||||
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
|
||||
|
||||
const OPEN_PREVIEW_VIEW_ONLY_DESCRIPTION =
|
||||
'Show a workspace item in the side panel next to the chat, as its deployed page — the run form, triggers and past runs the user can act on. ONLY works inside an AI session. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). You cannot open item editors in this workspace, so use this to show the user something that is already deployed. Returns an error if there is no active session.'
|
||||
|
||||
const getPreviewStatusSchema = z.object({})
|
||||
|
||||
const closePageSchema = z.object({
|
||||
@@ -1470,6 +1496,10 @@ ${pipelineBullet}`
|
||||
- Keep context targeted.${
|
||||
previewTools
|
||||
? `${when(
|
||||
!canWriteDraft,
|
||||
`
|
||||
- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. You cannot open item editors in this workspace, so it only ever opens the deployed page.`
|
||||
)}${when(
|
||||
canWriteDraft,
|
||||
`
|
||||
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.
|
||||
@@ -4413,14 +4443,27 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
...artifactTools,
|
||||
{
|
||||
requires: NONE,
|
||||
def: createToolDef(
|
||||
openPreviewSchema,
|
||||
'open_preview',
|
||||
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
|
||||
),
|
||||
def: createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION),
|
||||
// Withhold the editor side from a session that cannot write drafts, so the model
|
||||
// never offers the user a panel it would not get.
|
||||
schemaFor: async (helpers) => {
|
||||
const access = (helpers as GlobalToolHelpers | undefined)?.access
|
||||
return access && !access.has('write_draft')
|
||||
? createToolDef(
|
||||
openPreviewViewOnlySchema,
|
||||
'open_preview',
|
||||
OPEN_PREVIEW_VIEW_ONLY_DESCRIPTION
|
||||
)
|
||||
: createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION)
|
||||
},
|
||||
fn: async (ctx) => {
|
||||
const parsed = openPreviewSchema.parse(ctx.args)
|
||||
return openSessionPreview(parsed, sessionIdFromCtx(ctx))
|
||||
return openSessionPreview(
|
||||
parsed,
|
||||
sessionIdFromCtx(ctx),
|
||||
operatingWorkspaceFromHelpers(ctx.helpers),
|
||||
(ctx.helpers as GlobalToolHelpers | undefined)?.access
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4726,6 +4769,11 @@ export type OpenPreviewHandler = (req: {
|
||||
sessionId: string | undefined
|
||||
kind: 'script' | 'flow' | 'raw_app' | 'pipeline'
|
||||
path: string
|
||||
/** Which side to open. Omitted means the editor, except for a pipeline, which has only
|
||||
* one side. `openSessionPreview` resolves it before its capability gates rather than
|
||||
* leaving the default to this handler, so an omitted mode is gated like an explicit
|
||||
* 'edit'. */
|
||||
mode?: 'edit' | 'view'
|
||||
}) => string | Promise<string>
|
||||
|
||||
let openPreviewHandler: OpenPreviewHandler | undefined
|
||||
@@ -4735,16 +4783,73 @@ export function setOpenPreviewHandler(handler: OpenPreviewHandler | undefined):
|
||||
}
|
||||
|
||||
async function openSessionPreview(
|
||||
args: { kind: 'script' | 'flow' | 'raw_app' | 'pipeline'; path: string },
|
||||
sessionId: string | undefined
|
||||
args: { kind: 'script' | 'flow' | 'raw_app' | 'pipeline'; path: string; mode?: 'edit' | 'view' },
|
||||
sessionId: string | undefined,
|
||||
workspace: string | undefined,
|
||||
access: SessionAccess | undefined
|
||||
): Promise<string> {
|
||||
if (!openPreviewHandler) {
|
||||
return 'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.'
|
||||
}
|
||||
// open_preview only exists in sessions, so no sessionId check is needed here.
|
||||
// A pipeline has only an editor, and its own capability gates already cover it.
|
||||
if (args.kind === 'pipeline') {
|
||||
return await openPreviewHandler({ ...args, mode: undefined, sessionId })
|
||||
}
|
||||
// Resolved before the gates, not after: the handler reads a missing mode as the editor, so
|
||||
// gating on `args.mode === 'edit'` would wave through every call that simply omitted it —
|
||||
// which is what a model whose schema only offers 'view' does.
|
||||
const mode: 'edit' | 'view' = args.mode ?? 'edit'
|
||||
// The advertised schema already withholds 'edit' from a session that cannot write
|
||||
// drafts; re-check so a model asking outside the enum can't act outside it either.
|
||||
if (mode === 'edit' && access && !access.has('write_draft')) {
|
||||
const opened = await openPreviewHandler({ ...args, mode: 'view', sessionId })
|
||||
return `${opened}\nOpened the deployed page instead: you cannot open item editors in this workspace.`
|
||||
}
|
||||
// `write_draft` is a workspace capability and says nothing about this path, so an
|
||||
// edit that survived it is still checked against the item's own permissions.
|
||||
if (mode === 'edit' && workspace) {
|
||||
const verdict = await canEditItemPath(workspace, args.kind, args.path)
|
||||
if (verdict !== 'allowed') {
|
||||
const opened = await openPreviewHandler({ ...args, mode: 'view', sessionId })
|
||||
// "Couldn't check" is not "denied" — reporting the lookup failure as a denial
|
||||
// states something false about the user's permissions.
|
||||
return verdict === 'denied'
|
||||
? `${opened}\nOpened the deployed page instead: the user does not have edit rights on ${promptSafe(args.path)}.`
|
||||
: `${opened}\nOpened the deployed page: their edit rights on ${promptSafe(args.path)} couldn't be checked just now.`
|
||||
}
|
||||
}
|
||||
// For a pipeline the handler awaits the editor's tool registration, so the
|
||||
// model's next build_pipeline_node call can't race the async canvas mount.
|
||||
return await openPreviewHandler({ ...args, sessionId })
|
||||
return await openPreviewHandler({ ...args, mode, sessionId })
|
||||
}
|
||||
|
||||
/** Whether the user may edit `path` in `workspace`. `'unverified'` when their role
|
||||
* there could not be read — distinct from a denial, which `canWrite` cannot express
|
||||
* (it answers false for an unknown user). */
|
||||
async function canEditItemPath(
|
||||
workspace: string,
|
||||
kind: 'script' | 'flow' | 'raw_app',
|
||||
path: string
|
||||
): Promise<'allowed' | 'denied' | 'unverified'> {
|
||||
const role = await roleForWorkspace(workspace)
|
||||
if (role.kind === 'not_a_member') return 'denied'
|
||||
if (role.kind !== 'resolved' || !role.user) return 'unverified'
|
||||
if (role.user.operator) return 'denied'
|
||||
// Folder and ownership rules answer most calls without a request. An item's own
|
||||
// `extra_perms` can only widen them, so the item is fetched only to overturn a denial.
|
||||
if (canWrite(path, {}, role.user)) return 'allowed'
|
||||
try {
|
||||
const extraPerms =
|
||||
kind === 'script'
|
||||
? (await ScriptService.getScriptByPath({ workspace, path })).extra_perms
|
||||
: kind === 'flow'
|
||||
? (await FlowService.getFlowByPath({ workspace, path })).extra_perms
|
||||
: (await AppService.getAppByPath({ workspace, path })).extra_perms
|
||||
return canWrite(path, extraPerms ?? {}, role.user) ? 'allowed' : 'denied'
|
||||
} catch (e) {
|
||||
// Nothing deployed means no sharing to widen the folder rules with.
|
||||
return (e as { status?: number } | null | undefined)?.status === 404 ? 'denied' : 'unverified'
|
||||
}
|
||||
}
|
||||
|
||||
// Opens a workspace *page* (Runs, Schedules, …) as a page tab in the session's
|
||||
|
||||
@@ -16,15 +16,31 @@ export type OpenItemPreviewAction = {
|
||||
label: string
|
||||
previewKind: PreviewCardKind
|
||||
path: string
|
||||
/** Which side of the item to show. A card on a write-tool result means the
|
||||
* draft the tool just wrote, so it asks for the editor; a path the model
|
||||
* mentioned in prose means the item as it stands, so it asks for the
|
||||
* deployed page. Declared per call site because only the call site knows
|
||||
* which of the two it is. */
|
||||
mode: 'edit' | 'view'
|
||||
}
|
||||
|
||||
/** Build the action a preview card or path link dispatches from its (kind, path). */
|
||||
export function openItemPreviewAction(kind: PreviewCardKind, path: string): OpenItemPreviewAction {
|
||||
export function openItemPreviewAction(
|
||||
kind: PreviewCardKind,
|
||||
path: string,
|
||||
mode: 'edit' | 'view' = 'edit'
|
||||
): OpenItemPreviewAction {
|
||||
return {
|
||||
id: `open-item-preview:${kind}:${path}`,
|
||||
// The side is part of the identity: the same path can be offered both ways
|
||||
// in one transcript, and a shared id would collapse them into one action.
|
||||
id: `open-item-preview:${kind}:${mode}:${path}`,
|
||||
type: 'open_item_preview',
|
||||
label: `Open ${kind === 'raw_app' ? 'app' : kind} preview`,
|
||||
label:
|
||||
mode === 'view'
|
||||
? `Open ${kind === 'raw_app' ? 'app' : kind}`
|
||||
: `Open ${kind === 'raw_app' ? 'app' : kind} preview`,
|
||||
previewKind: kind,
|
||||
path
|
||||
path,
|
||||
mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1851,14 +1851,25 @@ describe('openItemPreviewAction', () => {
|
||||
// to previewTargetForSessionTarget.
|
||||
it('carries the kind and path through to the dispatch action', () => {
|
||||
expect(openItemPreviewAction('flow', 'f/team/etl')).toEqual({
|
||||
id: 'open-item-preview:flow:f/team/etl',
|
||||
id: 'open-item-preview:flow:edit:f/team/etl',
|
||||
type: 'open_item_preview',
|
||||
label: 'Open flow preview',
|
||||
previewKind: 'flow',
|
||||
path: 'f/team/etl'
|
||||
path: 'f/team/etl',
|
||||
mode: 'edit'
|
||||
})
|
||||
})
|
||||
|
||||
// The side is part of the id: a transcript can offer the same path both ways —
|
||||
// a write card opening its draft's editor, a prose mention opening the deployed
|
||||
// page — and one id would collapse them into a single action.
|
||||
it('distinguishes the two sides of the same item', () => {
|
||||
const edit = openItemPreviewAction('flow', 'f/team/etl')
|
||||
const view = openItemPreviewAction('flow', 'f/team/etl', 'view')
|
||||
expect(view.mode).toBe('view')
|
||||
expect(view.id).not.toBe(edit.id)
|
||||
})
|
||||
|
||||
// raw_app is the internal kind; the user-facing label says "app".
|
||||
it('labels raw_app as "app"', () => {
|
||||
expect(openItemPreviewAction('raw_app', 'u/me/dash').label).toBe('Open app preview')
|
||||
|
||||
@@ -271,14 +271,18 @@ export function workspaceItemAction(
|
||||
): ToolDisplayAction | undefined {
|
||||
if (!kind || !path) return undefined
|
||||
|
||||
// 'view' throughout: this registry is loaded without `include_draft_only`, so a
|
||||
// path only resolves here once something is deployed at it. A mention in prose
|
||||
// names the item as it stands, which is that deployed version — the model shows
|
||||
// its own fresh drafts through `open_preview` and the write-result card instead.
|
||||
if (kind === 'script' || kind === 'flow') {
|
||||
return openItemPreviewAction(kind, path)
|
||||
return openItemPreviewAction(kind, path, 'view')
|
||||
}
|
||||
|
||||
// Raw apps only. A legacy drag-and-drop app has no editor the panel can host, and
|
||||
// legacy items are not extended onto new surfaces — its link stays outbound.
|
||||
if (kind === 'app') {
|
||||
return rawApp ? openItemPreviewAction('raw_app', path) : undefined
|
||||
return rawApp ? openItemPreviewAction('raw_app', path, 'view') : undefined
|
||||
}
|
||||
|
||||
const base = {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { copyToClipboard } from '$lib/utils'
|
||||
import { isWindmillTooBigObject } from '$lib/components/job_args'
|
||||
import { previewDbtRows } from './previewRows'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { appendViewToken } from '$lib/viewToken'
|
||||
import AssetGraphCanvas from '$lib/components/assets/AssetGraph/AssetGraphCanvas.svelte'
|
||||
import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types'
|
||||
@@ -66,6 +66,8 @@
|
||||
onResume?: () => Promise<unknown> | void
|
||||
} = $props()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
// The graph endpoint is folder-scoped; a script outside `f/` has no folder and
|
||||
// falls back to the whole workspace, which the filter below narrows anyway.
|
||||
let folder = $derived(scriptPath.startsWith('f/') ? scriptPath.split('/')[1] : undefined)
|
||||
@@ -82,7 +84,7 @@
|
||||
// own snapshot with nothing left to fetch again and correct it.
|
||||
let graphSeq = 0
|
||||
async function load() {
|
||||
const ws = $workspaceStore
|
||||
const ws = $operatingWorkspace
|
||||
if (!ws) return
|
||||
const gen = runGen
|
||||
const seq = ++graphSeq
|
||||
@@ -128,7 +130,7 @@
|
||||
// one answer. `generation` is this page's own: a response that outlives a
|
||||
// navigation between runs would colour the next one's models.
|
||||
const runStatus = useDbtRunStatus({
|
||||
workspace: () => $workspaceStore,
|
||||
workspace: () => $operatingWorkspace,
|
||||
jobId: () => jobId,
|
||||
running: () => running,
|
||||
result: () => result,
|
||||
@@ -495,7 +497,7 @@
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
const ws = $workspaceStore
|
||||
const ws = $operatingWorkspace
|
||||
const dbt = selectedDbt
|
||||
if (!ws || !dbt) return
|
||||
// Keyed on the ARGUMENTS too, not the model alone: on the script page the
|
||||
@@ -731,18 +733,16 @@
|
||||
exactly like a project with no warehouse identity. -->
|
||||
No models stored for this version of the project.
|
||||
{#if scriptHash}
|
||||
A version's graph is written by its deploy, so this is what a deploy still in
|
||||
flight looks like — it fills in when that job lands.
|
||||
A version's graph is written by its deploy, so this is what a deploy still in flight looks
|
||||
like — it fills in when that job lands.
|
||||
{/if}
|
||||
A project that brings its own <span class="font-mono">profiles.yml</span> without naming
|
||||
a <span class="font-mono">profile.warehouse</span> also has no warehouse identity to key
|
||||
models on, and stores none.
|
||||
A project that brings its own <span class="font-mono">profiles.yml</span> without naming a
|
||||
<span class="font-mono">profile.warehouse</span> also has no warehouse identity to key models on,
|
||||
and stores none.
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="overflow-hidden flex flex-col {fill ? 'h-full' : 'border rounded'}"
|
||||
>
|
||||
<div class="overflow-hidden flex flex-col {fill ? 'h-full' : 'border rounded'}">
|
||||
{#if relationDrift > 0}
|
||||
<div class="shrink-0 px-2 py-1 text-2xs text-secondary border-b bg-surface-secondary">
|
||||
{relationDrift}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
import type { Item } from '$lib/utils'
|
||||
import { Bell, BellOff, Calendar } from 'lucide-svelte'
|
||||
import { toggleWorkspaceErrorHandler } from './errorHandlerToggle'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
type MainButton = {
|
||||
label: string
|
||||
@@ -54,6 +57,14 @@
|
||||
onSaved?: (newPath: string) => void
|
||||
children?: import('svelte').Snippet
|
||||
trigger_badges?: import('svelte').Snippet
|
||||
/** True for the route's own page: its header becomes the page header. A host that renders
|
||||
* this inside something else — a session's preview panel — leaves it false and gets the
|
||||
* row below. */
|
||||
ownsPageHeader?: boolean
|
||||
/** Whether the bar is wide enough to hold every button beside the summary, decided by
|
||||
* the layout from its own width. Not a viewport media query: the same page also renders
|
||||
* inside an AI session's preview panel, where the window is wide and the pane is not. */
|
||||
wide?: boolean
|
||||
/** Controls ahead of the menu, such as the way an agent's page runs it. */
|
||||
leading_actions?: import('svelte').Snippet
|
||||
}
|
||||
@@ -72,20 +83,24 @@
|
||||
onSaved,
|
||||
children,
|
||||
trigger_badges,
|
||||
ownsPageHeader = false,
|
||||
wide = true,
|
||||
leading_actions
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// These buttons share the page header's row with the breadcrumb, so what decides whether they
|
||||
// all fit is that row's width, not the window's — the sidebar and a page's side panel both take
|
||||
// from it. The trail and the summary want ~560px and the full set is ~530px wide. Unmeasured (0)
|
||||
// counts as wide: the bar measures itself on mount, and starting narrow would pop the buttons
|
||||
// out of the menu a frame later.
|
||||
// Two reasons the row can be too tight, and either one collapses it. The host knows when its
|
||||
// own box is narrow while the window is not — a session's preview panel — and says so through
|
||||
// `wide`. The band knows its own width, which the host cannot see: these buttons share that row
|
||||
// with the breadcrumb, and the trail and summary want ~560px against the set's ~530. Unmeasured
|
||||
// (0) counts as wide, because the bar measures itself on mount and starting narrow would pop
|
||||
// the buttons out of the menu a frame later.
|
||||
const COLLAPSE_BELOW = 1150
|
||||
const wide = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW)
|
||||
const roomInBar = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW)
|
||||
const wideRow = $derived(wide && roomInBar)
|
||||
|
||||
const barButtons = $derived(wide ? mainButtons : mainButtons.filter((b) => !b.narrow))
|
||||
const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow))
|
||||
|
||||
function dropdownHost(btn: MainButton): MainButton | undefined {
|
||||
if (typeof btn.narrow !== 'object') return undefined
|
||||
@@ -96,6 +111,7 @@
|
||||
async function toggleErrorHandler() {
|
||||
if (!errorHandlerKind || !scriptOrFlowPath) return
|
||||
const next = await toggleWorkspaceErrorHandler(
|
||||
$operatingWorkspace,
|
||||
errorHandlerKind,
|
||||
scriptOrFlowPath,
|
||||
errorHandlerMuted
|
||||
@@ -104,7 +120,7 @@
|
||||
}
|
||||
|
||||
const allMenuItems: Item[] = $derived([
|
||||
...(wide ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
|
||||
...(wideRow ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
|
||||
displayName: b.label,
|
||||
description: b.description,
|
||||
icon: b.buttonProps.startIcon,
|
||||
@@ -113,7 +129,7 @@
|
||||
disabled: b.buttonProps.disabled,
|
||||
type: 'action' as const
|
||||
})),
|
||||
...(wide || !errorHandlerKind
|
||||
...(wideRow || !errorHandlerKind
|
||||
? []
|
||||
: [
|
||||
{
|
||||
@@ -129,12 +145,12 @@
|
||||
action: item.onclick,
|
||||
disabled: item.disabled,
|
||||
type: item.color === 'red' ? ('delete' as const) : ('action' as const),
|
||||
separatorTop: i === 0 && !wide
|
||||
separatorTop: i === 0 && !wideRow
|
||||
}))
|
||||
])
|
||||
|
||||
function dropdownItemsOf(host: MainButton) {
|
||||
if (wide) return undefined
|
||||
if (wideRow) return undefined
|
||||
const items = mainButtons
|
||||
.filter((b) => dropdownHost(b) === host)
|
||||
.map((b) => ({
|
||||
@@ -189,7 +205,7 @@
|
||||
<DropdownV2 items={allMenuItems} placement="bottom-end" size="sm" />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if wide && errorHandlerKind && scriptOrFlowPath}
|
||||
{#if wideRow && errorHandlerKind && scriptOrFlowPath}
|
||||
<ErrorHandlerToggleButton
|
||||
kind={errorHandlerKind}
|
||||
{scriptOrFlowPath}
|
||||
@@ -212,10 +228,87 @@
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<!-- The summary keeps its rename-and-labels popover here rather than becoming plain text in
|
||||
the breadcrumb: renaming a script or flow is done from this page, not from the trail. -->
|
||||
<PageHeaderContent
|
||||
item={{ kind: errorHandlerKind, path, summaryContent }}
|
||||
{actions}
|
||||
contexts={headerContexts}
|
||||
/>
|
||||
{#if ownsPageHeader}
|
||||
<!-- The summary keeps its rename-and-labels popover here rather than becoming plain text in
|
||||
the breadcrumb: renaming a script or flow is done from this page, not from the trail. -->
|
||||
<PageHeaderContent
|
||||
item={{ kind: errorHandlerKind, path, summaryContent }}
|
||||
{actions}
|
||||
contexts={headerContexts}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Nested in a session's preview panel, which has a band of its own above it naming the
|
||||
session. A page in there draws its own row instead: registering would rename the band to
|
||||
whatever the panel happens to be showing. -->
|
||||
<div class="border-b">
|
||||
<div class="mx-auto">
|
||||
<div
|
||||
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12 py-2 md:py-0"
|
||||
>
|
||||
<div class="grow px-2 inline-flex items-center gap-4 min-w-0">
|
||||
<div class="min-w-0">
|
||||
<SummaryPathDisplay
|
||||
{summary}
|
||||
{path}
|
||||
bind:labels
|
||||
{inheritedLabels}
|
||||
{onSaved}
|
||||
kind={errorHandlerKind}
|
||||
/>
|
||||
</div>
|
||||
{#if tag}
|
||||
<Badge>tag: {tag}</Badge>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
{#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)}
|
||||
{@const primarySchedule = triggersState.triggers.findIndex(
|
||||
(t) => t.isPrimary && !t.isDraft
|
||||
)}
|
||||
<Button
|
||||
btnClasses="inline-flex"
|
||||
startIcon={{ icon: Calendar }}
|
||||
variant="contained"
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
dispatch('seeTriggers')
|
||||
await tick()
|
||||
triggersState.selectedTriggerIndex = primarySchedule
|
||||
}}
|
||||
>
|
||||
{$triggersCount?.primary_schedule?.schedule ?? ''}
|
||||
</Button>
|
||||
{/if}
|
||||
{@render trigger_badges?.()}
|
||||
</div>
|
||||
<div class="flex gap-1 items-center pr-4">
|
||||
{@render leading_actions?.()}
|
||||
{#if allMenuItems.length > 0}
|
||||
{#key allMenuItems}
|
||||
<DropdownV2 items={allMenuItems} placement="bottom-end" size="md" />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if wideRow && errorHandlerKind && scriptOrFlowPath}
|
||||
<ErrorHandlerToggleButton
|
||||
kind={errorHandlerKind}
|
||||
{scriptOrFlowPath}
|
||||
bind:errorHandlerMuted
|
||||
/>
|
||||
{/if}
|
||||
{#each barButtons as btn (btn.label)}
|
||||
{@const dropdownItems = dropdownItemsOf(btn)}
|
||||
<Button
|
||||
{...btn.buttonProps}
|
||||
startIcon={{ icon: btn.buttonProps.startIcon }}
|
||||
{dropdownItems}
|
||||
dropdownWidth={dropdownItems?.some((i) => i.description) ? 288 : undefined}
|
||||
btnClasses="flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
selected: string
|
||||
forceSmallScreen?: boolean
|
||||
isChatMode?: boolean
|
||||
header?: import('svelte').Snippet
|
||||
header?: import('svelte').Snippet<[{ wide: boolean }]>
|
||||
/** `graphInline`: whether the form should carry the flow graph under it. It does in the
|
||||
* split layout; the tabbed layout gives the graph a tab of its own. */
|
||||
form?: import('svelte').Snippet<[{ graphInline: boolean }]>
|
||||
@@ -67,10 +67,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- `h-full`, not `h-screen`: this page is also rendered inside an AI session's preview
|
||||
panel, which is shorter than the viewport. Claiming the viewport's height there
|
||||
overflows the pane, and the ancestors between are `overflow-visible`, so the bottom of
|
||||
the run form is unreachable rather than merely clipped. -->
|
||||
<main class="h-full w-full" bind:clientWidth>
|
||||
{#if useDesktopLayout}
|
||||
<div class="h-full w-full flex flex-col">
|
||||
{@render header?.()}
|
||||
{@render header?.({ wide: useDesktopLayout })}
|
||||
<div class="grow min-h-0 w-full">
|
||||
<Splitpanes>
|
||||
<Pane size={65} minSize={50}>
|
||||
@@ -101,7 +105,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full w-full flex flex-col">
|
||||
{@render header?.()}
|
||||
{@render header?.({ wide: useDesktopLayout })}
|
||||
<div class="grow min-h-0 w-full flex flex-col">
|
||||
<!-- no-scrollbar: at phone widths the tabs overflow their strip, and a browser with
|
||||
classic scrollbars would spend a track under them, opening a band between the tabs
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { Button } from '$lib/components/common'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
import { toggleWorkspaceErrorHandler } from './errorHandlerToggle'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
interface Props {
|
||||
kind: 'script' | 'flow'
|
||||
@@ -22,7 +25,12 @@
|
||||
}: Props = $props()
|
||||
|
||||
async function toggleErrorHandler(): Promise<void> {
|
||||
const next = await toggleWorkspaceErrorHandler(kind, scriptOrFlowPath, errorHandlerMuted)
|
||||
const next = await toggleWorkspaceErrorHandler(
|
||||
$operatingWorkspace,
|
||||
kind,
|
||||
scriptOrFlowPath,
|
||||
errorHandlerMuted
|
||||
)
|
||||
if (next !== undefined) errorHandlerMuted = next
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,997 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FlowService,
|
||||
JobService,
|
||||
WorkspaceService,
|
||||
type Flow,
|
||||
type FlowModule,
|
||||
type TriggersCount,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
defaultIfEmptyString,
|
||||
emptyString,
|
||||
urlParamsToObject,
|
||||
extractTagFromSharableHash,
|
||||
interpolateTag,
|
||||
isDynamicTag,
|
||||
isTagTemplate
|
||||
} from '$lib/utils'
|
||||
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
|
||||
|
||||
import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte'
|
||||
import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { Badge as HeaderBadge, Alert } from '$lib/components/common'
|
||||
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import RunForm from '$lib/components/RunForm.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte'
|
||||
import AIFormAssistant from '$lib/components/copilot/AIFormAssistant.svelte'
|
||||
import {
|
||||
FolderOpen,
|
||||
Archive,
|
||||
Trash,
|
||||
ChevronUpSquare,
|
||||
Shield,
|
||||
Loader2,
|
||||
GitFork,
|
||||
Play,
|
||||
History,
|
||||
Pen,
|
||||
Eye,
|
||||
HistoryIcon,
|
||||
LayoutDashboard
|
||||
} from 'lucide-svelte'
|
||||
|
||||
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
|
||||
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
|
||||
import { createRawAppFromFlow } from '$lib/components/details/createRawAppFromScript'
|
||||
import TimeAgo from '$lib/components/TimeAgo.svelte'
|
||||
import FlowGraphViewerStep from '$lib/components/FlowGraphViewerStep.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
|
||||
import Star from '$lib/components/Star.svelte'
|
||||
|
||||
import { writable } from 'svelte/store'
|
||||
import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import LogViewer from '$lib/components/LogViewer.svelte'
|
||||
import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte'
|
||||
import type { TriggerContext } from '$lib/components/triggers'
|
||||
import { setContext } from 'svelte'
|
||||
import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte'
|
||||
import { Triggers } from '$lib/components/triggers/triggers.svelte'
|
||||
import FlowAssetsHandler, {
|
||||
initFlowGraphAssetsCtx
|
||||
} from '$lib/components/flows/FlowAssetsHandler.svelte'
|
||||
import { interceptNav } from '$lib/components/details/interceptNav'
|
||||
import {
|
||||
setOperatingWorkspace,
|
||||
useOperatingUser,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import CiTestResults from '$lib/components/CiTestResults.svelte'
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import {
|
||||
buildForkEditUrl,
|
||||
editInForkAllowed,
|
||||
editInForkDescription,
|
||||
editInForkLabel,
|
||||
onEditInForkClick,
|
||||
openEditInFork
|
||||
} from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
let {
|
||||
path: routePath,
|
||||
workspace,
|
||||
searchParams,
|
||||
locationHash = '',
|
||||
onNavigate = goto,
|
||||
active = true,
|
||||
embedded = false,
|
||||
onLoadState
|
||||
}: {
|
||||
/** The `[...path]` route segment: the flow's path. */
|
||||
path: string
|
||||
workspace: string | undefined
|
||||
/** Query of the URL this detail was opened at (`version`). */
|
||||
searchParams?: URLSearchParams
|
||||
/** `#`-fragment of that URL, carrying prefilled run args from `Run again`. */
|
||||
locationHash?: string
|
||||
onNavigate?: (url: string) => void | Promise<void>
|
||||
/** Gates the window-level Ctrl+Enter handler, so a hidden instance cannot steal it. */
|
||||
active?: boolean
|
||||
/** Rendered inside a page that is not the flow's own (an AI session preview tab), whose
|
||||
* URL this must leave alone and which a cross-workspace link must not navigate away. */
|
||||
embedded?: boolean
|
||||
/** How the load ended, for a host that renders its own state around this page. */
|
||||
onLoadState?: (state: 'loaded' | 'not_found') => void
|
||||
} = $props()
|
||||
|
||||
// Every workspace-scoped call below and in the drawers this page opens targets
|
||||
// `workspace`, not the nav store: a session views a flow in its own workspace.
|
||||
setOperatingWorkspace(() => workspace)
|
||||
|
||||
// Roles are per-workspace, so every gate below asks about `workspace` rather than about
|
||||
// the one the browser is navigated to. Unresolved reads as unknown, which `canWrite`
|
||||
// refuses — the safe answer while a fork's `whoami` is still in flight.
|
||||
const operatingUser = useOperatingUser()
|
||||
// A ⌘-click opens these in a new tab, which only the query names the workspace for.
|
||||
const operatingHref = useOperatingWorkspaceHref()
|
||||
const actingUser = $derived(operatingUser.current)
|
||||
const operatorBuilderFlows = useOperatorBuilderFlows()
|
||||
|
||||
let flow: Flow | undefined = $state()
|
||||
// Derived, not assigned during the load: in a fork the acting user is looked up
|
||||
// asynchronously, and a value read after `getFlowByPath` resolves can land before that
|
||||
// lookup does. Assigned once, that leaves the page permanently read-only — `canWrite`
|
||||
// refuses an unknown user — with nothing to recompute it.
|
||||
const can_write = $derived(!!flow && canWrite(flow.path, flow.extra_perms!, actingUser))
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
|
||||
let scheduledForStr: string | undefined = $state(undefined)
|
||||
let invisible_to_owner: boolean | undefined = $state(undefined)
|
||||
let overrideTag: string | undefined = $state(undefined)
|
||||
let overrideTagNote: string | undefined = $state(undefined)
|
||||
// Tag carried over from 'Run again', pending the dynamic-tag check in loadFlow
|
||||
let carriedTag: string | undefined = undefined
|
||||
let inputSelected: 'saved' | 'history' | undefined = $state(undefined)
|
||||
let jsonView = $state(false)
|
||||
let deploymentInProgress = $state(false)
|
||||
let deploymentJobId: string | undefined = $state(undefined)
|
||||
|
||||
let intervalId: number | undefined = undefined
|
||||
|
||||
const triggersCount = writable<TriggersCount | undefined>(undefined)
|
||||
|
||||
// Add triggers context store
|
||||
const triggersState = $state(
|
||||
new Triggers([
|
||||
{ type: 'webhook', path: '', isDraft: false },
|
||||
{ type: 'default_email', path: '', isDraft: false },
|
||||
{ type: 'cli', path: '', isDraft: false }
|
||||
])
|
||||
)
|
||||
setContext<TriggerContext>('TriggerContext', {
|
||||
triggersCount,
|
||||
simplifiedPoll: writable(false),
|
||||
showCaptureHint: writable(undefined),
|
||||
triggersState
|
||||
})
|
||||
|
||||
setContext(
|
||||
'FlowGraphAssetContext',
|
||||
initFlowGraphAssetsCtx({ getModules: () => flow?.value.modules ?? [] })
|
||||
)
|
||||
|
||||
// `${path}|${version}` so navigating between pinned and latest re-runs loadFlow.
|
||||
let previousLoadKey: string | undefined = $state(undefined)
|
||||
|
||||
async function archiveFlow(): Promise<void> {
|
||||
await FlowService.archiveFlowByPath({
|
||||
workspace: workspace!,
|
||||
path,
|
||||
requestBody: { archived: !flow?.archived }
|
||||
})
|
||||
loadFlow()
|
||||
}
|
||||
|
||||
async function deleteFlow(): Promise<void> {
|
||||
await FlowService.deleteFlowByPath({ workspace: workspace!, path })
|
||||
sendUserToast('Flow deleted')
|
||||
// Embedded, the host owns the tab: reloading lets it show that nothing is deployed
|
||||
// here, rather than opening Home beside a tab still showing the deleted flow.
|
||||
if (embedded) {
|
||||
loadFlow().then(
|
||||
() => onLoadState?.('loaded'),
|
||||
(e) => e?.status === 404 && onLoadState?.('not_found')
|
||||
)
|
||||
} else onNavigate('/')
|
||||
}
|
||||
|
||||
async function loadTriggersCount() {
|
||||
$triggersCount = await FlowService.getTriggersCountOfFlow({
|
||||
workspace: workspace!,
|
||||
path
|
||||
})
|
||||
}
|
||||
|
||||
async function loadTriggers(): Promise<void> {
|
||||
await triggersState.fetchTriggers(triggersCount, workspace, path, true, undefined, actingUser)
|
||||
}
|
||||
|
||||
let pinnedVersion: number | undefined = $state(undefined)
|
||||
|
||||
async function loadFlow(): Promise<void> {
|
||||
const versionParam = searchParams?.get('version')
|
||||
const versionId = versionParam ? Number(versionParam) : NaN
|
||||
if (Number.isFinite(versionId)) {
|
||||
const versioned = await FlowService.getFlowVersion({
|
||||
workspace: workspace!,
|
||||
version: versionId
|
||||
})
|
||||
if (versioned.path !== path) {
|
||||
sendUserToast(`Flow version ${versionId} belongs to ${versioned.path}, not ${path}.`, true)
|
||||
onNavigate(`/flows/get/${versioned.path}?workspace=${workspace}&version=${versionId}`)
|
||||
return
|
||||
}
|
||||
flow = versioned
|
||||
pinnedVersion = versionId
|
||||
} else {
|
||||
flow = await FlowService.getFlowByPath({
|
||||
workspace: workspace!,
|
||||
path,
|
||||
withStarredInfo: true
|
||||
})
|
||||
pinnedVersion = undefined
|
||||
}
|
||||
// A carried non-template value equal to the resolution of the flow's own dynamic
|
||||
// tag for these args is not an override but the previous run's pinned resolution:
|
||||
// drop it so the backend re-resolves from the (possibly edited) args. A differing
|
||||
// value is a genuine user override and a template re-resolves at push, so both stay.
|
||||
if (
|
||||
carriedTag &&
|
||||
isDynamicTag(flow.tag) &&
|
||||
!isTagTemplate(carriedTag) &&
|
||||
interpolateTag(flow.tag ?? '', workspace!, args) === carriedTag
|
||||
) {
|
||||
if (overrideTag === carriedTag) {
|
||||
overrideTag = undefined
|
||||
overrideTagNote = `tag ${flow.tag} is resolved at run time, so the previous run's tag ${carriedTag} was not applied`
|
||||
}
|
||||
carriedTag = undefined
|
||||
}
|
||||
if (!flow.path.startsWith(`u/${actingUser?.username}`) && flow.path.split('/').length > 2) {
|
||||
invisible_to_owner = flow.visible_to_runner_only
|
||||
}
|
||||
intervalId && clearInterval(intervalId)
|
||||
deploymentInProgress = flow.lock_error_logs == ''
|
||||
if (deploymentInProgress) {
|
||||
intervalId = setInterval(syncer, 500)
|
||||
}
|
||||
}
|
||||
|
||||
let isValid = $state(true)
|
||||
let loading = $state(false)
|
||||
|
||||
async function syncer(): Promise<void> {
|
||||
if (flow) {
|
||||
const status = await FlowService.getFlowDeploymentStatus({
|
||||
workspace: workspace!,
|
||||
path: flow.path
|
||||
})
|
||||
if (status.lock_error_logs == undefined || status.lock_error_logs != '') {
|
||||
deploymentInProgress = false
|
||||
deploymentJobId = undefined
|
||||
flow.lock_error_logs = status.lock_error_logs
|
||||
clearInterval(intervalId)
|
||||
} else if (status.job_id) {
|
||||
deploymentJobId = status.job_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runFlow(
|
||||
scheduledForStr: string | undefined,
|
||||
args: Record<string, any>,
|
||||
invisibleToOwner: boolean | undefined,
|
||||
overrideTag: string | undefined
|
||||
) {
|
||||
loading = true
|
||||
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
|
||||
try {
|
||||
let run = await JobService.runFlowByPath({
|
||||
workspace: workspace!,
|
||||
path,
|
||||
invisibleToOwner,
|
||||
requestBody: args,
|
||||
scheduledFor,
|
||||
tag: overrideTag,
|
||||
skipPreprocessor: true
|
||||
})
|
||||
await onNavigate('/run/' + run + '?workspace=' + workspace)
|
||||
} catch (e) {
|
||||
throw e
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runFlowForChat(
|
||||
userMessage: string,
|
||||
conversationId: string,
|
||||
additionalInputs?: Record<string, any>
|
||||
): Promise<string> {
|
||||
const run = await JobService.runFlowByPath({
|
||||
workspace: workspace!,
|
||||
path,
|
||||
memoryId: conversationId,
|
||||
requestBody: { user_message: userMessage, ...(additionalInputs ?? {}) },
|
||||
skipPreprocessor: true
|
||||
})
|
||||
return run
|
||||
}
|
||||
|
||||
let args: Record<string, any> | undefined = $state(undefined)
|
||||
|
||||
// Read once on purpose: these args seed the form, so tracking the fragment would
|
||||
// overwrite what the user has typed whenever it changes.
|
||||
let hash = untrack(() => locationHash)
|
||||
if (hash.length > 1) {
|
||||
try {
|
||||
let searchParams = new URLSearchParams(hash.slice(1))
|
||||
carriedTag = extractTagFromSharableHash(searchParams)
|
||||
overrideTag = carriedTag
|
||||
let params = [...Object.entries(urlParamsToObject(searchParams))].map(([k, v]) => [
|
||||
k,
|
||||
JSON.parse(v)
|
||||
])
|
||||
args = Object.fromEntries(params)
|
||||
} catch (e) {
|
||||
console.error('Was not able to transform hash as args', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Operators with the builder right author flows out of deployed runnables; every other
|
||||
// operator is read-only here.
|
||||
let canAuthorFlow = $derived(!actingUser?.operator || $operatorBuilderFlows)
|
||||
|
||||
let moveDrawer: MoveDrawer | undefined = $state()
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let runForm: RunForm | undefined = $state()
|
||||
|
||||
// The dev workspace's editor is not one the session panel can host, so from a preview tab
|
||||
// it opens in a new browser tab, as the session editors' own entry does.
|
||||
function editInFork(e: Event | undefined, itemPath: string) {
|
||||
if (!embedded) {
|
||||
return onEditInForkClick(e, 'flow', itemPath, { hasHref: true, prodWorkspace: workspace })
|
||||
}
|
||||
const m = e as MouseEvent | undefined
|
||||
if (m && (m.metaKey || m.ctrlKey || m.shiftKey || m.altKey || (m.button ?? 0) !== 0)) return
|
||||
e?.preventDefault()
|
||||
return openEditInFork('flow', itemPath, workspace)
|
||||
}
|
||||
|
||||
function getMainButtons(flow: Flow | undefined, args: object | undefined) {
|
||||
const buttons: any = []
|
||||
|
||||
if (flow && canAuthorFlow) {
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
description: `Start a new flow from a copy of this one`,
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: operatingHref(`${base}/flows/add?template=${flow.path}`),
|
||||
onClick: interceptNav(onNavigate, `/flows/add?template=${flow.path}`),
|
||||
variant: 'subtle',
|
||||
unifiedSize: 'md',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: GitFork
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
flow &&
|
||||
!actingUser?.operator &&
|
||||
!isCloudHosted() &&
|
||||
editInForkAllowed(workspace, $userWorkspaces)
|
||||
) {
|
||||
buttons.push({
|
||||
label: editInForkLabel(workspace, $userWorkspaces),
|
||||
description: editInForkDescription('flow', workspace, $userWorkspaces),
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('flow', flow.path, workspace),
|
||||
onClick: (e: Event | undefined) => editInFork(e, flow.path),
|
||||
unifiedSize: 'md',
|
||||
variant: !showEditButtons ? 'default' : 'subtle',
|
||||
startIcon: Pen
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!flow) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
label: `Runs`,
|
||||
buttonProps: {
|
||||
href: operatingHref(`${base}/runs/${flow.path}`),
|
||||
onClick: interceptNav(onNavigate, `/runs/${flow.path}`),
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
startIcon: Play
|
||||
}
|
||||
})
|
||||
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
narrow: 'menu',
|
||||
buttonProps: {
|
||||
onClick: () => flowHistory?.open(),
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
startIcon: History
|
||||
}
|
||||
})
|
||||
|
||||
if (!flow || !canAuthorFlow || !can_write) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
// The builder right covers flows only; building an app is still refused to operators.
|
||||
if (!actingUser?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
narrow: 'menu',
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
|
||||
// The raw app editor reads the payload from sessionStorage on mount.
|
||||
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
|
||||
await onNavigate('/apps_raw/add')
|
||||
},
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: LayoutDashboard
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
label: 'Edit',
|
||||
buttonProps: {
|
||||
href: operatingHref(`${base}/flows/edit/${path}`),
|
||||
onClick: interceptNav(onNavigate, `/flows/edit/${path}`),
|
||||
variant: 'accent',
|
||||
unifiedSize: 'md',
|
||||
disabled: !can_write || !showEditButtons,
|
||||
startIcon: Pen
|
||||
}
|
||||
})
|
||||
return buttons
|
||||
}
|
||||
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: workspace! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
function getMenuItems(
|
||||
flow: Flow | undefined,
|
||||
deployUiSettings: WorkspaceDeployUISettings | undefined
|
||||
) {
|
||||
if (!flow || !canAuthorFlow) return []
|
||||
|
||||
const menuItems: any = []
|
||||
|
||||
menuItems.push({
|
||||
label: 'Permissions',
|
||||
onclick: () => shareModal?.openDrawer(flow?.path ?? '', 'flow'),
|
||||
Icon: Shield,
|
||||
disabled: !can_write
|
||||
})
|
||||
|
||||
if (showEditButtons) {
|
||||
menuItems.push({
|
||||
label: 'Move/Rename',
|
||||
onclick: () => moveDrawer?.openDrawer(flow?.path ?? '', flow?.summary, 'flow'),
|
||||
Icon: FolderOpen
|
||||
})
|
||||
}
|
||||
|
||||
// The builder right opens this menu to operators; audit logs stay behind their own setting.
|
||||
if (
|
||||
!actingUser?.operator ||
|
||||
$userWorkspaces.find((w) => w.id === workspace)?.operator_settings?.audit_logs
|
||||
) {
|
||||
menuItems.push({
|
||||
label: 'Audit logs',
|
||||
Icon: Eye,
|
||||
onclick: () => {
|
||||
onNavigate(`/audit_logs?resource=${flow?.path}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (isDeployable('flow', flow?.path ?? '', deployUiSettings) && !actingUser?.operator) {
|
||||
menuItems.push({
|
||||
label: 'Deploy to staging/prod',
|
||||
onclick: () => deploymentDrawer?.openDrawer(flow?.path ?? '', 'flow'),
|
||||
Icon: ChevronUpSquare
|
||||
})
|
||||
}
|
||||
|
||||
if (can_write && showEditButtons) {
|
||||
menuItems.push({
|
||||
label: 'Deployments',
|
||||
onclick: () => flowHistory?.open(),
|
||||
Icon: HistoryIcon
|
||||
})
|
||||
menuItems.push({
|
||||
label: flow.archived ? 'Unarchive' : 'Archive',
|
||||
onclick: () => flow?.path && archiveFlow(),
|
||||
Icon: Archive,
|
||||
color: 'red'
|
||||
})
|
||||
menuItems.push({
|
||||
label: 'Delete',
|
||||
onclick: () => flow?.path && deleteFlow(),
|
||||
Icon: Trash,
|
||||
color: 'red'
|
||||
})
|
||||
}
|
||||
return menuItems
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
intervalId && clearInterval(intervalId)
|
||||
})
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
// Several instances can be mounted at once (a session keeps hidden tabs
|
||||
// mounted), and each one listens on the window: only the visible one runs.
|
||||
// `defaultPrevented` keeps anything nearer that already claimed the key — the chat
|
||||
// composer's send, a drawer's Escape — from also reaching the form.
|
||||
if (!active || event.defaultPrevented) return
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (isValid) {
|
||||
event.preventDefault()
|
||||
runForm?.run()
|
||||
} else {
|
||||
sendUserToast('Please fix errors before running', true)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
let stepDetail: FlowModule | string | undefined = $state(undefined)
|
||||
let rightPaneSelected = $state('saved_inputs')
|
||||
let savedInputsV2: SavedInputsV2 | undefined = $state(undefined)
|
||||
let detailLayout: DetailPageLayout | undefined = $state(undefined)
|
||||
let flowHistory: FlowHistory | undefined = $state(undefined)
|
||||
let path = $derived(routePath ?? '')
|
||||
|
||||
let topSectionHeight = $state(0)
|
||||
let paneHeight = $state(0)
|
||||
let flowGraphHeight = $state(0)
|
||||
|
||||
let graphMinHeight = $derived.by(() => {
|
||||
if (!topSectionHeight || !paneHeight) return 400
|
||||
const availableHeight = paneHeight - topSectionHeight - 1 // Account for the separator between the top section and the graph
|
||||
return Math.max(400, availableHeight)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const cliTrigger = triggersState.triggers.find((t) => t.type === 'cli')
|
||||
if (cliTrigger) {
|
||||
cliTrigger.extra = {
|
||||
cliCommand: `wmill flow run ${flow?.path} -d '${JSON.stringify(args)}'`
|
||||
}
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
// `$userStore`, not the acting user: this asks whether the session is authenticated at
|
||||
// all, which is global. Waiting on the acting user instead would hold the load for a
|
||||
// fork's `whoami`, and never load it at all if that lookup failed.
|
||||
if (workspace && $userStore && routePath) {
|
||||
const versionParam = searchParams?.get('version') ?? ''
|
||||
const loadKey = `${workspace}|${path}|${versionParam}`
|
||||
if (previousLoadKey !== loadKey) {
|
||||
previousLoadKey = loadKey
|
||||
untrack(() => {
|
||||
// A path with no deployed flow rejects here, which is the normal state of a
|
||||
// draft the user has not deployed — reported so a host can say so in place
|
||||
// rather than leaving the page blank on an unhandled rejection.
|
||||
loadFlow().then(
|
||||
() => onLoadState?.('loaded'),
|
||||
(e) => {
|
||||
if (onLoadState && e?.status === 404) onLoadState('not_found')
|
||||
else sendUserToast('Could not load flow: ' + (e?.body ?? e?.message ?? e), true)
|
||||
}
|
||||
)
|
||||
loadTriggersCount()
|
||||
loadTriggers()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
let showEditButtons = $state(false)
|
||||
let mainButtons = $derived(getMainButtons(flow, args))
|
||||
let chatInputEnabled = $derived(flow?.value?.chat_input_enabled ?? false)
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<ShareModal bind:this={shareModal} />
|
||||
<MoveDrawer
|
||||
bind:this={moveDrawer}
|
||||
on:update={async (e) => {
|
||||
await onNavigate('/flows/get/' + e.detail + `?workspace=${workspace}`)
|
||||
loadFlow()
|
||||
}}
|
||||
/>
|
||||
{#if flow}
|
||||
<FlowHistory bind:this={flowHistory} path={flow.path} onHistoryRestore={loadFlow} />
|
||||
{/if}
|
||||
|
||||
<DetailPageLayout
|
||||
bind:this={detailLayout}
|
||||
bind:selected={rightPaneSelected}
|
||||
isOperator={actingUser?.operator}
|
||||
forceSmallScreen={chatInputEnabled}
|
||||
isChatMode={chatInputEnabled}
|
||||
flow_json={{
|
||||
value: flow?.value,
|
||||
summary: flow?.summary,
|
||||
description: flow?.description,
|
||||
schema: flow?.schema
|
||||
}}
|
||||
>
|
||||
{#snippet header({ wide }: { wide: boolean })}
|
||||
<DetailPageHeader
|
||||
{wide}
|
||||
ownsPageHeader={!embedded}
|
||||
on:seeTriggers={() => {
|
||||
detailLayout?.showTriggers()
|
||||
}}
|
||||
{mainButtons}
|
||||
menuItems={getMenuItems(flow, deployUiSettings)}
|
||||
bind:errorHandlerMuted={
|
||||
() => flow?.ws_error_handler_muted ?? false,
|
||||
(v) => {
|
||||
if (flow !== undefined) flow.ws_error_handler_muted = v
|
||||
}
|
||||
}
|
||||
scriptOrFlowPath={flow?.path ?? ''}
|
||||
errorHandlerKind="flow"
|
||||
tag={flow?.tag ?? ''}
|
||||
labels={flow?.labels}
|
||||
inheritedLabels={flow?.inherited_labels}
|
||||
summary={flow?.summary}
|
||||
path={flow?.path}
|
||||
onSaved={can_write
|
||||
? async (newPath) => {
|
||||
if (newPath !== flow?.path) {
|
||||
await onNavigate(`/flows/get/${newPath}?workspace=${workspace}`)
|
||||
} else {
|
||||
loadFlow()
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#snippet trigger_badges()}
|
||||
<TriggersBadge
|
||||
showOnlyWithCount={true}
|
||||
showDraft={false}
|
||||
{path}
|
||||
newItem={false}
|
||||
isFlow
|
||||
selected={rightPaneSelected == 'triggers'}
|
||||
onSelect={async (triggerIndex: number) => {
|
||||
detailLayout?.showTriggers()
|
||||
await tick()
|
||||
triggersState.selectedTriggerIndex = triggerIndex
|
||||
}}
|
||||
small={false}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if workspace && flow}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace === $workspaceStore}
|
||||
<Star kind="flow" path={flow.path} summary={flow.summary} />
|
||||
{/if}
|
||||
{/if}
|
||||
<OnBehalfOfBadge
|
||||
onBehalfOf={flow?.on_behalf_of}
|
||||
onBehalfOfEmail={flow?.on_behalf_of_email}
|
||||
kind="flow"
|
||||
/>
|
||||
{#if flow?.value?.priority != undefined}
|
||||
<div class="hidden md:block">
|
||||
<HeaderBadge color="blue" variant="outlined" size="xs">
|
||||
{`Priority: ${flow?.value?.priority}`}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow?.value?.concurrent_limit != undefined && flow?.value?.concurrency_time_window_s != undefined}
|
||||
<div class="hidden md:block">
|
||||
<HeaderBadge color="gray" variant="outlined" size="xs">
|
||||
{`Concurrency limit: ${flow?.value?.concurrent_limit} runs every ${flow?.value?.concurrency_time_window_s}s`}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
</DetailPageHeader>
|
||||
{/snippet}
|
||||
{#snippet form({ graphInline }: { graphInline: boolean })}
|
||||
<div class="px-3">
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showEditButtons = v)} />
|
||||
</div>
|
||||
{#if flow}
|
||||
<div class="flex flex-col h-full bg-surface divide-y" bind:clientHeight={paneHeight}>
|
||||
<div bind:clientHeight={topSectionHeight} class={twMerge(chatInputEnabled ? 'h-full' : '')}>
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full flex flex-col',
|
||||
chatInputEnabled ? 'h-full min-h-0' : 'max-w-3xl p-6 min-h-[300px] justify-center',
|
||||
'mx-auto'
|
||||
)}
|
||||
>
|
||||
<!-- The chat reaches the edges of the pane, so the notices above it carry their
|
||||
own padding. `contents` leaves the form layout exactly as it was. -->
|
||||
<!-- Top spacing hangs off the first notice, not the wrapper: `{#if}` leaves a
|
||||
comment anchor behind, so an empty wrapper is not `:empty` and its own
|
||||
padding would show as a gap above a chat with nothing to announce. -->
|
||||
<div
|
||||
class={chatInputEnabled ? 'flex flex-col px-3 [&>*:first-child]:mt-3' : 'contents'}
|
||||
>
|
||||
{#if flow?.path}
|
||||
<CiTestResults path={flow.path} kind="flow" />
|
||||
{/if}
|
||||
|
||||
{#if flow?.archived}
|
||||
<Alert type="error" title="Archived">This flow was archived</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if pinnedVersion !== undefined}
|
||||
<Alert type="info" title="Viewing pinned version {pinnedVersion}">
|
||||
This is a historical version of the flow, not the latest.
|
||||
<a
|
||||
class="underline"
|
||||
href="/flows/get/{path}?workspace={workspace}"
|
||||
onclick={interceptNav(onNavigate, `/flows/get/${path}`)}
|
||||
>
|
||||
View latest
|
||||
</a>
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
<!-- In chat mode the description belongs to the chat, which shows it under the
|
||||
empty transcript. -->
|
||||
{#if !chatInputEnabled && !emptyString(flow?.description)}
|
||||
<div class="p-4 rounded-md bg-surface-secondary">
|
||||
<GfmMarkdown
|
||||
md={defaultIfEmptyString(flow?.description, 'No description')}
|
||||
noPadding
|
||||
/>
|
||||
</div>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if deploymentInProgress}
|
||||
<div class="pb-4" transition:slide={{ duration: 150 }}>
|
||||
<HeaderBadge color="yellow">
|
||||
<Loader2 size={12} class="inline animate-spin mr-1" />
|
||||
Deployment in progress
|
||||
{#if deploymentJobId}
|
||||
<a
|
||||
href="/run/{deploymentJobId}?workspace={workspace}"
|
||||
class="underline"
|
||||
target="_blank">view job</a
|
||||
>
|
||||
{/if}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow.lock_error_logs && flow.lock_error_logs != ''}
|
||||
<Alert type="error" title="Deployment failed">
|
||||
<p>
|
||||
This flow has not been deployed successfully because of the following errors:
|
||||
</p>
|
||||
<LogViewer content={flow.lock_error_logs} isLoading={false} tag={undefined} />
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chatInputEnabled}
|
||||
<!-- Chat Layout with Sidebar -->
|
||||
<FlowChat
|
||||
onRunFlow={runFlowForChat}
|
||||
{deploymentInProgress}
|
||||
path={flow?.path ?? ''}
|
||||
description={flow?.description}
|
||||
inputSchema={flow?.schema}
|
||||
flowModules={flow?.value?.modules}
|
||||
wideLayout
|
||||
frame="none"
|
||||
/>
|
||||
{:else}
|
||||
{@const hasSchema =
|
||||
flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0}
|
||||
<!-- Normal Mode: Form Layout -->
|
||||
<div class="flex flex-col align-left">
|
||||
{#if hasSchema || inputSelected}
|
||||
<div
|
||||
class="flex flex-row justify-between min-h-12"
|
||||
transition:slide={{ duration: 150 }}
|
||||
>
|
||||
<InputSelectedBadge
|
||||
onReject={() => {
|
||||
savedInputsV2?.resetSelected()
|
||||
}}
|
||||
{inputSelected}
|
||||
/>
|
||||
|
||||
{#if hasSchema}
|
||||
<Toggle
|
||||
bind:checked={jsonView}
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'JSON',
|
||||
rightTooltip: 'Fill args from JSON'
|
||||
}}
|
||||
lightMode
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.schema?.prompt_for_ai !== undefined}
|
||||
<AIFormAssistant
|
||||
instructions={flow.schema?.prompt_for_ai as string}
|
||||
onEditInstructions={() => {
|
||||
onNavigate(`/flows/edit/${flow?.path}`)
|
||||
}}
|
||||
runnableType="flow"
|
||||
path={flow?.path}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<RunForm
|
||||
bind:scheduledForStr
|
||||
bind:invisible_to_owner
|
||||
bind:overrideTag
|
||||
{overrideTagNote}
|
||||
syncArgsToUrl={!embedded}
|
||||
viewKeybinding
|
||||
{loading}
|
||||
autofocus
|
||||
detailed={false}
|
||||
bind:isValid
|
||||
runnable={flow}
|
||||
runAction={runFlow}
|
||||
bind:args
|
||||
bind:this={runForm}
|
||||
{jsonView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 flex flex-col gap-1 w-full items-end">
|
||||
<span class="text-2xs text-secondary">
|
||||
Edited <TimeAgo date={flow.edited_at ?? ''} noSeconds /> by {flow.edited_by}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if graphInline}
|
||||
<div class="grow min-h-0">
|
||||
<FlowGraphViewer
|
||||
triggerNode={true}
|
||||
download
|
||||
{flow}
|
||||
noSide={true}
|
||||
minHeight={graphMinHeight}
|
||||
on:select={(e) => {
|
||||
if (e.detail) {
|
||||
stepDetail = e.detail
|
||||
rightPaneSelected = 'flow_step'
|
||||
} else {
|
||||
stepDetail = undefined
|
||||
rightPaneSelected = 'saved_inputs'
|
||||
}
|
||||
}}
|
||||
on:triggerDetail={(e) => {
|
||||
detailLayout?.showTriggers()
|
||||
}}
|
||||
noBorder={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet save_inputs()}
|
||||
<SavedInputsV2
|
||||
bind:this={savedInputsV2}
|
||||
schema={flow?.schema}
|
||||
{jsonView}
|
||||
flowPath={flow?.path}
|
||||
{isValid}
|
||||
args={args ?? {}}
|
||||
bind:inputSelected
|
||||
on:selected_args={(e) => {
|
||||
const nargs = JSON.parse(JSON.stringify(e.detail))
|
||||
args = nargs
|
||||
if (jsonView) {
|
||||
runForm?.syncJsonEditor()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet flow_step({ onBack }: { onBack?: () => void })}
|
||||
{#if flow}
|
||||
{#if stepDetail}
|
||||
<FlowGraphViewerStep schema={flow.schema} {stepDetail} {onBack} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet triggers()}
|
||||
{#if flow}
|
||||
<TriggersEditor
|
||||
{args}
|
||||
runnableVersion={flow.version_id?.toString()}
|
||||
initialPath={flow.path}
|
||||
currentPath={flow.path}
|
||||
noEditor={true}
|
||||
newItem={false}
|
||||
isFlow={true}
|
||||
schema={flow.schema}
|
||||
isDeployed={true}
|
||||
noCapture={true}
|
||||
isEditor={false}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet flow_graph()}
|
||||
{#if flow}
|
||||
<div class="h-full overflow-auto" bind:clientHeight={flowGraphHeight}>
|
||||
<FlowGraphViewer
|
||||
triggerNode={true}
|
||||
download
|
||||
{flow}
|
||||
noSide={true}
|
||||
noBorder
|
||||
minHeight={flowGraphHeight}
|
||||
on:select={(e) => {
|
||||
if (e.detail) {
|
||||
stepDetail = e.detail
|
||||
rightPaneSelected = 'flow_step'
|
||||
} else {
|
||||
stepDetail = undefined
|
||||
rightPaneSelected = 'saved_inputs'
|
||||
}
|
||||
}}
|
||||
on:triggerDetail={(e) => {
|
||||
detailLayout?.showTriggers()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DetailPageLayout>
|
||||
|
||||
<FlowAssetsHandler
|
||||
modules={flow?.value.modules ?? []}
|
||||
enableDbExplore
|
||||
enablePathScriptAndFlowAssets
|
||||
/>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { FlowService, ScriptService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
/** Flips the workspace error handler for one script or flow and reports the outcome in a
|
||||
* toast. Returns the new muted state, or undefined when nothing changed. */
|
||||
* toast. Returns the new muted state, or undefined when nothing changed. The workspace is the
|
||||
* caller's to supply — a detail page embedded in an AI session acts on that session's
|
||||
* workspace, which is not the one the navigation store names. */
|
||||
export async function toggleWorkspaceErrorHandler(
|
||||
workspace: string | undefined,
|
||||
kind: 'script' | 'flow',
|
||||
path: string,
|
||||
muted: boolean | undefined
|
||||
): Promise<boolean | undefined> {
|
||||
const workspace = get(workspaceStore)
|
||||
if (workspace === undefined) return undefined
|
||||
const next = !muted
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Route a link's plain left-click through the detail page's `onNavigate` instead of
|
||||
* letting the browser follow it. The detail pages keep real `href`s so a reader can
|
||||
* still middle-click or ⌘-click one into a new tab — those clicks are left alone —
|
||||
* but a plain click has to reach `onNavigate`, which in an AI session preview moves
|
||||
* within the panel rather than taking the browser out of the session.
|
||||
*/
|
||||
export function interceptNav(
|
||||
onNavigate: (url: string) => void | Promise<void>,
|
||||
url: string
|
||||
): (e?: Event) => void {
|
||||
return (e?: Event) => {
|
||||
const m = e as MouseEvent | undefined
|
||||
if (m && (m.metaKey || m.ctrlKey || m.shiftKey || m.altKey || (m.button ?? 0) !== 0)) return
|
||||
e?.preventDefault()
|
||||
void onNavigate(url)
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,7 @@
|
||||
variant="accent"
|
||||
on:click={() =>
|
||||
window.open(
|
||||
`/flows/add?template_id=${selectedVersion?.id}&template=${path}`,
|
||||
`/flows/add?template_id=${selectedVersion?.id}&template=${path}&workspace=${$operatingWorkspace}`,
|
||||
'_blank'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
import AgentEvalsModal from './AgentEvalsModal.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
import { emptyString } from '$lib/utils'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
@@ -87,6 +89,9 @@
|
||||
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
|
||||
let versionDrawer: Drawer | undefined = $state(undefined)
|
||||
let settingsDrawer: Drawer | undefined = $state(undefined)
|
||||
/** Held by the pen's popover while it is open; the band's trail reads it so the path does not
|
||||
* reflow under the pointer as the user types. */
|
||||
let pathSnapshot = $state<string | undefined>(undefined)
|
||||
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
|
||||
let saving = $state(false)
|
||||
|
||||
@@ -310,7 +315,7 @@
|
||||
breadcrumb, the level it is on reads after that name, and its controls are the band's
|
||||
actions. -->
|
||||
<PageHeaderContent
|
||||
item={{ path: shownPath, summaryContent: agentBadges }}
|
||||
item={{ path: pathSnapshot ?? shownPath, summaryContent: agentSummary }}
|
||||
afterName={agentHint}
|
||||
actions={settings}
|
||||
/>
|
||||
@@ -322,11 +327,46 @@
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
{#snippet agentBadges()}
|
||||
<!-- The version rides with the agent's name, as the linked-agent card in the step panel has
|
||||
it. The trail's levels are not here: this layout opens settings in a drawer and evals
|
||||
over the page, so it never stands on one. -->
|
||||
{@render titleBadge()}
|
||||
{#snippet agentSummary()}
|
||||
<!-- What the agent is, beside what it is called, and the pen that renames both — the same
|
||||
shape the script, flow and app editors carry. The version rides along, as the
|
||||
linked-agent card in the step panel has it. The trail's levels are not here: this layout
|
||||
opens settings in a drawer and evals over the page, so it never stands on one. -->
|
||||
<div class="group flex items-center gap-1 min-w-0">
|
||||
{#if draft?.state}
|
||||
<span
|
||||
class="min-w-0 truncate text-xs {emptyString(draft.state.description)
|
||||
? 'text-tertiary italic font-normal'
|
||||
: 'font-medium text-emphasis'}"
|
||||
title={draft.state.description}
|
||||
>{emptyString(draft.state.description)
|
||||
? 'Add a summary...'
|
||||
: draft.state.description}</span
|
||||
>
|
||||
<PathEditPopover
|
||||
bind:summary={
|
||||
() => draft.state?.description ?? '',
|
||||
(v) => {
|
||||
if (draft.state) draft.state.description = v
|
||||
}
|
||||
}
|
||||
bind:path={
|
||||
() => draft.state?.path ?? '',
|
||||
(v) => {
|
||||
if (draft.state) draft.state.path = v
|
||||
}
|
||||
}
|
||||
bind:snapshotPath={pathSnapshot}
|
||||
savedPath={draft?.noDeployed ? undefined : target?.path}
|
||||
kind="resource"
|
||||
workspaceId={ws}
|
||||
pathEditable={!readOnly}
|
||||
summaryEditable={!readOnly}
|
||||
penVisibility={emptyString(draft.state.description) ? 'always' : 'hover'}
|
||||
/>
|
||||
{/if}
|
||||
{@render titleBadge()}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet agentHint()}
|
||||
{#if description}
|
||||
|
||||
@@ -137,6 +137,7 @@ repeatedly — check the brand's own page.
|
||||
| `FunkwhaleIcon` | `funkwhale` | mixed | #009FE3 | #009FE3 | 10.69 | 5.71 | www.funkwhale.audio/logos (theme/images/icon.svg) |
|
||||
| `GSheetsIcon` | `gsheets` | fixed | #009954 | #009954 | 3.57 | 12.47 | Google product logo sheets_2026q3 (gstatic productlogos, used on workspace.google.com/products/sheets) |
|
||||
| `GcalIcon` | `gcal` | fixed | #BBE2FF | #BBE2FF | 3.40 | 12.47 | Google's own Calendar 2026 product logo, https://www.gstatic.com/images/branding/productlogos/calendar_2026/v2/web/192px.svg (paths verbatim) |
|
||||
| `GchatIcon` | `gchat` | fixed | #0EBC5F | #0EBC5F | 2.80 | 4.98 | Google's own Chat 2026 product icon, https://www.gstatic.com/images/branding/productlogos/chat_2026/v2/web/192px.svg (paths verbatim, ids namespaced) |
|
||||
| `GdocsIcon` | `gdocs` | inherits | #718096 | #A9B0BA | 3.88 | 5.71 | Google's own Docs product icon (gstatic.com/images/branding/productlogos/docs_2026/v2/web/192px.svg, served on workspace.google.com/products/docs) |
|
||||
| `GdriveIcon` | `gdrive` | fixed | #B43333 | #B43333 | 5.87 | 10.05 | https://www.gstatic.com/images/branding/productlogos/drive_2026/v2/web/192px.svg, Google's own product-logo CDN; paths and gradient stops are verbatim |
|
||||
| `GhostCmsIcon` | `ghostcms` | pair | #15171A | #FFFFFF | 17.38 | 12.47 | docs.ghost.org |
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
height?: string
|
||||
width?: string
|
||||
}
|
||||
|
||||
let { height = '24px', width = '24px' }: Props = $props()
|
||||
</script>
|
||||
|
||||
<!-- Google's own Chat product icon, gstatic.com/images/branding/productlogos/chat_2026/v2/web/192px.svg.
|
||||
Google forbids recolouring its logos, so this ships unmodified apart from namespacing the
|
||||
gradient/mask ids, which are document-global. -->
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
viewBox="0 0 192 192"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Google Chat"
|
||||
>
|
||||
<rect width="160" height="96" x="16" y="28" fill="#00AF57" rx="48" />
|
||||
<path
|
||||
fill="#0EBC5F"
|
||||
d="M133 48c28.167 0 51 22.834 51 51 0 28.167-22.833 51-51 51H96.624l-34.857 23.064c-3.86 2.544-5.789 3.816-7.372 3.92a6 6 0 0 1-5.612-3.022C48 172.583 48 170.271 48 165.649V148.81C25.121 143.78 8 123.39 8 99c0-28.166 22.834-51 51-51h74z"
|
||||
/>
|
||||
<mask
|
||||
id="gchat-mask-a"
|
||||
width="176"
|
||||
height="129"
|
||||
x="8"
|
||||
y="48"
|
||||
maskUnits="userSpaceOnUse"
|
||||
style="mask-type:alpha"
|
||||
>
|
||||
<path
|
||||
fill="#0EBC5F"
|
||||
d="M133 48c28.167 0 51 22.834 51 51 0 28.167-22.833 51-51 51H96.722l-39.428 25.896c-3.99 2.62-9.294-.242-9.294-5.015V148.81C25.121 143.78 8 123.39 8 99c0-28.166 22.834-51 51-51h74z"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#gchat-mask-a)">
|
||||
<rect width="160" height="96" x="16" y="28" fill="#0EBC5F" rx="48" />
|
||||
<rect width="160" height="96" x="16" y="28" fill="url(#gchat-paint-b)" rx="48" />
|
||||
<path
|
||||
stroke="#fff"
|
||||
stroke-linecap="round"
|
||||
stroke-width="12"
|
||||
d="M62 94s8.84 18 34 18 34-17.182 34-17.182"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="gchat-paint-b"
|
||||
x1="96"
|
||||
x2="96"
|
||||
y1="28"
|
||||
y2="124"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset=".09" stop-color="#94D4FF" />
|
||||
<stop offset=".28" stop-color="#78C9FF" />
|
||||
<stop offset=".88" stop-color="#01AE58" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
@@ -33,6 +33,7 @@ import StripeIcon from './StripeIcon.svelte'
|
||||
import TelegramIcon from './TelegramIcon.svelte'
|
||||
import FunkwhaleIcon from './FunkwhaleIcon.svelte'
|
||||
import GdocsIcon from './GdocsIcon.svelte'
|
||||
import GchatIcon from './GchatIcon.svelte'
|
||||
import NextcloudIcon from './NextcloudIcon.svelte'
|
||||
import NetsuiteIcon from './NetsuiteIcon.svelte'
|
||||
import FaunadbIcon from './FaunadbIcon.svelte'
|
||||
@@ -386,6 +387,7 @@ export const APP_TO_ICON_COMPONENT = {
|
||||
helper: WindmillIcon,
|
||||
windmillhub: WindmillIcon,
|
||||
gdocs: GdocsIcon,
|
||||
gchat: GchatIcon,
|
||||
ocs: NextcloudIcon,
|
||||
faunadb: FaunadbIcon,
|
||||
clickhouse: ClickhouseIcon,
|
||||
@@ -715,6 +717,7 @@ export {
|
||||
DatadogIcon,
|
||||
FunkwhaleIcon,
|
||||
GdocsIcon,
|
||||
GchatIcon,
|
||||
FaunadbIcon,
|
||||
ClickhouseIcon,
|
||||
OpenaiIcon,
|
||||
|
||||
@@ -45,9 +45,6 @@ export type PageHeaderContent = {
|
||||
* running in a fork. The breadcrumb then names that workspace, and its fork part shows only
|
||||
* when it really is a fork. */
|
||||
actingWorkspaceId?: string
|
||||
/** The page is the whole viewport — a deployed app. The band leaves the flow and waits behind
|
||||
* a handle in the corner, until the viewer pins it. */
|
||||
fullBleed?: boolean
|
||||
/** Width in px, at the right of the viewport, that this page owns from the top down — a
|
||||
* session's side panel. The band stops there instead of running over it, and the band floats
|
||||
* above the page rather than pushing it down, so that column starts at the top. */
|
||||
@@ -101,7 +98,6 @@ export const pageHeader = {
|
||||
if (c.contexts !== undefined) merged.contexts = c.contexts
|
||||
if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId
|
||||
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
|
||||
if (c.fullBleed !== undefined) merged.fullBleed = c.fullBleed
|
||||
if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible
|
||||
}
|
||||
return merged
|
||||
|
||||
@@ -16,6 +16,18 @@ describe('pendingEditorFlush', () => {
|
||||
expect(anyEditorUnparseable()).toBe(false)
|
||||
})
|
||||
|
||||
it("scopes the check to one form, so another form's broken editor does not block it", () => {
|
||||
const insideA = {} as Element
|
||||
const formA = { contains: (el: Element) => el === insideA } as Element
|
||||
const formB = { contains: () => false } as unknown as Element
|
||||
const editor = {}
|
||||
setEditorUnparseable(editor, true, insideA)
|
||||
expect(anyEditorUnparseable(formA)).toBe(true)
|
||||
expect(anyEditorUnparseable(formB)).toBe(false)
|
||||
expect(anyEditorUnparseable()).toBe(true)
|
||||
setEditorUnparseable(editor, false)
|
||||
})
|
||||
|
||||
it('flushes registered editors, and stops once they unmount', () => {
|
||||
let flushed = 0
|
||||
const deregister = registerPendingEditor({ flushPendingChanges: () => flushed++ })
|
||||
|
||||
@@ -20,17 +20,22 @@ export function flushAllPendingEditorChanges(): void {
|
||||
// Editors whose current text does not parse. Their value never reaches the bound field, so
|
||||
// a caller persisting "what is on screen" would save the last value that did parse and
|
||||
// leave without it. Registered by the editors that parse, not by the ones that only hold text.
|
||||
const unparseable = new Set<object>()
|
||||
const unparseable = new Map<object, Element | undefined>()
|
||||
|
||||
/** Mark or clear this editor as holding text that does not parse. */
|
||||
export function setEditorUnparseable(key: object, invalid: boolean): void {
|
||||
if (invalid) unparseable.add(key)
|
||||
/** Mark or clear this editor as holding text that does not parse. `el` is where the editor
|
||||
* sits, for a caller that checks only its own part of the page. */
|
||||
export function setEditorUnparseable(key: object, invalid: boolean, el?: Element): void {
|
||||
if (invalid) unparseable.set(key, el)
|
||||
else unparseable.delete(key)
|
||||
}
|
||||
|
||||
/** Whether any editor on screen holds text that cannot be persisted as written. Registry-
|
||||
* wide rather than per-item: the editors that parse are nested arbitrarily deep and none
|
||||
* of them knows which draft it belongs to. */
|
||||
export function anyEditorUnparseable(): boolean {
|
||||
return unparseable.size > 0
|
||||
/** Whether any editor holds text that cannot be persisted as written. Registry-wide rather
|
||||
* than per-item: the editors that parse are nested arbitrarily deep and none of them knows
|
||||
* which draft it belongs to. `within` narrows it to the editors inside one element, for a
|
||||
* form that shares the page with others: a session keeps hidden preview tabs mounted, and
|
||||
* one tab's broken JSON must not stop another tab's form from running. */
|
||||
export function anyEditorUnparseable(within?: Element): boolean {
|
||||
if (!within) return unparseable.size > 0
|
||||
for (const el of unparseable.values()) if (el && within.contains(el)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
// `if (!inSessionPane) UserDraft.remove(...)` guards below skip the editor's
|
||||
// own removal (it would target the wrong, navigation-workspace key). The
|
||||
// session-side equivalent is RawAppEditorView's `onDeploy` →
|
||||
// `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads
|
||||
// `runtime.itemDeployed`, which discards the fork draft + reloads
|
||||
// the preview to the deployed version.
|
||||
import { AIBtnClasses } from '../copilot/chat/AIButtonStyle'
|
||||
import { stripRawAppDiffNoise } from './utils'
|
||||
|
||||
@@ -21,9 +21,15 @@
|
||||
* (publish flow) needs it to read the app's DOM, which is only possible on
|
||||
* the unsandboxed path. */
|
||||
oniframe?: (iframe: HTMLIFrameElement | undefined) => void
|
||||
/** Mirror the app's own route into the page URL's hash, and start from it. Off where
|
||||
* the app is embedded in a page that is not its own — an AI session preview tab —
|
||||
* since there the hash belongs to that page and to whichever app was opened first. */
|
||||
syncHashToUrl?: boolean
|
||||
}
|
||||
|
||||
let { workspace, user, secret, path, runnables, oniframe }: Props = $props()
|
||||
let { workspace, user, secret, path, runnables, oniframe, syncHashToUrl = true }: Props =
|
||||
$props()
|
||||
const pageHash = () => (syncHashToUrl ? window.location.hash : '')
|
||||
|
||||
$effect(() => {
|
||||
const el = unsandboxed ? iframe : undefined
|
||||
@@ -86,7 +92,7 @@
|
||||
secret,
|
||||
{ ctx: u, workspace },
|
||||
window.location.origin,
|
||||
window.location.hash || ''
|
||||
pageHash()
|
||||
)
|
||||
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
|
||||
}
|
||||
@@ -186,7 +192,7 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initialHash = window.location.hash || ''
|
||||
initialHash = pageHash()
|
||||
if (framed) {
|
||||
// Pre-fetch the shared store from the embedder.
|
||||
try {
|
||||
@@ -249,7 +255,7 @@
|
||||
)
|
||||
} catch (_) {}
|
||||
}
|
||||
} else if (data?.type === 'windmill:hashchange') {
|
||||
} else if (data?.type === 'windmill:hashchange' && syncHashToUrl) {
|
||||
// Keep the parent URL hash in sync for shareable URLs.
|
||||
const newHash = data.hash || ''
|
||||
if (window.location.hash !== newHash) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
path,
|
||||
workspaceId,
|
||||
onNavigate,
|
||||
onSeeDetails,
|
||||
isActiveSession = true,
|
||||
active = true,
|
||||
initialSelectedId
|
||||
@@ -23,6 +24,9 @@
|
||||
path: string
|
||||
workspaceId: string
|
||||
onNavigate?: (item: WorkspaceItem) => void
|
||||
/** `Exit & see details` — flips this tab to the item's deployed view rather than
|
||||
* navigating out of the session. */
|
||||
onSeeDetails?: (e: { path: string }) => void
|
||||
/** Forwarded to SessionEditorTarget — only the visible session claims the
|
||||
* workspace's single live-editor slot. */
|
||||
isActiveSession?: boolean
|
||||
@@ -124,15 +128,13 @@
|
||||
version={cell.saved.val?.version_id}
|
||||
{diffDrawer}
|
||||
{onNavigate}
|
||||
onDetails={onSeeDetails}
|
||||
condensedHeader={true}
|
||||
customUi={{ topBar: { aiBuilder: false } }}
|
||||
onDeploy={() => {
|
||||
// FlowBuilder has no deploy toast and the session stays put, so toast
|
||||
// here, then sync the preview to deployed (pulls the new locks + version_id).
|
||||
// FlowBuilder has no deploy toast and the session stays put, so toast here.
|
||||
sendUserToast('Deployed')
|
||||
runtime.syncPreviewWithDeployed(workspaceId, 'flow', path)
|
||||
// Deploying clears the item's pending draft — refresh the Draft Count.
|
||||
invalidateWorkspaceDrafts(workspaceId)
|
||||
runtime.itemDeployed(workspaceId, 'flow', path)
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* The View side of a session preview tab: the deployed item, rendered by the same
|
||||
* components the workspace `/scripts/get`, `/flows/get` and `/apps_raw/get` pages
|
||||
* render. Everything those pages reach — drawers, triggers, saved inputs — is scoped
|
||||
* to the session's workspace through `workspace`, and every navigation they attempt
|
||||
* is caught here and turned into a move inside the preview panel, so nothing takes
|
||||
* the browser out of the session.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import { base } from '$lib/base'
|
||||
import ScriptDetail from '$lib/components/details/ScriptDetail.svelte'
|
||||
import FlowDetail from '$lib/components/details/FlowDetail.svelte'
|
||||
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import type { SessionRuntime } from './sessionRuntime.svelte'
|
||||
import type { SessionTargetKind } from './sessionRuntime.svelte'
|
||||
import {
|
||||
parseHistoricalScriptEdit,
|
||||
parsePreviewItemRoute,
|
||||
previewLocationLabel,
|
||||
stripBase
|
||||
} from './previewRouter'
|
||||
|
||||
let {
|
||||
runtime,
|
||||
kind,
|
||||
path,
|
||||
version,
|
||||
workspaceId,
|
||||
tabId,
|
||||
container,
|
||||
active = true
|
||||
}: {
|
||||
runtime: SessionRuntime
|
||||
/** The preview tab hosting this view, which its in-place moves re-point. */
|
||||
tabId: string
|
||||
/** The tab's host element, which also holds the overlays the page portals out. */
|
||||
container: HTMLElement | undefined
|
||||
kind: SessionTargetKind
|
||||
path: string
|
||||
/** Deployed version this tab is pinned to, from its URL's `?version=`. */
|
||||
version?: string
|
||||
workspaceId: string
|
||||
/** Whether this side is the one on screen. Hidden sides stay mounted, so this gates
|
||||
* the window-level keyboard handler — several instances listen on the window. */
|
||||
active?: boolean
|
||||
} = $props()
|
||||
|
||||
// This reads the deployed version over the API rather than from the editor cell the
|
||||
// chat mutates, so it is the one preview kind that does not self-sync (the invariant
|
||||
// `previewReload.ts` states for editors). A deploy from the editor topbar beside it
|
||||
// bumps this counter through `itemDeployed`; a deploy from the chat arrives
|
||||
// instead as `refresh()`, via `previewReload`'s item list. Neither covers a deploy made
|
||||
// outside the session (Compare & Deploy, the CLI, another browser tab).
|
||||
const revision = $derived(runtime.deployedRevision(kind, path))
|
||||
let reloadKey = $state(0)
|
||||
// Seeded from the current value on purpose: before the first run this holds "no deploy
|
||||
// seen yet", so a fresh mount must not count as one.
|
||||
let lastRevision = untrack(() => revision)
|
||||
$effect(() => {
|
||||
const rev = revision
|
||||
if (rev === lastRevision) return
|
||||
lastRevision = rev
|
||||
reloadKey++
|
||||
})
|
||||
|
||||
/** Refetch the deployed item. The host calls this for the reload signals that reach a
|
||||
* tab from outside it; nothing about becoming visible triggers it, so a form the reader
|
||||
* filled in survives switching tabs away and back. */
|
||||
export function refresh(): void {
|
||||
reloadKey++
|
||||
}
|
||||
|
||||
const searchParams = $derived(version ? new URLSearchParams({ version }) : undefined)
|
||||
|
||||
// Remounts the detail page below, which loads on mount.
|
||||
const loadKey = $derived(`${workspaceId}/${kind}/${path}/${version ?? ''}/${reloadKey}`)
|
||||
// Nothing deployed at this path. Stamped with the load it answers, so a refetch or a
|
||||
// re-pointed tab is never answered from an earlier load's verdict.
|
||||
let notFoundFor: string | undefined = $state(undefined)
|
||||
const notDeployed = $derived(notFoundFor === loadKey)
|
||||
function onLoadState(key: string, state: 'loaded' | 'not_found') {
|
||||
// A load that outlived its key (still in flight when the tab was re-pointed) answers
|
||||
// for an item no longer on screen, either way.
|
||||
if (key !== loadKey) return
|
||||
notFoundFor = state === 'not_found' ? key : undefined
|
||||
}
|
||||
|
||||
function toEditSide() {
|
||||
onNavigate(
|
||||
kind === 'flow'
|
||||
? `/flows/edit/${path}`
|
||||
: kind === 'script'
|
||||
? `/scripts/edit/${path}`
|
||||
: `/apps_raw/edit/${path}`
|
||||
)
|
||||
}
|
||||
|
||||
// Everything the detail pages navigate to, resolved against the preview panel: a run
|
||||
// opens as its own tab, another side or version of an item re-points this tab, and
|
||||
// any other page opens as a page tab. Nothing reaches the top-level router.
|
||||
function onNavigate(url: string): void {
|
||||
const clean = stripBase(url)
|
||||
const query = new URL(url, 'http://x').searchParams
|
||||
const run = clean.match(/^\/run\/([^/?#]+)$/)
|
||||
if (run) {
|
||||
const jobId = decodeURIComponent(run[1])
|
||||
runtime.manager.openRunInPreview?.({
|
||||
jobId,
|
||||
workspace: query.get('workspace') || workspaceId,
|
||||
label: previewLocationLabel(`/run/${jobId}`)
|
||||
})
|
||||
return
|
||||
}
|
||||
const route = parsePreviewItemRoute(url)
|
||||
// Legacy drag-and-drop apps and historical script edits have no in-panel host, so they
|
||||
// fall through to a page tab.
|
||||
if (route && (route.kind !== 'app' || route.raw_app) && !parseHistoricalScriptEdit(url)) {
|
||||
// A script's version history addresses a version by putting its hash where the
|
||||
// path goes. Every real script path is `u/<user>/…` or `f/<folder>/…`, so a
|
||||
// segment with no slash is a hash — pinned in the query instead, or this tab's
|
||||
// identity would become that hash and it would stop being a tab on this script.
|
||||
const isVersionHash = route.kind === 'script' && !route.itemPath.includes('/')
|
||||
const target = {
|
||||
type: 'item' as const,
|
||||
item: {
|
||||
path: isVersionHash ? path : route.itemPath,
|
||||
summary: '',
|
||||
kind: route.kind,
|
||||
raw_app: route.raw_app
|
||||
},
|
||||
mode: route.mode,
|
||||
version: isVersionHash ? route.itemPath : (query.get('version') ?? undefined)
|
||||
}
|
||||
// This tab is the deployed side, so a link to an editor is a request for the other
|
||||
// side: it opens beside this one rather than replacing the page it was asked from.
|
||||
// Everything else the page links — another version, the head, a rename's new path —
|
||||
// is still this same view of the item, so it re-points in place.
|
||||
if (route.mode === 'edit') runtime.previewTabs.open(target)
|
||||
else runtime.previewTabs.navigate(target, tabId)
|
||||
return
|
||||
}
|
||||
runtime.previewTabs.open({
|
||||
type: 'page',
|
||||
href: `${base}${clean}${query.size ? `?${query}` : ''}`,
|
||||
label: previewLocationLabel(clean)
|
||||
})
|
||||
}
|
||||
|
||||
// Every in-app link under the tab, not only the ones that call `onNavigate`: a plain anchor
|
||||
// followed by the browser would take it out of the session. `container` is the tab's host,
|
||||
// which also holds the drawers the page portals out of this component. Listened for on
|
||||
// `body`, between the link's own handlers (Svelte delegates them to the app root inside it)
|
||||
// and SvelteKit's router (on the document element), so it leaves alone a click a handler
|
||||
// already took and beats the router to the rest. A `#` link is a control's own; followed,
|
||||
// it would write into the sessions page's hash.
|
||||
$effect(() => {
|
||||
const el = container
|
||||
if (!el) return
|
||||
function onClick(e: MouseEvent) {
|
||||
if (e.defaultPrevented || e.button !== 0) return
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||
const target = e.target as Element | null
|
||||
if (!target || !el!.contains(target)) return
|
||||
const a = target.closest?.('a[href]') as HTMLAnchorElement | null
|
||||
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return
|
||||
const url = new URL(a.href, window.location.href)
|
||||
if (url.origin !== window.location.origin) return
|
||||
e.preventDefault()
|
||||
if (a.getAttribute('href')?.startsWith('#')) return
|
||||
onNavigate(`${url.pathname}${url.search}`)
|
||||
}
|
||||
document.body.addEventListener('click', onClick)
|
||||
return () => document.body.removeEventListener('click', onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Remount to refetch: both detail components load on mount, so a deploy lands here
|
||||
rather than as a refresh path of their own. -->
|
||||
{#key loadKey}
|
||||
{@const mountedKey = loadKey}
|
||||
{@const setLoadState = (state: 'loaded' | 'not_found') => onLoadState(mountedKey, state)}
|
||||
{#if notDeployed}
|
||||
<!-- This side shows the DEPLOYED item, and there isn't one. Without this the detail
|
||||
page renders nothing and the panel is simply blank, which reads as a failure
|
||||
rather than as the ordinary state of a draft nobody has deployed yet. -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<div class="text-sm text-primary">Not deployed yet</div>
|
||||
<div class="text-xs text-tertiary max-w-sm">
|
||||
Nothing is deployed at <span class="font-mono">{path}</span>, so there is no deployed
|
||||
version to view. Deploy it from the editor to see it here.
|
||||
</div>
|
||||
<Button unifiedSize="sm" variant="default" startIcon={{ icon: Pen }} on:click={toEditSide}>
|
||||
Back to editor
|
||||
</Button>
|
||||
</div>
|
||||
{:else if kind === 'script'}
|
||||
<ScriptDetail
|
||||
hash={path}
|
||||
workspace={workspaceId}
|
||||
{searchParams}
|
||||
{onNavigate}
|
||||
{active}
|
||||
embedded
|
||||
onLoadState={setLoadState}
|
||||
/>
|
||||
{:else if kind === 'flow'}
|
||||
<FlowDetail
|
||||
{path}
|
||||
workspace={workspaceId}
|
||||
{searchParams}
|
||||
{onNavigate}
|
||||
{active}
|
||||
embedded
|
||||
onLoadState={setLoadState}
|
||||
/>
|
||||
{:else}
|
||||
<InWorkspaceAppViewer
|
||||
workspace={workspaceId}
|
||||
{path}
|
||||
onEdit={() => onNavigate(`/apps_raw/edit/${path}`)}
|
||||
onLoadState={setLoadState}
|
||||
syncHashToUrl={false}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
@@ -60,7 +60,11 @@
|
||||
if (item) {
|
||||
runtime.previewTabs.open({
|
||||
type: 'item',
|
||||
item: { kind: item.kind, raw_app: item.raw_app, path: item.itemPath, summary: '' }
|
||||
item: { kind: item.kind, raw_app: item.raw_app, path: item.itemPath, summary: '' },
|
||||
// A list page only ever links what it runs — a schedule's script, a
|
||||
// trigger's flow — which is deployed by construction. Its deployed page
|
||||
// is what the reader clicking through is asking about.
|
||||
mode: item.mode ?? 'view'
|
||||
})
|
||||
} else {
|
||||
runtime.previewTabs.open({ type: 'page', href, label: previewLocationLabel(href) })
|
||||
|
||||
@@ -16,7 +16,7 @@ section or the flat layout.
|
||||
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { Compass, FileText } from 'lucide-svelte'
|
||||
import { Compass, FileText, Pen } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
@@ -45,6 +45,7 @@ section or the flat layout.
|
||||
isArtifactKey,
|
||||
pageHref,
|
||||
pageKey,
|
||||
previewModeFor,
|
||||
type PreviewTarget
|
||||
} from './previewRouter'
|
||||
|
||||
@@ -135,7 +136,11 @@ section or the flat layout.
|
||||
draftPath: storagePath !== d.path ? d.path : d.draftPath,
|
||||
summary: d.summary ?? '',
|
||||
kind: k,
|
||||
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined
|
||||
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined,
|
||||
// A chat-scaffolded draft the workspace listers have never seen. It has
|
||||
// an editor and nothing else, so picking it must not aim at a deployed
|
||||
// page — and a row merged over a loaded one keeps the lister's answer.
|
||||
draftOnly: true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -145,14 +150,42 @@ section or the flat layout.
|
||||
|
||||
// Wrap item leaves as PreviewTargets so the tree can carry page leaves too.
|
||||
// Branch structure is untouched — only leaf `data` is re-tagged.
|
||||
//
|
||||
// `mode` is stamped here rather than left to the tab: picking is the reader
|
||||
// saying "show me this", so it lands on the deployed page, and `navigate`
|
||||
// would otherwise keep whichever side the re-pointed tab happened to be on.
|
||||
function tagItems(nodes: DrillNode<WorkspaceItem>[]): DrillNode<PreviewTarget>[] {
|
||||
return nodes.map((n) =>
|
||||
n.type === 'leaf'
|
||||
? { ...n, data: { type: 'item', item: n.data } }
|
||||
? { ...n, data: { type: 'item', item: n.data, mode: previewModeFor(n.data) } }
|
||||
: { ...n, children: tagItems(n.children) }
|
||||
)
|
||||
}
|
||||
|
||||
// Open the item's editor. Offered on every item row, including the draft-only ones
|
||||
// a plain click already opens there: a row that drops the button to say "you were
|
||||
// getting this anyway" reads as one that cannot be edited.
|
||||
function editAction(leaf: DrillLeaf<PreviewTarget>) {
|
||||
const target = leaf.data
|
||||
if (target.type !== 'item') return undefined
|
||||
return {
|
||||
icon: Pen,
|
||||
label: 'Edit',
|
||||
title: `Edit ${workspaceItemDisplayPath(target.item)}`,
|
||||
run: () => onPick({ ...target, mode: 'edit' })
|
||||
}
|
||||
}
|
||||
|
||||
// What the row says about the item's deploy state, in the review dock's words so
|
||||
// the two never describe the same item differently.
|
||||
function stateBadge(leaf: DrillLeaf<PreviewTarget>) {
|
||||
const target = leaf.data
|
||||
if (target.type !== 'item') return undefined
|
||||
if (target.item.draftOnly) return { label: 'Draft only', color: 'indigo' as const }
|
||||
if (target.item.hasDraft) return { label: 'Draft', color: 'indigo' as const }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pageLeaf(p: (typeof PREVIEW_PAGES)[number]): DrillLeaf<PreviewTarget> {
|
||||
return {
|
||||
type: 'leaf',
|
||||
@@ -262,5 +295,7 @@ section or the flat layout.
|
||||
if (scope.length > 0) loader.ensureForScopeSegment(scope[0])
|
||||
}}
|
||||
onFilterChange={loader.onFilterChange}
|
||||
leafAction={editAction}
|
||||
leafBadge={stateBadge}
|
||||
{rootLoading}
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
darkMode,
|
||||
fullscreen = false,
|
||||
onNavigate,
|
||||
onSeeDetails,
|
||||
onLoad
|
||||
}: {
|
||||
tab: SessionPreviewTab
|
||||
@@ -57,6 +58,9 @@
|
||||
darkMode: boolean
|
||||
/** A link click inside a live editor re-points the active preview tab. */
|
||||
onNavigate: (item: WorkspaceItem) => void
|
||||
/** An editor's `Exit & see details` — re-points this tab at the item's deployed
|
||||
* view instead of navigating out of the session. */
|
||||
onSeeDetails: (item: WorkspaceItem) => void
|
||||
/** Iframe finished loading — the page reads back its observed location. */
|
||||
onLoad: (frame: HTMLIFrameElement) => void
|
||||
} = $props()
|
||||
@@ -65,6 +69,32 @@
|
||||
// any editable item (script/flow/raw app) or a pipeline folder mounts its own
|
||||
// live editor.
|
||||
const slot = $derived(resolvePreviewTab(tab.url))
|
||||
// The item this tab is on and the side of it its URL addresses, whichever side that is.
|
||||
const itemKind = $derived(
|
||||
slot.kind === 'editor' ? slot.editorKind : slot.kind === 'viewer' ? slot.viewerKind : undefined
|
||||
)
|
||||
const itemPath = $derived(
|
||||
slot.kind === 'editor' || slot.kind === 'viewer' ? slot.path : undefined
|
||||
)
|
||||
const mode = $derived(slot.kind === 'viewer' ? 'view' : 'edit')
|
||||
// A pinned deployed version, read from the tab URL rather than passed down: the tab
|
||||
// URL is the single record of where this tab points.
|
||||
const pinnedVersion = $derived(
|
||||
slot.kind === 'viewer'
|
||||
? (new URL(tab.url, 'http://x').searchParams.get('version') ?? undefined)
|
||||
: undefined
|
||||
)
|
||||
|
||||
// The path an editor reports is its deployed one, which a staged rename can make
|
||||
// differ from this tab's — so the view opens on what actually exists.
|
||||
const seeDetails = (e: { path: string }) =>
|
||||
onSeeDetails({
|
||||
path: e.path,
|
||||
summary: '',
|
||||
kind: itemKind === 'raw_app' ? 'app' : (itemKind as 'script' | 'flow'),
|
||||
raw_app: itemKind === 'raw_app'
|
||||
})
|
||||
|
||||
// Where inside the editor the tab was opened on ("open this flow step in a
|
||||
// session"). Only the in-process editors need it handed over — an iframe tab
|
||||
// loads the URL whole, params included.
|
||||
@@ -82,6 +112,7 @@
|
||||
)
|
||||
|
||||
let frame: HTMLIFrameElement | undefined = $state()
|
||||
let viewer: { refresh: () => void } | undefined = $state()
|
||||
|
||||
// Pages whose theme we mirror on live toggles. Regular apps are the only item
|
||||
// route that resolves to an iframe (scripts/flows/raw apps mount live editors)
|
||||
@@ -110,9 +141,15 @@
|
||||
export function reload() {
|
||||
// A live editor shares the runtime store the chat mutates, so generic chat
|
||||
// edits are already reflected — no reload needed. Deploys refresh it via
|
||||
// each editor view's onDeploy → runtime.syncPreviewWithDeployed. So only the
|
||||
// each editor view's onDeploy → runtime.itemDeployed. So only the
|
||||
// iframe fallback (a separate page) has to be told to refresh.
|
||||
if (slot.kind === 'editor') return
|
||||
// The viewer is the exception to that invariant: it reads the deployed item over
|
||||
// the API, so nothing about a store mutation reaches it and it has to refetch.
|
||||
if (slot.kind === 'viewer') {
|
||||
viewer?.refresh()
|
||||
return
|
||||
}
|
||||
if (slot.kind === 'pageitem') {
|
||||
pageItemReloadNonce++
|
||||
return
|
||||
@@ -264,63 +301,90 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if slot.kind === 'editor' && mounted && runtime}
|
||||
{#if itemKind && itemPath && mounted && runtime}
|
||||
<div
|
||||
bind:this={overlayHostEl}
|
||||
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
|
||||
aria-hidden={!active}
|
||||
>
|
||||
<!-- Dynamic imports: the live editors pull in the heaviest module graphs in
|
||||
the app (FlowBuilder, ScriptBuilder/Monaco, the raw-app editor, the
|
||||
pipeline graph). Loading them only when an editor tab first mounts keeps
|
||||
the /sessions route chunk thin, so entering session mode stays snappy. -->
|
||||
{#if slot.editorKind === 'flow'}
|
||||
{#await import('./FlowEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={slot.path}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
initialSelectedId={selectedId}
|
||||
/>
|
||||
{/await}
|
||||
{:else if slot.editorKind === 'script'}
|
||||
{#await import('./ScriptEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={slot.path}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
{fullscreen}
|
||||
/>
|
||||
{/await}
|
||||
{:else if slot.editorKind === 'pipeline'}
|
||||
{#await import('./PipelineEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default {runtime} path={slot.path} {workspaceId} {isActiveSession} {active} />
|
||||
{/await}
|
||||
{:else}
|
||||
{#await import('./RawAppEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={slot.path}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
/>
|
||||
{/await}
|
||||
<!-- Only the side the tab shows is mounted. A hidden editor would be a second editor on
|
||||
the item's one cell that tab dedupe, reading URLs, cannot see; a hidden side's drawers
|
||||
portal into this shared host and would stay over the other. -->
|
||||
{#if mode === 'edit'}
|
||||
<div class="absolute inset-0 flex flex-col min-h-0 bg-surface">
|
||||
<!-- Dynamic imports: the live editors pull in the heaviest module graphs in
|
||||
the app (FlowBuilder, ScriptBuilder/Monaco, the raw-app editor, the
|
||||
pipeline graph). Loading them only when an editor tab first mounts keeps
|
||||
the /sessions route chunk thin, so entering session mode stays snappy. -->
|
||||
{#if itemKind === 'flow'}
|
||||
{#await import('./FlowEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={itemPath}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
onSeeDetails={seeDetails}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
initialSelectedId={selectedId}
|
||||
/>
|
||||
{/await}
|
||||
{:else if itemKind === 'script'}
|
||||
{#await import('./ScriptEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={itemPath}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
onSeeDetails={seeDetails}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
{fullscreen}
|
||||
/>
|
||||
{/await}
|
||||
{:else if itemKind === 'pipeline'}
|
||||
{#await import('./PipelineEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default {runtime} path={itemPath} {workspaceId} {isActiveSession} {active} />
|
||||
{/await}
|
||||
{:else}
|
||||
{#await import('./RawAppEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={itemPath}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if itemKind !== 'pipeline'}
|
||||
<div class="absolute inset-0 flex flex-col min-h-0 bg-surface">
|
||||
{#await import('./ItemViewerView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
bind:this={viewer}
|
||||
{runtime}
|
||||
kind={itemKind}
|
||||
path={itemPath}
|
||||
version={pinnedVersion}
|
||||
{workspaceId}
|
||||
tabId={tab.id}
|
||||
container={overlayHostEl}
|
||||
active={active && !collapsed}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if slot.kind === 'pageitem' && mounted && runtime}
|
||||
|
||||
@@ -274,12 +274,7 @@
|
||||
{onNavigate}
|
||||
condensedHeader={true}
|
||||
onResetToDeployed={reloadDeployed}
|
||||
onDeploy={(e) => {
|
||||
// Sync the preview to deployed (raw apps deploy only from this editor).
|
||||
runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path)
|
||||
// Deploying clears the item's pending draft — refresh the Draft Count.
|
||||
invalidateWorkspaceDrafts(workspaceId)
|
||||
}}
|
||||
onDeploy={(e) => runtime.itemDeployed(workspaceId, 'raw_app', e.path)}
|
||||
defaultSidebarCollapsed
|
||||
sidebarStorageKey="raw-app-sidebar-collapsed-preview"
|
||||
defaultSplitWithPreview={false}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
path,
|
||||
workspaceId,
|
||||
onNavigate,
|
||||
onSeeDetails,
|
||||
fullscreen = false,
|
||||
isActiveSession = true,
|
||||
active = true
|
||||
@@ -23,6 +24,9 @@
|
||||
path: string
|
||||
workspaceId: string
|
||||
onNavigate?: (item: WorkspaceItem) => void
|
||||
/** `Exit & See details` — flips this tab to the item's deployed view rather than
|
||||
* navigating out of the session. */
|
||||
onSeeDetails?: (e: { path: string }) => void
|
||||
/** Preview panel is in full screen: collapse the test pane in the narrow
|
||||
* side-by-side layout, reopen it when there's room in full screen. */
|
||||
fullscreen?: boolean
|
||||
@@ -114,16 +118,13 @@
|
||||
condensedHeader={true}
|
||||
{diffDrawer}
|
||||
{onNavigate}
|
||||
{onSeeDetails}
|
||||
testPanelCollapsed={!fullscreen}
|
||||
onDeploy={(e) => {
|
||||
// Fires on every deploy (primary, "Deploy & Stay here", and lib — we
|
||||
// ignore e.stay since the session always stays). Toast, then sync the
|
||||
// preview to the deployed version.
|
||||
// ignore e.stay since the session always stays).
|
||||
sendUserToast('Deployed')
|
||||
runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path)
|
||||
// Deploying clears the item's pending draft — refresh the workspace
|
||||
// Draft Count so the session bar / compare page drop it immediately.
|
||||
invalidateWorkspaceDrafts(workspaceId)
|
||||
runtime.itemDeployed(workspaceId, 'script', e.path)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -194,7 +194,9 @@
|
||||
// Routed on the storage `path` — the edit route's key, which for a draft-only
|
||||
// item is its `draft_<uuid>` path, not the friendly `displayPath`.
|
||||
function previewTargetFor(item: DeployItem): PreviewTarget | undefined {
|
||||
return previewTargetForDeployKind(item.deployKind, item.path)
|
||||
// `done` means this session already deployed it, so there is a deployed
|
||||
// version to show even though the row was built from a draft-only item.
|
||||
return previewTargetForDeployKind(item.deployKind, item.path, item.draftOnly && !item.done)
|
||||
}
|
||||
|
||||
// The row's primary action is the preview; kinds the panel can't host fall back
|
||||
|
||||
@@ -151,18 +151,38 @@ export function stripBase(path: string): string {
|
||||
return p || '/'
|
||||
}
|
||||
|
||||
export type PreviewItemRoute = { kind: WorkspaceItemKind; raw_app: boolean; itemPath: string }
|
||||
/** Which side of an item a route addresses: `/edit/` builds it, `/get/` shows the
|
||||
* deployed version. The two are one tab in a session, switched by re-pointing its URL. */
|
||||
export type PreviewItemMode = 'edit' | 'view'
|
||||
|
||||
// Parse a preview URL/pathname into the workspace item it edits, or null for a
|
||||
export type PreviewItemRoute = {
|
||||
kind: WorkspaceItemKind
|
||||
raw_app: boolean
|
||||
itemPath: string
|
||||
mode: PreviewItemMode
|
||||
}
|
||||
|
||||
// Parse a preview URL/pathname into the workspace item it addresses, or null for a
|
||||
// non-item page (home, runs, …). Shared by the breadcrumb (drill segments) and
|
||||
// `previewRouter`'s tab resolver so both agree on what counts as an item route.
|
||||
export function parsePreviewItemRoute(fullPath: string): PreviewItemRoute | null {
|
||||
const p = stripBase(fullPath)
|
||||
const m = p.match(/^\/(scripts|flows|apps|apps_raw)\/(?:edit|get)\/(.+)$/)
|
||||
const m = p.match(/^\/(scripts|flows|apps|apps_raw)\/(edit|get)\/(.+)$/)
|
||||
if (!m) return null
|
||||
const itemPath = decodeURIComponent(m[2])
|
||||
if (m[1] === 'scripts') return { kind: 'script', raw_app: false, itemPath }
|
||||
if (m[1] === 'flows') return { kind: 'flow', raw_app: false, itemPath }
|
||||
if (m[1] === 'apps_raw') return { kind: 'app', raw_app: true, itemPath }
|
||||
return { kind: 'app', raw_app: false, itemPath }
|
||||
const itemPath = decodeURIComponent(m[3])
|
||||
const mode: PreviewItemMode = m[2] === 'get' ? 'view' : 'edit'
|
||||
if (m[1] === 'scripts') return { kind: 'script', raw_app: false, itemPath, mode }
|
||||
if (m[1] === 'flows') return { kind: 'flow', raw_app: false, itemPath, mode }
|
||||
if (m[1] === 'apps_raw') return { kind: 'app', raw_app: true, itemPath, mode }
|
||||
return { kind: 'app', raw_app: false, itemPath, mode }
|
||||
}
|
||||
|
||||
/** The script and hash of an editor opened on a historical version (`/scripts/edit/<path>?hash=`),
|
||||
* or null. The session's live editor holds only the current draft, so this route is the
|
||||
* standalone editor — which writes no draft in that mode — and never that editor's tab. */
|
||||
export function parseHistoricalScriptEdit(url: string): { path: string; hash: string } | null {
|
||||
const route = parsePreviewItemRoute(url)
|
||||
if (route?.kind !== 'script' || route.mode !== 'edit') return null
|
||||
const hash = new URL(url, 'http://x').searchParams.get('hash')
|
||||
return hash ? { path: route.itemPath, hash } : null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { toolReloadEffect, tabsToReload } from './previewReload'
|
||||
import { toolReloadEffect, tabsToReload, viewerTabsToReload } from './previewReload'
|
||||
import type { SessionPreviewTab } from './sessionState.svelte'
|
||||
import { pageItemUrl } from './previewPaths'
|
||||
|
||||
@@ -106,3 +106,66 @@ describe('tabsToReload', () => {
|
||||
expect(tabsToReload([navigated], new Set(['/schedules']))).toEqual([navigated])
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewerTabsToReload', () => {
|
||||
const viewTab: SessionPreviewTab = {
|
||||
id: 'v',
|
||||
url: '/scripts/get/f/foo/bar',
|
||||
loc: '/scripts/get/f/foo/bar'
|
||||
}
|
||||
const editTab: SessionPreviewTab = {
|
||||
id: 'e',
|
||||
url: '/flows/edit/f/foo/baz',
|
||||
loc: '/flows/edit/f/foo/baz'
|
||||
}
|
||||
const pageTab: SessionPreviewTab = { id: 'p', url: '/schedules', loc: '/schedules' }
|
||||
|
||||
it('returns the view-side tab whose item was deployed', () => {
|
||||
expect(viewerTabsToReload([viewTab, editTab, pageTab], new Set(['f/foo/bar']))).toEqual([
|
||||
viewTab
|
||||
])
|
||||
})
|
||||
|
||||
// The editor holds what the user is writing; refetching the deployed version over it
|
||||
// would discard that, and it self-syncs from the store the chat already mutated.
|
||||
it('never returns the edit side, even for the item that was deployed', () => {
|
||||
expect(viewerTabsToReload([editTab], new Set(['f/foo/baz']))).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a view tab on a different item alone', () => {
|
||||
expect(viewerTabsToReload([viewTab], new Set(['f/other/item']))).toEqual([])
|
||||
})
|
||||
|
||||
it('is empty when no items were deployed', () => {
|
||||
expect(viewerTabsToReload([viewTab, editTab], new Set())).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolReloadEffect deployed', () => {
|
||||
// A deploy is invisible to a View tab otherwise: it renders the deployed version over
|
||||
// the API and shares no store with the chat.
|
||||
it('names the item a deploy or delete changed, for the kinds a tab can view', () => {
|
||||
for (const type of ['script', 'flow', 'app']) {
|
||||
expect(toolReloadEffect('deploy_workspace_item', { type, path: 'u/me/x' }).deployed).toEqual([
|
||||
'u/me/x'
|
||||
])
|
||||
expect(toolReloadEffect('delete_workspace_item', { type, path: 'u/me/x' }).deployed).toEqual([
|
||||
'u/me/x'
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('names no item for a draft-only tool — nothing deployed changed', () => {
|
||||
expect(toolReloadEffect('write_script', { path: 'u/me/x' }).deployed).toEqual([])
|
||||
expect(
|
||||
toolReloadEffect('discard_local_draft', { type: 'script', path: 'u/me/x' }).deployed
|
||||
).toEqual([])
|
||||
expect(toolReloadEffect('rebase_draft', { type: 'flow', path: 'u/me/x' }).deployed).toEqual([])
|
||||
})
|
||||
|
||||
it('names no item for types that have a list page instead of a view side', () => {
|
||||
expect(
|
||||
toolReloadEffect('deploy_workspace_item', { type: 'schedule', path: 'u/me/s' }).deployed
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
pageItemListPath,
|
||||
pageItemUrl,
|
||||
parsePageItemRoute,
|
||||
parsePreviewItemRoute,
|
||||
stripBase,
|
||||
TRIGGER_PAGES,
|
||||
type PageItemRef,
|
||||
@@ -26,8 +27,14 @@ import {
|
||||
// in-process tabs: their editors read a draft only when they open, so a write to
|
||||
// one reloads its tab too. `items` names it when the tool's args do; without a
|
||||
// path, every tab of that kind reloads.
|
||||
export type ToolReloadEffect = { pages: string[]; items: PageItemRef[] }
|
||||
const NO_RELOAD: ToolReloadEffect = { pages: [], items: [] }
|
||||
//
|
||||
// `deployed` is the other exception. A tab can sit on the VIEW side of an item,
|
||||
// which renders the DEPLOYED version read over the API — it shares no store with
|
||||
// the chat, so a tool that changes what is deployed at a path has to name it here
|
||||
// or that tab keeps showing the previous deployment. Only tools that change the
|
||||
// deployed item belong in `deployed`; a draft write does not.
|
||||
export type ToolReloadEffect = { pages: string[]; items: PageItemRef[]; deployed: string[] }
|
||||
const NO_RELOAD: ToolReloadEffect = { pages: [], items: [], deployed: [] }
|
||||
|
||||
export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
|
||||
switch (name) {
|
||||
@@ -41,13 +48,19 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
|
||||
case 'write_variable':
|
||||
return withItem(['/variables'], itemRef('variable', args))
|
||||
case 'create_folder':
|
||||
return { pages: ['/folders'], items: [] }
|
||||
return { pages: ['/folders'], items: [], deployed: [] }
|
||||
// These two change what is deployed at a path, so a tab on that item's View side
|
||||
// is showing a version that no longer exists.
|
||||
case 'delete_workspace_item':
|
||||
case 'deploy_workspace_item':
|
||||
return {
|
||||
...withItem(pagesForItemType(args?.type, args), itemRef(args?.type, args)),
|
||||
deployed: deployedItems(args)
|
||||
}
|
||||
// Generic item tools carry a workspace-item `type`; refresh its list page
|
||||
// when it lives on one (schedule/resource/variable/trigger). script/flow/app
|
||||
// have their own live editor tab and no previewed list page → nothing.
|
||||
case 'delete_workspace_item':
|
||||
case 'discard_local_draft':
|
||||
case 'deploy_workspace_item':
|
||||
case 'rebase_draft':
|
||||
return withItem(
|
||||
pagesForItemType(args?.type, args),
|
||||
@@ -58,8 +71,16 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
|
||||
}
|
||||
}
|
||||
|
||||
// The item path a deploy/delete changed, when it is one of the kinds a preview tab can
|
||||
// show the deployed side of. Other types (schedule, resource, …) have list pages instead.
|
||||
function deployedItems(args: any): string[] {
|
||||
const type = args?.type
|
||||
if (type !== 'script' && type !== 'flow' && type !== 'app') return []
|
||||
return typeof args?.path === 'string' && args.path ? [args.path] : []
|
||||
}
|
||||
|
||||
function withItem(pages: string[], item: PageItemRef | undefined): ToolReloadEffect {
|
||||
return { pages, items: item && pages.length ? [item] : [] }
|
||||
return { pages, items: item && pages.length ? [item] : [], deployed: [] }
|
||||
}
|
||||
|
||||
function itemRef(type: unknown, args: any, triggerKind?: unknown): PageItemRef | undefined {
|
||||
@@ -114,3 +135,17 @@ export function tabsToReload(
|
||||
return !named || items.has(pageItemUrl(pageItem))
|
||||
})
|
||||
}
|
||||
|
||||
// The open tabs a deploy/delete of `items` should refresh: those sitting on the VIEW side
|
||||
// of one of those paths. The Edit side is excluded on purpose — it is the live editor that
|
||||
// self-syncs, and reloading it would throw away whatever is being edited.
|
||||
export function viewerTabsToReload(
|
||||
tabs: SessionPreviewTab[],
|
||||
items: ReadonlySet<string>
|
||||
): SessionPreviewTab[] {
|
||||
if (items.size === 0) return []
|
||||
return tabs.filter((t) => {
|
||||
const route = parsePreviewItemRoute(whereIs(t))
|
||||
return route?.mode === 'view' && items.has(route.itemPath)
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user