Merge branch 'main' into alexandre/win-2332-add-expiration-to-secret-variable-trigger-runnable-on

Main moved the variables page into VariablesList.svelte; the value expiry
indicator is carried over there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-09-24 16:52:01 +02:00
co-authored by Claude Opus 5.5
139 changed files with 9875 additions and 10914 deletions
+1 -1
View File
@@ -295,4 +295,4 @@ jobs:
# never reaches it. Pin the target dir (matching the cache step above) so
# its own tests run off this compile rather than a second bundled build.
(cd windmill-duckdb-ffi-internal && export CARGO_TARGET_DIR="$PWD/target" && ./build_dev.sh && cargo test --release -p windmill_duckdb_ffi_internal)
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline --all -- --nocapture --test-threads=10
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,smtp --all -- --nocapture --test-threads=10
@@ -98,9 +98,11 @@ export async function runEval<THelpers, TOutput>(
// Wrap tools to intercept fn calls for tracking.
// Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type
// but the actual callbacks passed at runtime will satisfy both interfaces.
// `fn` forwards `this`: the chat loop calls it on a per-iteration copy carrying the def the
// model was sent, and a tool reading `this.def` must see that one, as it does in production.
const wrappedTools = tools.map((tool) => ({
...tool,
fn: async (p: any) => {
fn: async function (this: unknown, p: any) {
toolCallsCount++;
toolsCalled.push(tool.def.function.name);
let argumentsText = "";
@@ -120,7 +122,7 @@ export async function runEval<THelpers, TOutput>(
toolName: tool.def.function.name,
argumentsText,
});
return tool.fn(p);
return tool.fn.call(this, p);
},
}));
+21 -11
View File
@@ -1112,9 +1112,9 @@
- opens the Kafka triggers page
- does not write, deploy, or delete anything
- id: global-openpage7-compare-review
- id: global-draft-does-not-open-compare
prompt: |-
Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it), then open the review page so I can look over the pending change and deploy it myself.
Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it). I want to look over the change before it gets deployed.
runtime:
maxTurns: 8
validate:
@@ -1122,6 +1122,24 @@
toolExpect:
requiredToolsUsed:
- write_script
forbiddenToolsUsed:
- open_page
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- creates the script draft without opening any workspace page
- does not deploy or delete anything
- id: global-openpage7-compare-fork-deploy
prompt: |-
I'm done with my changes in this fork and want to ship them to the parent workspace myself. Take me there.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- open_page
forbiddenToolsUsed:
- deploy_workspace_item
@@ -1131,17 +1149,9 @@
field: page
stringIncludesAnyOf:
- compare
# The eval chat is untracked (no modified-items mask), so the model must scope
# the review by passing the item it changed explicitly — an omitted mask would
# preselect every pending change in the workspace.
- tool: open_page
field: items
stringIncludesAnyOf:
- f/evals/global/compare_review_demo
skipJudge: true
judgeChecklist:
- creates the script draft, then opens the Compare & Deploy review page instead of deploying itself
- preselects only the created script on the review page
- opens Compare & Deploy only because the user explicitly wants to deploy the fork into its parent
- does not deploy or delete anything
- id: global-openpage8-runs-label-and-worker
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
"query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
"describe": {
"columns": [
{
@@ -236,5 +236,5 @@
true
]
},
"hash": "bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e"
"hash": "4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56"
}
+5
View File
@@ -15658,11 +15658,14 @@ dependencies = [
"prometheus",
"quick_cache",
"rand 0.9.5",
"rcgen",
"regex",
"reqwest 0.13.5",
"reqwest-middleware",
"reqwest-retry",
"rsa",
"rustls 0.23.35",
"rustls-native-certs 0.8.4",
"schemars 0.8.22",
"semver 1.0.28",
"serde",
@@ -15683,6 +15686,7 @@ dependencies = [
"tikv-jemalloc-ctl",
"tokio",
"tokio-postgres",
"tokio-rustls 0.26.5",
"tokio-stream",
"tokio-util",
"tonic 0.13.1",
@@ -15693,6 +15697,7 @@ dependencies = [
"url",
"urlencoding",
"uuid",
"webpki-roots 1.0.9",
"windmill-macros",
"windmill-parser",
"windmill-parser-py",
+1
View File
@@ -757,4 +757,5 @@ hyper-http-proxy = { version = "1", default-features = false, features = ["rustl
hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
rustls-native-certs = "0.8"
webpki-roots = "1"
rcgen = "0.13"
+1 -1
View File
@@ -1 +1 @@
8a1dd52a80d260b43541f9e18a9bb919133a7089
c38536b664c8e858193338bc1e54ddb4076da3b7
@@ -0,0 +1,6 @@
-- `created_by` tells this migration's row from one a user created by hand; a hub
-- sync updates in place and leaves it alone.
DELETE FROM resource_type
WHERE workspace_id = 'admins'
AND name = 'ai_instruction'
AND created_by = 'system';
@@ -0,0 +1,15 @@
-- Folder instructions for the AI chat: a markdown file resource whose
-- `value.content` applies to every item under the directory it lives in.
-- `format_extension` makes the resource editor render it as a plain .md file.
-- Seeded under 'admins' so every workspace sees it before the next hub sync.
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension, edited_at)
VALUES (
'admins',
'ai_instruction',
'{"type": "object", "properties": {"content": {"type": "string"}}}',
'Instructions for the AI chat that apply to every item under the folder this resource lives in, like an AGENTS.md: an ai_instruction at f/billing/AGENTS is given to the assistant whenever it works on something under f/billing/. The file body is the instructions it follows.',
'system',
'md',
now()
)
ON CONFLICT (workspace_id, name) DO NOTHING;
+1
View File
@@ -619,6 +619,7 @@ async fn edit_schedule(
dynamic_skip = $23,
email = $24,
edited_by = $25,
edited_at = now(),
permissioned_as = $26,
labels = COALESCE($27, labels)
WHERE path = $19 AND workspace_id = $20
+6 -1
View File
@@ -18,7 +18,7 @@ parquet = []
aws_auth = ["dep:aws-sdk-sts", "dep:aws-config"]
otel = ["dep:opentelemetry-semantic-conventions", "dep:opentelemetry-otlp", "dep:opentelemetry_sdk",
"dep:tracing-opentelemetry", "dep:opentelemetry-appender-tracing", "dep:tonic", "dep:opentelemetry"]
smtp = ["dep:mail-send"]
smtp = ["dep:mail-send", "dep:rustls", "dep:tokio-rustls", "dep:rustls-native-certs", "dep:webpki-roots"]
scoped_cache = []
cloud = []
dev_override = []
@@ -85,6 +85,10 @@ aws-sdk-rds = { workspace = true, optional = true }
indexmap.workspace = true
bytes.workspace = true
mail-send = { workspace = true, optional = true }
rustls = { workspace = true, optional = true }
tokio-rustls = { workspace = true, optional = true }
rustls-native-certs = { workspace = true, optional = true }
webpki-roots = { workspace = true, optional = true }
futures-core.workspace = true
async-stream.workspace = true
const_format.workspace = true
@@ -136,6 +140,7 @@ equivalent = "1.0.2"
[dev-dependencies]
# `test-util` is not part of tokio's `full`; it is what lets tests pause the clock.
tokio = { workspace = true, features = ["test-util"] }
rcgen.workspace = true
[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemalloc-ctl = { optional = true, workspace = true }
+2
View File
@@ -140,6 +140,7 @@ export const CONFIG_REFERENCE: ConfigOption[] = [
type: "object",
properties: {
relative_path: { type: "string", description: "Path to the codebase directory" },
extra_digest_paths: { type: "array", items: { type: "string" }, description: "Paths outside relative_path that scripts import, relative to wmill.yaml; hashed recursively" },
includes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to include in bundle" },
excludes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to exclude from bundle" },
format: { type: "string", enum: ["cjs", "esm"], description: "Bundle output format" },
@@ -159,6 +160,7 @@ export const CONFIG_REFERENCE: ConfigOption[] = [
example: [
"# codebases:",
'# - relative_path: ./shared # path to the codebase',
'# # extra_digest_paths: ["../packages/utils/src"] # imported paths outside relative_path, hashed recursively (skip node_modules/dist)',
'# includes: ["**/*.ts"] # files to include in bundle',
'# excludes: ["node_modules/**"] # files to exclude',
'# format: esm # bundle format: "cjs" or "esm"',
+16 -1
View File
@@ -61,7 +61,11 @@ import {
readConfigFile,
readEffectiveSyncBehavior,
} from "../../core/conf.ts";
import { SyncCodebase, listSyncCodebases } from "../../utils/codebase.ts";
import {
SyncCodebase,
listSyncCodebases,
uncoveredBundleInputs,
} from "../../utils/codebase.ts";
import { pollJobWithQueueLogging } from "../../utils/job_polling.ts";
import fs from "node:fs";
import { createTarBlob, type TarEntry } from "../../utils/tar.ts";
@@ -446,8 +450,19 @@ export async function handleFile(
target: format == "cjs" ? "node20.15.1" : "esnext",
banner: codebase.banner,
// ...(codebase.banner != null && { banner: codebase.banner }),
metafile: true,
});
const endTime = performance.now();
const uncovered = uncoveredBundleInputs(
codebase,
path,
Object.keys(out.metafile?.inputs ?? {})
);
if (uncovered.length > 0) {
log.warnAlways(
`${path} bundles files outside codebase ${codebase.relative_path}; edits to them won't be detected as changes unless listed in extra_digest_paths: ${uncovered.join(", ")}`
);
}
bundleContent = out.outputFiles[0].text;
outputFiles = out.outputFiles ?? [];
if (outputFiles.length == 0) {
+1
View File
@@ -115,6 +115,7 @@ export interface SyncOptions {
export interface Codebase {
relative_path: string;
extra_digest_paths?: string[];
includes?: string[];
excludes?: string[];
assets?: {
+49 -4
View File
@@ -1,6 +1,44 @@
import { Codebase, SyncOptions } from "../core/conf.ts";
import * as log from "../core/log.ts";
import { digestDir } from "./utils.ts";
import { digestDir, generateHash, generateHashFromBuffer } from "./utils.ts";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
// A bare string is accepted, like `includes`/`excludes` in findCodebase.
function extraDigestPaths(codebase: Codebase): string[] {
return ([] as string[]).concat(codebase.extra_digest_paths ?? []);
}
async function digestPath(p: string): Promise<string> {
let s;
try {
s = await stat(p);
} catch {
throw new Error(
`Codebase extra_digest_paths entry not found: ${p} (resolved to ${path.resolve(p)})`
);
}
return s.isDirectory()
? await digestDir(p, "")
: await generateHashFromBuffer(await readFile(p));
}
/** Bundle inputs that no codebase digest covers, so edits to them would not trigger a re-push. */
export function uncoveredBundleInputs(
codebase: Codebase,
entry: string,
inputs: string[]
): string[] {
const entryAbs = path.resolve(entry);
const roots = [codebase.relative_path, ...extraDigestPaths(codebase)]
.map((r) => path.resolve(r));
return inputs.filter((i) => {
// non-file namespaces ("<define:x>", "(disabled):x", "ns:path") and installed packages
if (/^[<(]|^[a-z-]{2,}:/i.test(i) || i.includes("node_modules")) return false;
const abs = path.resolve(i);
return abs != entryAbs && !roots.some((r) => abs == r || abs.startsWith(r + path.sep));
});
}
export type SyncCodebase = Codebase & {
getDigest: (forceTar?: boolean) => Promise<string>;
@@ -23,13 +61,20 @@ export function listSyncCodebases(options: SyncOptions): SyncCodebase[] {
forceTar?: boolean
) => {
if (_digest == undefined) {
_digest = await digestDir(
// Concurrent callers read `_digest` across these awaits: assign it only once complete.
let digest = await digestDir(
codebase.relative_path,
JSON.stringify(codebase)
);
if (codebase.format == "esm") {
_digest += ".esm";
const extra = extraDigestPaths(codebase);
if (extra.length > 0) {
const hashes = await Promise.all(extra.map(digestPath));
digest = await generateHash(digest + hashes.join(""));
}
if (codebase.format == "esm") {
digest += ".esm";
}
_digest = digest;
if (!alreadyPrinted) {
alreadyPrinted = true;
log.info(`Codebase ${codebase.relative_path}, digest: ${_digest}`);
+48
View File
@@ -0,0 +1,48 @@
import { expect, test } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
listSyncCodebases,
uncoveredBundleInputs,
} from "../src/utils/codebase.ts";
test("extra_digest_paths edits change the codebase digest", async () => {
const root = await mkdtemp(join(tmpdir(), "wmill-codebase-"));
await mkdir(join(root, "f"));
await mkdir(join(root, "packages/utils"), { recursive: true });
await writeFile(join(root, "f/main.ts"), "export * from '../packages/utils/a'");
await writeFile(join(root, "packages/utils/a.ts"), "export const a = 1");
const digest = () =>
listSyncCodebases({
codebases: [
{
relative_path: join(root, "f"),
extra_digest_paths: [join(root, "packages/utils")],
},
],
} as any)[0].getDigest();
try {
const before = await digest();
await writeFile(join(root, "packages/utils/a.ts"), "export const a = 2");
expect(await digest()).not.toBe(before);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("uncoveredBundleInputs reports only files outside the digested roots", () => {
const codebase = { relative_path: "f", extra_digest_paths: ["packages/a"] };
expect(
uncoveredBundleInputs(codebase, "g/entry.ts", [
"g/entry.ts",
"f/main.ts",
"packages/a/x.ts",
"packages/ab/y.ts",
"node_modules/pg/index.js",
"<define:process.env>",
])
).toEqual(["packages/ab/y.ts"]);
});
+7
View File
@@ -147,6 +147,13 @@
"type": "string",
"description": "Path to the codebase directory"
},
"extra_digest_paths": {
"type": "array",
"items": {
"type": "string"
},
"description": "Paths outside relative_path that scripts import, relative to wmill.yaml; hashed recursively"
},
"includes": {
"type": "array",
"items": {
+6
View File
@@ -46,6 +46,12 @@ Symbols, not line numbers, are cited: they drift less.
(`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs
`users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user
token stored as a secret, never `$WM_TOKEN`. Token scopes cannot narrow superadmin routes.
- **Write access to a folder steers its readers' AI chat.** An `ai_instruction` resource's body is
delivered to the global chat of everyone who can read the resource, with no opt-in, whenever the
chat touches a path under its folder (`copilot/chat/folderInstructions.ts`); the chat then acts
with that reader's permissions, outside the folder too. `ai_skill` bodies are likewise in play
for every reader until turned off. The resource ACL is the only control, so granting write on a
folder grants that influence.
- **A remote deploy token is a credential for another instance**, held per account and workspace
(`remote_deploy_token`, encrypted under the workspace key, re-keyed by `set_encryption_key`).
Its `email` references `password(email)` with `ON DELETE/UPDATE CASCADE`, so whatever deletes or
@@ -1,7 +1,6 @@
<script lang="ts">
import { WorkspaceService } from '$lib/gen'
import { createEventDispatcher } from 'svelte'
import { workspaceStore } from '$lib/stores'
import { Button } from './common'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
@@ -10,6 +9,9 @@
import autosize from '$lib/autosize'
import Label from './Label.svelte'
import TextInput from './text_input/TextInput.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const dispatch = createEventDispatcher()
@@ -35,7 +37,7 @@
async function updateVariable(): Promise<void> {
await WorkspaceService.setEnvironmentVariable({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
requestBody: {
value: value,
name: name
@@ -6,7 +6,7 @@
collectTransformRefs
} from './deployDependencies'
import { base } from '$lib/base'
import { enterpriseLicense, superadmin, workspaceStore } from '$lib/stores'
import { enterpriseLicense, superadmin } from '$lib/stores'
import {
AppService,
FlowService,
@@ -50,6 +50,9 @@
type OnBehalfOfDetails
} from './OnBehalfOfSelector.svelte'
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const dispatch = createEventDispatcher()
@@ -209,7 +212,12 @@
let source: string | undefined
let targetValue: string | undefined
try {
source = await getOnBehalfOf(dep.kind, dep.path, $workspaceStore!, additionalInformation)
source = await getOnBehalfOf(
dep.kind,
dep.path,
$operatingWorkspace!,
additionalInformation
)
} catch {}
try {
targetValue = await getOnBehalfOf(dep.kind, dep.path, target!, additionalInformation)
@@ -226,7 +234,10 @@
): Promise<{ kind: Kind; path: string }[]> {
async function rec(kind: Kind, path: string): Promise<{ kind: Kind; path: string }[]> {
if (kind == 'schedule') {
const schedule = await ScheduleService.getSchedule({ workspace: $workspaceStore!, path })
const schedule = await ScheduleService.getSchedule({
workspace: $operatingWorkspace!,
path
})
if (schedule.script_path && schedule.script_path != '') {
if (schedule.script_path) {
return [{ kind: 'script', path: schedule.script_path }]
@@ -237,7 +248,7 @@
return []
}
} else if (kind == 'flow') {
const flow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, path })
const flow = await FlowService.getFlowByPath({ workspace: $operatingWorkspace!, path })
return getAllModules(flow.value.modules, flow.value.failure_module).flatMap((x) => {
let result: { kind: Kind; path: string }[] = []
if (x.value.type == 'script' || x.value.type == 'rawscript' || x.value.type == 'flow') {
@@ -257,7 +268,7 @@
return result
})
} else if (kind == 'app') {
const app = await AppService.getAppByPath({ workspace: $workspaceStore!, path })
const app = await AppService.getAppByPath({ workspace: $operatingWorkspace!, path })
let result: { kind: Kind; path: string }[] = []
if (app.raw_app) {
const rawAppValue = app.value as { runnables?: Record<string, Runnable> }
@@ -285,7 +296,7 @@
}
return result
} else if (kind == 'resource') {
const res = await ResourceService.getResource({ workspace: $workspaceStore!, path })
const res = await ResourceService.getResource({ workspace: $operatingWorkspace!, path })
function recObj(obj: any): { kind: Kind; path: string }[] {
if (typeof obj == 'string') {
if (obj.startsWith('$var:')) {
@@ -310,12 +321,16 @@
]
} else if (kind == 'trigger') {
if (additionalInformation?.triggers) {
return getTriggerDependency(additionalInformation.triggers.kind, path, $workspaceStore!)
return getTriggerDependency(
additionalInformation.triggers.kind,
path,
$operatingWorkspace!
)
}
throw new Error('Missing trigger information')
} else if (kind == 'script') {
const imports = await WorkspaceService.getImports({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
importerPath: path
})
return imports.map((importedPath) => ({ kind: 'script' as Kind, path: importedPath }))
@@ -363,7 +378,7 @@
const result = await deployItem({
kind,
path,
workspaceFrom: $workspaceStore!,
workspaceFrom: $operatingWorkspace!,
workspaceTo: target!,
additionalInformation,
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind),
@@ -398,7 +413,7 @@
diffDrawer?.openDrawer()
let values = await Promise.all([
getItemValue(kind, path, workspaceToDeployTo!, additionalInformation),
getItemValue(kind, path, $workspaceStore!, additionalInformation)
getItemValue(kind, path, $operatingWorkspace!, additionalInformation)
])
diffDrawer?.setDiff({
mode: 'simple',
@@ -409,12 +424,12 @@
}
async function loadDestinations() {
const workspace = $workspaceStore!
const workspace = $operatingWorkspace!
const [deployTo, remote] = await Promise.all([
WorkspaceService.getDeployTo({ workspace }),
WorkspaceService.getRemoteDeployTarget({ workspace }).catch(() => undefined)
])
if (workspace !== $workspaceStore) return
if (workspace !== $operatingWorkspace) return
parentWorkspace = deployTo.deploy_to
remoteStatus = remote
// Keep the user's pick across a reload, but only while this workspace still has it: the
@@ -430,7 +445,7 @@
}
$effect(() => {
$workspaceStore && untrack(() => loadDestinations())
$operatingWorkspace && untrack(() => loadDestinations())
})
// Until the caller has a token for the remote instance there is nothing to deploy against, so
@@ -439,7 +454,7 @@
destination === 'parent'
? parentWorkspace
: isRemote && remoteStatus?.connection
? remoteDeployWorkspace($workspaceStore!, remoteStatus.connection)
? remoteDeployWorkspace($operatingWorkspace!, remoteStatus.connection)
: undefined
)
let destinationLabel = $derived(
@@ -560,12 +575,12 @@
{#if isRemote && remoteTarget}
<div class="mt-3">
<RemoteDeployConnect
workspace={$workspaceStore!}
workspace={$operatingWorkspace!}
target={remoteTarget}
connection={remoteStatus?.connection}
returnTo={kind === 'trigger'
? undefined
: `${base}/deploy/${kind}/${initialPath}?workspace=${encodeURIComponent($workspaceStore!)}`}
: `${base}/deploy/${kind}/${initialPath}?workspace=${encodeURIComponent($operatingWorkspace!)}`}
onChange={loadDestinations}
/>
</div>
@@ -1,6 +1,7 @@
<script lang="ts" module>
import { z } from 'zod'
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
import { useHostedPage } from '$lib/components/hostedPage'
import { formatDatePretty, parsePrettyDate, type IconType } from '$lib/utils'
export type FilterSchemaRec = Record<string, FilterSchema>
@@ -134,8 +135,9 @@
// URL lacks, so a value written to the instance is undone on the next sync. Going through
// urlFilter rather than straight to history keeps the search-param cells in step, so it
// does not matter whether a popstate follows.
const hosted = useHostedPage()
function seed(values: Partial<FilterInstanceRec<T>>) {
const sp = new URLSearchParams(window.location.search)
const sp = new URLSearchParams(hosted ? untrack(() => hosted.search) : window.location.search)
for (const [key, value] of Object.entries(values) as [string, unknown][]) {
if (value === undefined || value === null || sp.has(key)) continue
urlFilter[key] = value instanceof Date ? value.toISOString() : value
@@ -1,8 +1,11 @@
<script lang="ts">
import type { SyncResponse } from '$lib/git-sync'
let { previewResult } = $props<{
let { previewResult, heightClass = 'max-h-40' } = $props<{
previewResult: SyncResponse | undefined
/** Room the list gets before it scrolls. A host with a whole step to spare hands it
* `flex-1 min-h-0` so it takes what is left. */
heightClass?: string
}>()
let added = $derived(
@@ -16,7 +19,7 @@
)
</script>
<div class="border rounded p-2 text-xs max-h-40 overflow-y-auto bg-surface-secondary">
<div class="border rounded p-2 text-xs {heightClass} overflow-y-auto bg-surface-secondary">
<div class="font-semibold text-[11px] mb-1 text-primary">Preview of changes:</div>
{#if !added.length && !deleted.length && !edited.length}
+7 -28
View File
@@ -4,7 +4,6 @@
type AiAgent,
type FlowModule,
type InputTransform,
type JavascriptTransform,
type Job
} from '$lib/gen'
import { getScriptByPath } from '$lib/scripts'
@@ -20,7 +19,7 @@
type LinkedAgentDraft
} from './flows/linkedAgentDrafts'
import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils'
import { AGENT_HISTORY_KEYS } from './flows/agentFormFields'
import { agentTestInputTransforms } from './flows/agentFormFields'
import { sendUserToast } from '$lib/toast'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -175,37 +174,17 @@
// `args` spans the whole AI agent schema, so on a linked step it carries every brain key as
// undefined; overlaying those would shadow the draft's brain, so an inlined step takes only
// the inputs its form offers. A blank history input is unset, as on the step: an expression
// evaluating to nothing reads as an empty memory id, and the step's transform is stale.
const isBlank = (v: unknown) => v == undefined || v === '' || (Array.isArray(v) && !v.length)
const formKeys = (
draft ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) : Object.keys(args)
).filter(
(key) => !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key])
)
const stepTransforms = Object.fromEntries(
Object.entries((agentVal.input_transforms ?? {}) as Record<string, InputTransform>).filter(
([key]) => !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key])
)
)
// the inputs its form offers.
// The test form only covers the schema it was given, and for a standalone agent that may be
// the flow-local one (the agent editor shows the brain in its own form, not here). Take the
// brain from the module as authored and let the form's own keys win over it, so an edit made
// in the form after the test panel mounted is what runs. A linked agent needs none of this:
// the server reads its brain from the resource.
const inputTransforms: { [key: string]: JavascriptTransform | InputTransform } = {
...(agentVal.agent ? {} : stepTransforms),
...Object.fromEntries(
formKeys.map((key) => [
key,
{
expr: `flow_input.${key}`,
type: 'javascript'
}
])
)
}
const inputTransforms = agentTestInputTransforms(
agentVal.agent ? {} : ((agentVal.input_transforms ?? {}) as Record<string, InputTransform>),
args,
draft ? AGENT_FLOW_LOCAL_KEYS : Object.keys(args)
)
await jobLoader?.runFlowPreview(
args,
@@ -4,27 +4,55 @@
* workspace and the admin bypass in its popover. Renders nothing for operators, who have no edit
* affordance for it to explain.
*/
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { resource } from 'runed'
import {
canUserBypassRuleKind,
getActiveRulesetsForKind,
isRuleActive
canUserBypassRuleKindInRulesets,
fetchProtectionRulesForWorkspace,
getActiveRulesetsForKindInRulesets,
isRuleActiveInRulesets,
protectionRulesState
} from '$lib/workspaceProtectionRules.svelte'
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
import { devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { canCreateFork } from '$lib/utils/editInFork'
import { switchWorkspace } from '$lib/storeUtils'
import { Badge, Button } from './common'
import Popover from './meltComponents/Popover.svelte'
import Toggle from './Toggle.svelte'
import { GitFork, Lock, ShieldOff } from 'lucide-svelte'
import {
useOperatingWorkspace,
useOperatingUser
} from '$lib/components/operatingWorkspace.svelte'
let activeDeployRulesets = $derived(getActiveRulesetsForKind('DisableDirectDeployment'))
let canBypass = $derived(canUserBypassRuleKind('DisableDirectDeployment', $userStore))
let canonicalDev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces))
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
// The shared rules state holds the navigation workspace's rules, so a host acting on another
// workspace (an AI session's fork) has to ask for that one's.
const foreignRules = resource(
() => ($operatingWorkspace !== $workspaceStore ? $operatingWorkspace : undefined),
(workspace) => (workspace ? fetchProtectionRulesForWorkspace(workspace) : Promise.resolve([]))
)
let rulesets = $derived(
$operatingWorkspace === $workspaceStore
? (protectionRulesState.rulesets ?? [])
: (foreignRules.current ?? [])
)
let activeDeployRulesets = $derived(
getActiveRulesetsForKindInRulesets(rulesets, 'DisableDirectDeployment')
)
let canBypass = $derived(
canUserBypassRuleKindInRulesets(rulesets, 'DisableDirectDeployment', operatingUser.current)
)
let canonicalDev = $derived(findCanonicalDevWorkspace($operatingWorkspace, $userWorkspaces))
// Forking may itself be blocked by DisableWorkspaceForking, so only suggest it
// when the user can actually fork this workspace.
let canFork = $derived(canCreateFork($userStore))
let canFork = $derived(
!isRuleActiveInRulesets(rulesets, 'DisableWorkspaceForking') ||
canUserBypassRuleKindInRulesets(rulesets, 'DisableWorkspaceForking', operatingUser.current)
)
let editAdvice = $derived(
canFork
? 'You will need to either fork the workspace, or make your changes locally and submit a PR to an authorized user.'
@@ -34,7 +62,9 @@
// The toggle is only offered to a user who can bypass, but the answer can change under a
// workspace switch, so the checked flag alone never grants the edit.
let bypassActive = $derived(canBypass && overrideChecked)
let canEdit = $derived(!isRuleActive('DisableDirectDeployment') || bypassActive)
let canEdit = $derived(
!isRuleActiveInRulesets(rulesets, 'DisableDirectDeployment') || bypassActive
)
let {
onUpdateCanEditStatus = (value) => {}
@@ -55,7 +85,7 @@
)
</script>
{#if !$userStore?.operator && activeDeployRulesets.length > 0}
{#if !operatingUser.current?.operator && activeDeployRulesets.length > 0}
<div class="my-2">
<Popover
placement="bottom-start"
@@ -12,7 +12,6 @@
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
pageDrawerSessionSource,
setPageDrawerAnchor
} from './sessions/pageDrawerSession'
@@ -102,7 +101,6 @@
* dedicated editor elsewhere: the generic form would render its configuration field by field,
* and materialize a default into every one the value leaves out. */
export async function initEdit(p: string, opts?: { json?: boolean }): Promise<void> {
if (handOffPageDrawer(RESOURCES_PATH, p)) return
// A `close({ keepAnchor })` on an already-closed drawer emits no close event, so the flag
// would still be standing when the next drawer session ends and would swallow that one's
// anchor clear. Every session starts having to clear its own.
+24 -15
View File
@@ -10,7 +10,7 @@
} from '$lib/gen'
import type { Folder } from '$lib/gen/types.gen'
import { createEventDispatcher } from 'svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { Alert, Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import { sendUserToast } from '$lib/toast'
@@ -22,11 +22,22 @@
import Toggle from './Toggle.svelte'
import { Trash } from 'lucide-svelte'
import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud'
import {
useOperatingWorkspace,
useOperatingUser
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const dispatch = createEventDispatcher()
let restricted = $derived(
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
isDemoWorkspaceRestricted(
$operatingWorkspace,
operatingUser.current?.is_admin,
operatingUser.current?.is_super_admin
)
)
type Kind =
@@ -81,7 +92,7 @@
defaultPermsLabel = `Folder f/${folderName} permissions`
try {
const folder: Folder = await FolderService.getFolder({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
name: folderName
})
if (path !== currentPath) return
@@ -134,7 +145,7 @@
const currentPath = path
try {
const resource = await ResourceService.getResource({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path: currentPath
})
if (path !== currentPath) return
@@ -164,27 +175,27 @@
}
async function loadOwner() {
own = isOwner(path, $userStore!, $workspaceStore!)
own = isOwner(path, operatingUser.current!, $operatingWorkspace!)
}
async function loadAcls() {
acls = Object.entries(
await GranularAclService.getGranularAcls({ workspace: $workspaceStore!, path, kind })
await GranularAclService.getGranularAcls({ workspace: $operatingWorkspace!, path, kind })
)
}
async function loadGroups(): Promise<void> {
groups = await GroupService.listGroupNames({ workspace: $workspaceStore! })
groups = await GroupService.listGroupNames({ workspace: $operatingWorkspace! })
}
async function loadUsernames(): Promise<void> {
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! })
}
async function deleteAcl(owner: string) {
try {
await GranularAclService.removeGranularAcls({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path,
kind,
requestBody: { owner }
@@ -193,7 +204,7 @@
for (const varPath of linkedVarPaths) {
try {
await GranularAclService.removeGranularAcls({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path: varPath,
kind: 'variable',
requestBody: { owner }
@@ -212,7 +223,7 @@
async function addAcl(owner: string, write: boolean) {
await GranularAclService.addGranularAcls({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path,
kind,
requestBody: { owner, write }
@@ -221,7 +232,7 @@
for (const varPath of linkedVarPaths) {
try {
await GranularAclService.addGranularAcls({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path: varPath,
kind: 'variable',
requestBody: { owner, write }
@@ -251,9 +262,7 @@
</div>
{/if}
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-emphasis"
>Extra members ({acls?.length ?? 0})</span
>
<span class="text-sm font-semibold text-emphasis">Extra members ({acls?.length ?? 0})</span>
{#if linkedVarPaths.length > 0}
<div class="flex flex-col gap-1.5 p-3 border rounded bg-surface-secondary text-xs">
<Toggle
@@ -12,10 +12,13 @@
import autosize from '$lib/autosize'
import { ResourceService, VariableService } from '$lib/gen'
import { oauthStore, workspaceStore } from '$lib/stores'
import { oauthStore } from '$lib/stores'
import Password from './Password.svelte'
import { createEventDispatcher } from 'svelte'
import HighlightTheme from './HighlightTheme.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
let drawer: Drawer | undefined = $state()
let token: undefined | string = $state(undefined)
@@ -78,7 +81,7 @@
async function save() {
if (!path) return
await VariableService.createVariable({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
requestBody: {
path,
value: password,
@@ -89,7 +92,7 @@
})
await ResourceService.createResource({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
requestBody: {
resource_type: 'postgresql',
path,
+11 -5
View File
@@ -93,6 +93,8 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- Keeps the sr-only checkbox's absolute box on the toggle; it does not size the knob,
which resolves against the track. -->
<div
class="relative"
onclick={stopPropagation(bubble('click'))}
@@ -116,21 +118,25 @@
dispatch('change', !!checked)
})}
/>
<!-- Knob offsets resolve against the track's padding box, so the track stays
relative. Whole px and 2px of inset: at 1x a track on a fractional x blurs its
edge over a whole pixel, which swallows a thinner gap.
knob = h - 6, checked translate = w - h. -->
<div
class={classNames(
"transition-all bg-surface-sunken rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:bg-surface after:border-white after:border after:rounded-full after:transition-all items-center",
"relative transition-all bg-surface-sunken rounded-full peer peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-surface after:border-white after:border after:rounded-full after:transition-all items-center",
color == 'red'
? 'peer-checked:bg-red-600'
: color == 'blue'
? 'peer-checked:bg-blue-400 '
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-900',
size === 'md'
? 'w-11 h-6 after:top-0.5 after:left-[2px] after:h-5 after:w-5'
? 'w-[44px] h-[24px] after:h-[18px] after:w-[18px] peer-checked:after:translate-x-[20px]'
: size === 'sm'
? 'w-9 h-5 after:top-0.5 after:left-[2px] after:h-4 after:w-4'
? 'w-[36px] h-[20px] after:h-[14px] after:w-[14px] peer-checked:after:translate-x-[16px]'
: size === '2xs'
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3',
? 'w-[20px] h-[12px] after:h-[6px] after:w-[6px] peer-checked:after:translate-x-[8px]'
: 'w-[28px] h-[16px] after:h-[10px] after:w-[10px] peer-checked:after:translate-x-[12px]',
inputBorderClass()
)}
></div>
@@ -7,7 +7,6 @@
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
pageDrawerSessionSource,
setPageDrawerAnchor
} from './sessions/pageDrawerSession'
@@ -394,7 +393,6 @@
}
export function editVariable(edit_path: string): void {
if (handOffPageDrawer(VARIABLES_PATH, edit_path)) return
reset()
editPath = edit_path
selected = curWs!
@@ -71,6 +71,16 @@
}
}
// The entry follows the prop for as long as the overlay is open: a dialog opened before
// the AI chat still has to rise above it when the chat is opened underneath it.
$effect(() => {
const wanted = open && minZIndex > 0 ? minZIndex : undefined
untrack(() => {
if (wanted !== undefined) minZIndexEntries[id] = wanted
else delete minZIndexEntries[id]
})
})
// A disposable can be unmounted while still open, by an ancestor that tears its whole
// subtree down. Its id would then sit on the stack forever, and since the topmost entry
// arbitrates Escape, every overlay opened afterwards would stop answering it.
@@ -84,10 +94,10 @@
offset = initialOffset
if (stack.val.includes(id)) {
stack.val = stack.val.filter((drawer) => drawer !== id)
if (minZIndex > 0) {
delete minZIndexEntries[id]
}
}
// Unconditionally: `minZIndex` may have fallen to 0 since the entry was written (the
// chat closed), and a leftover entry keeps every later overlay elevated for good.
delete minZIndexEntries[id]
}
export function isOpen() {
@@ -10,6 +10,9 @@
import { fade } from 'svelte/transition'
import { zIndexes } from '$lib/zIndexes'
import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte'
import Disposable from '../drawer/Disposable.svelte'
import { overlayHostActive, setTopmostSurface } from '../overlayHost.svelte'
import { untrack } from 'svelte'
interface Props {
title: string
@@ -30,6 +33,9 @@
* window, so a stacked pair would both close on one press; set it
* false on the underlying modal while its child is up. */
closeOnEscape?: boolean
/** Blocks every way out — the header ✕, Escape and an outside click — for a dialog
* holding work in flight that a dismissal would orphan. */
preventDismiss?: boolean
/** Wider side padding and a lighter title, for a dialog whose body is a form rather
* than a list. Opt-in: every other Modal2 keeps the padding and heading it had. */
formStyling?: boolean
@@ -51,10 +57,11 @@
contentClasses = '',
closeOnOutsideClick = true,
closeOnEscape = true,
preventDismiss = false,
formStyling = false,
headerLeft,
headerRight,
children
children: body
}: Props = $props()
const widthMap = {
@@ -84,8 +91,30 @@
isOpen = true
}
// Registered in the disposable stack so a drawer opened from inside the dialog (a
// resource picker's editor) stacks above it, and so Escape and outside clicks aimed
// at that drawer do not close the dialog underneath.
let disposable: Disposable | undefined = $state(undefined)
const hostActive = overlayHostActive()
setTopmostSurface(() => disposable?.isTopmost() ?? true)
$effect(() => {
isOpen
untrack(() => (isOpen ? disposable?.openDrawer() : disposable?.closeDrawer()))
})
/** Whether this dialog was the overlay on top when the interaction started. `clickOutside`
* awaits its async `exclude` before reporting, and an overlay above closes itself in that
* gap — by the time the report lands, the dialog looks topmost and would close with it. */
let topmostAtPress = true
function snapshotTopmost() {
topmostAtPress = disposable?.isTopmost() ?? true
}
function handleKeyDown(event: KeyboardEvent) {
if (!isOpen || !closeOnEscape) return
if (!isOpen || !closeOnEscape || preventDismiss) return
// Hidden hosts stay mounted and still receive window keys — see overlayHost.
if (!hostActive()) return
if (!(disposable?.isTopmost() ?? true)) return
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
@@ -97,79 +126,94 @@
return fade(node, { duration: 200 })
}
// Elevate above the AI chat panel (zIndexes.aiChat) while chat is open so
// the dialog isn't hidden behind it; otherwise keep the default modal
// stacking just above disposables (zIndexes.disposables).
const overlayZIndex = $derived(
chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10
)
// Elevate above the AI chat panel while chat is open so the dialog isn't hidden behind it.
const minZIndex = $derived(chatState.size > 0 ? zIndexes.aiChat + 1 : 0)
</script>
<svelte:window onkeydown={handleKeyDown} />
<svelte:window
onkeydown={handleKeyDown}
onpointerdowncapture={snapshotTopmost}
onclickcapture={(e) => {
// A keyboard-activated control emits a click with no pointer event before it (`detail`
// 0), so the pointerdown snapshot would be whatever a previous interaction left.
if (e.detail === 0) snapshotTopmost()
}}
/>
{#if isOpen}
<Portal name="always-mounted" {target}>
<div
class={'fixed top-0 bottom-0 left-0 right-0 transition-all overflow-auto bg-black bg-opacity-60 w-full h-full'}
style="z-index: {overlayZIndex}"
transition:fadeFast|local
>
<div class="flex min-h-full items-center justify-center p-8">
<Disposable bind:open={isOpen} bind:this={disposable} preventEscape {minZIndex}>
{#snippet children({ zIndex })}
{#if isOpen}
<Portal name="always-mounted" {target}>
<div
style={`width: ${widthMap[fixedWidth]}; ${
heightMap[fixedHeight] ? `height: ${heightMap[fixedHeight]}; ` : ''
}${css?.popup?.style || ''}`}
class={twMerge(
'max-h-screen-80 max-w-screen-80 rounded-lg relative bg-surface',
formStyling ? 'py-4 px-6' : 'p-4',
css?.popup?.class,
'wm-modal-form-popup'
)}
use:clickOutside={{
onClickOutside: () => closeOnOutsideClick && close()
}}
class={'fixed top-0 bottom-0 left-0 right-0 transition-all overflow-auto bg-black bg-opacity-60 w-full h-full'}
style="z-index: {zIndex}"
transition:fadeFast|local
>
<List gap="md">
<div class="flex w-full">
<List horizontal justify="between">
<h3 class={formStyling ? 'font-semibold' : undefined}>{title}</h3>
<div class="grow w-min-0">
<div class="flex min-h-full items-center justify-center p-8">
<div
style={`width: ${widthMap[fixedWidth]}; ${
heightMap[fixedHeight] ? `height: ${heightMap[fixedHeight]}; ` : ''
}${css?.popup?.style || ''}`}
class={twMerge(
'max-h-screen-80 max-w-screen-80 rounded-lg relative bg-surface',
formStyling ? 'py-4 px-6' : 'p-4',
css?.popup?.class,
'wm-modal-form-popup'
)}
use:clickOutside={{
onClickOutside: () =>
closeOnOutsideClick && !preventDismiss && topmostAtPress && close(),
// A dropdown opened from inside the dialog portals its menu out of it, so a
// click on one of its items lands outside this node and would close the
// dialog under the menu.
exclude: async () =>
Array.from(document.querySelectorAll('[data-menu]')) as HTMLElement[]
}}
>
<List gap="md">
<div class="flex w-full">
<List horizontal justify="between">
<div class="min-w-0 grow">
{@render headerLeft?.()}
</div>
<div class="min-w-0 grow-0 justify-end">
<List horizontal justify="end">
{@render headerRight?.()}
<div class="w-8">
<button
id="modal-close-button"
onclick={() => {
close()
}}
class="hover:bg-surface-hover rounded-full w-8 h-8 flex items-center justify-center transition-all"
>
<X class="text-primary " />
</button>
<h3 class={formStyling ? 'font-semibold' : undefined}>{title}</h3>
<div class="grow w-min-0">
<List horizontal justify="between">
<div class="min-w-0 grow">
{@render headerLeft?.()}
</div>
<div class="min-w-0 grow-0 justify-end">
<List horizontal justify="end">
{@render headerRight?.()}
<div class="w-8">
<button
id="modal-close-button"
disabled={preventDismiss}
onclick={() => {
close()
}}
class="hover:bg-surface-hover rounded-full w-8 h-8 flex items-center justify-center transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
<X class="text-primary " />
</button>
</div>
</List>
</div>
</List>
</div>
</List>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full flex grow min-h-0 {contentClasses}"
onclick={stopPropagation(() => {})}
>
{@render body?.()}
</div>
</List>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full flex grow min-h-0 {contentClasses}"
onclick={stopPropagation(() => {})}
>
{@render children?.()}
</div>
</List>
</div>
</div>
</div>
</div>
</Portal>
{/if}
</Portal>
{/if}
{/snippet}
</Disposable>
@@ -12,6 +12,9 @@
allowStepNavigation?: boolean
/** Compact variant, for steering a dialog rather than a full page. */
small?: boolean
/** Steps this run does not go through, greyed out and not clickable — a step the
* chosen path skips rather than one not reached yet. */
disabledIndices?: number[]
}
let {
@@ -21,7 +24,8 @@
statusByStep = [],
hasValidations = false,
allowStepNavigation = false,
small = false
small = false,
disabledIndices = []
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -75,15 +79,20 @@
{#each tabs ?? [] as step, index}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
{@const disabled = disabledIndices.includes(index)}
<li
class={classNames(
small
? 'flex items-center gap-1.5 px-1.5 py-0.5 hover:bg-gray-1200 rounded-md m-0.5'
: 'flex items-center gap-2 px-2 py-1 hover:bg-gray-1200 rounded-md m-0.5',
index <= maxReachedIndex || allowStepNavigation ? 'cursor-pointer' : 'cursor-not-allowed'
disabled
? 'cursor-not-allowed opacity-50'
: index <= maxReachedIndex || allowStepNavigation
? 'cursor-pointer'
: 'cursor-not-allowed'
)}
onclick={() => {
dispatch('click', { index })
if (!disabled) dispatch('click', { index })
}}
>
{#if statusByStep[index] === 'pending'}
@@ -94,7 +103,9 @@
small
? 'h-4 w-4 rounded-full flex items-center justify-center text-2xs'
: 'h-6 w-6 rounded-full flex items-center justify-center text-xs',
getStepColor(index, selectedIndex, statusByStep, maxReachedIndex)
disabled
? 'bg-gray-200'
: getStepColor(index, selectedIndex, statusByStep, maxReachedIndex)
)}
class:font-bold={selectedIndex === index}
>
@@ -105,7 +116,11 @@
<span
class={classNames(
'hidden sm:block',
selectedIndex === index ? 'font-semibold text-primary' : 'font-normal text-primary'
disabled
? 'font-normal text-disabled'
: selectedIndex === index
? 'font-semibold text-primary'
: 'font-normal text-primary'
)}
>
{step}
@@ -276,16 +276,22 @@
// run's last answer carries the only link to that run. Keyed on the answer's own job (a step
// label repeats when a step runs in a loop), and on answers rather than the next row: a
// flow's tool rows have their own job, and a stopped turn can end on a tool row.
// None in a turn still running, paused on the user included (their reply lands as a tool
// result, so a row shown during the pause would vanish again): which answer ends the turn is
// unknown until it does, and one followed by a tool call would leave the row's blank gap above
// it. A manual compaction loads without a turn of its own, so the last turn keeps its row.
const showsAnswerActions = $derived.by(() => {
const shows: boolean[] = new Array(messages.length).fill(false)
const answeredLater = new Set<string | undefined>()
let inRunningTurn = chatHost.loading && !chatHost.compacting
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role === 'user' || message.role === 'summary') {
answeredLater.clear()
inRunningTurn = false
} else if (message.role === 'assistant' && message.content) {
const run = message.jobId ?? message.stepName
shows[i] = !answeredLater.has(run)
shows[i] = !inRunningTurn && !answeredLater.has(run)
answeredLater.add(run)
}
}
@@ -125,6 +125,14 @@ import {
isWebSearchEnabledForProvider
} from '$lib/aiStore'
import type { WorkspaceMutationTarget } from './workspaceTools'
import { resolveSessionAccess } from './global/sessionAccess'
import type { SessionAccess } from './sessionCapabilities'
import { filterSessionTools } from './global/sessionToolset'
import {
listFolderInstructions,
type FolderInstruction,
type FolderInstructionsDelivery
} from './folderInstructions'
import {
loadWorkspaceSkills,
resolveGlobalPromptIdentity,
@@ -696,6 +704,10 @@ export class AIChatManager implements ChatViewHost {
// needs the preview pane; the global side-panel chat leaves it false. Reactive because
// `planModeAvailable` derives from it.
isSessionChat = $state(false)
// Undefined until a send or the assistant settings modal resolves it. Reactive: both
// tool views derive from it.
private sessionAccess = $state<SessionAccess | undefined>(undefined)
private sessionAccessGeneration = 0
autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode))
autoAcceptEditsActive = $derived(
this.autoAcceptEditsAvailable &&
@@ -719,15 +731,21 @@ export class AIChatManager implements ChatViewHost {
})
/** The sources the current mode assembled, concatenated. Private, because neither view
* over it is this list: a consumer reading it would advertise a tool set no request ever
* carries. */
#assembledTools = $state<Tool<any>[]>([])
* carries. Raw: every write replaces the list, and a deep proxy would reach the tool
* defs, which the session filter copies with `structuredClone` — that throws on a proxy. */
#assembledTools = $state.raw<Tool<any>[]>([])
/** Both views narrow the same way — concatenate every source, then filter — so a tool
* reaching either one can never arrive unfiltered. A session withholds what its user's
* capabilities do not cover; elsewhere `sessionAccess` is unset and nothing is dropped. */
#shipped = (planTools: Tool<any>[]): Tool<any>[] =>
filterSessionTools([...this.#assembledTools, ...planTools], this.sessionAccess)
/** What the request carries: the assembled tools plus the plan-mode transition the current
* posture offers. Read by the request path and by the YOLO disclosure. */
tools: Tool<any>[] = $derived([...this.#assembledTools, ...this.planMode.tools])
tools: Tool<any>[] = $derived(this.#shipped(this.planMode.tools))
/** What the assistant can call in this session, posture-independent: both plan-mode
* transitions, whichever one is offered right now. A reference answer, so flipping the
* autonomy picker must not change it. */
availableTools: Tool<any>[] = $derived([...this.#assembledTools, ...this.planMode.availableTools])
availableTools: Tool<any>[] = $derived(this.#shipped(this.planMode.availableTools))
helpers = $state<any | undefined>(undefined)
scriptEditorOptions = $state<ScriptOptions | undefined>(undefined)
@@ -1336,6 +1354,13 @@ export class AIChatManager implements ChatViewHost {
globalSkills = $state<AiSkillListItem[]>([])
private globalSkillsRefreshId = 0
// The `ai_instruction` resources readable in the operating workspace, loaded
// alongside skills. The prompt names their folders; the tool loop delivers each
// one's body the first time a call touches its folder, recording which call did.
private globalFolderInstructions: FolderInstruction[] = []
private globalFolderInstructionsRefreshId = 0
private folderInstructionsDeliveredBy = new Map<string, FolderInstructionsDelivery>()
// External MCP servers the user connected (resources of type `mcp`). Loaded
// asynchronously alongside skills; the MCP tools are only registered when
// this is non-empty, so a workspace with no connection pays no schema cost
@@ -1427,10 +1452,12 @@ export class AIChatManager implements ChatViewHost {
typeof this.systemMessage.content === 'string'
? this.systemMessage.content.length / tokenPerCharacter
: 0
const tools = this.tools
// Counts each tool's shared `def`, not the per-chat one `schemaFor` builds at send time,
// so a flow's input schema is missed. Accepted: the estimate only stands in until the
// provider reports usage, and the compaction trigger's headroom absorbs the gap.
const toolTokens =
this.tools.length > 0
? JSON.stringify(this.tools.map((t) => t.def)).length / tokenPerCharacter
: 0
tools.length > 0 ? JSON.stringify(tools.map((t) => t.def)).length / tokenPerCharacter : 0
return systemTokens + toolTokens
}
@@ -2332,8 +2359,9 @@ export class AIChatManager implements ChatViewHost {
}
) {
if (!isAIModeVisible(mode)) return
// A session chat is GLOBAL for its whole life, and the plan gate reads that mode: moving
// it lifts the gate on a session the user still has set to Plan.
// A session chat is GLOBAL for its whole life, and two things read that mode: moving it
// lifts the plan gate on a session the user still has set to Plan, and hands the
// capability filter tools that declare no `requires`, so it fails closed to nothing.
if (this.isSessionChat && mode !== AIMode.GLOBAL) {
console.error(`Refusing to move a session chat to ${mode} mode: sessions are GLOBAL-only.`)
return
@@ -2412,6 +2440,7 @@ export class AIChatManager implements ChatViewHost {
this.configureGlobalMode()
void this.refreshGlobalIdentity()
void this.refreshGlobalSkills()
void this.refreshGlobalFolderInstructions()
void this.refreshMcpServers()
} else if (mode === AIMode.APP) {
const customPrompt = getCombinedCustomPrompt(mode)
@@ -2435,13 +2464,13 @@ export class AIChatManager implements ChatViewHost {
sessionId: this.sessionId,
operatingWorkspace: this.operatingWorkspace,
artifacts: this.artifacts,
access: this.sessionAccess,
getChatId: () => this.historyManager.getCurrentChatId(),
openArtifact: this.openArtifact
}
: {}),
testActiveFlow: async (storagePath: string, args?: Record<string, any>, memoryId?: string) =>
this.flowEditorFor(storagePath)?.testFlow(args, memoryId),
getModifiedItems: () => (this.modifiedItems ? [...this.modifiedItems] : undefined),
attachedFiles: this.attachedFiles,
getUserInstructions: () => getUserCustomPrompts()[AIMode.GLOBAL] ?? '',
setUserInstructions: (instructions: string) => {
@@ -2465,7 +2494,9 @@ export class AIChatManager implements ChatViewHost {
previewTools: this.isSessionChat,
user: this.globalIdentity,
skills: this.globalSkills,
folderInstructions: this.globalFolderInstructions,
mcpServers: this.mcpServers,
access: this.sessionAccess,
sessionContext: this.sessionContextResolver?.(),
pipelineContext: this.pipelineAiChatHelpers?.getPipelineContext()
})
@@ -2489,6 +2520,25 @@ export class AIChatManager implements ChatViewHost {
}
}
// Same shape as refreshGlobalSkills.
refreshGlobalFolderInstructions = async (workspace = this.operatingWorkspace ?? '') => {
const refreshId = ++this.globalFolderInstructionsRefreshId
let instructions: FolderInstruction[] = []
try {
instructions = await listFolderInstructions(workspace)
} catch (e) {
console.error('Failed to load folder instructions', e)
}
if (refreshId !== this.globalFolderInstructionsRefreshId) {
return
}
this.globalFolderInstructions =
workspace === (this.operatingWorkspace ?? '') ? instructions : []
if (this.mode === AIMode.GLOBAL) {
this.configureGlobalMode()
}
}
// Same shape as refreshGlobalSkills. An identity that resolves after the operating
// workspace moved describes the workspace left behind, so it is dropped, not installed.
refreshGlobalIdentity = async (workspace = this.operatingWorkspace ?? '') => {
@@ -2540,6 +2590,23 @@ export class AIChatManager implements ChatViewHost {
this.systemMessage = { ...target, content: `${target.content}\n\n${section}` }
}
// Same shape as refreshGlobalSkills. Re-resolved per send rather than once for the
// session's life: the operating workspace can change between sends, and a transient
// failure resolves fail-open, so the next message re-asks rather than keeping that answer.
refreshSessionAccess = async (workspace = this.operatingWorkspace ?? '') => {
if (!this.isSessionChat || !workspace) {
this.sessionAccess = undefined
return
}
const generation = ++this.sessionAccessGeneration
const access = await resolveSessionAccess(workspace)
if (generation !== this.sessionAccessGeneration) return
this.sessionAccess = workspace === (this.operatingWorkspace ?? '') ? access : undefined
if (this.mode === AIMode.GLOBAL) {
this.configureGlobalMode()
}
}
// Rebuild the GLOBAL system message in place so an updated user instruction (persisted by
// the update_user_instructions tool) is picked up on the next chat-loop iteration, which
// re-reads this.systemMessage via a getter.
@@ -3043,13 +3110,8 @@ export class AIChatManager implements ChatViewHost {
console.error('Failed to record AI usage', e)
}
},
onBeforeIteration: async (tools, _helpers, modelProvider) => {
onBeforeIteration: async (modelProvider) => {
this.lastIterationModel = modelProvider
for (const tool of tools) {
if (tool.setSchema) {
await tool.setSchema(this.helpers)
}
}
}
})
if (this.isSessionChat && this.sessionId && result.tokenUsage.total > 0) {
@@ -3553,15 +3615,18 @@ export class AIChatManager implements ChatViewHost {
return false
}
}
// Session chats commit their workspace in beforeSend; the identity, skills and
// MCP servers must all match the committed workspace before the system prompt is
// sent. Settling them here rather than mid-turn also keeps the prompt — the
// cached prefix of every iteration — stable for the whole request.
// Session chats commit their workspace in beforeSend; the identity, skills, folder
// instructions, MCP servers and capabilities must all match the committed workspace
// before the system prompt is sent. Settling them here rather than mid-turn also
// keeps the prompt — the cached prefix of every iteration — stable for the whole
// request.
if (this.mode === AIMode.GLOBAL) {
await Promise.all([
this.refreshGlobalIdentity(this.operatingWorkspace ?? ''),
this.refreshGlobalSkills(this.operatingWorkspace ?? ''),
this.refreshMcpServers(this.operatingWorkspace ?? '')
this.refreshGlobalFolderInstructions(this.operatingWorkspace ?? ''),
this.refreshMcpServers(this.operatingWorkspace ?? ''),
this.refreshSessionAccess(this.operatingWorkspace ?? '')
])
}
// Stop/Escape during the beforeSend pre-flight aborted this send before any
@@ -4019,7 +4084,11 @@ export class AIChatManager implements ChatViewHost {
? {
onJobStarted: (job) => this.registerJob(job),
onJobStatus: (jobId, update) => this.updateJob(jobId, update),
onJobDetached: (jobId) => this.markJobDetached(jobId)
onJobDetached: (jobId) => this.markJobDetached(jobId),
folderInstructions: {
list: () => this.globalFolderInstructions,
deliveredBy: this.folderInstructionsDeliveredBy
}
}
: {}),
removeToolStatus: (id) => {
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { writable } from 'svelte/store'
import type { FlowAIChatHelpers } from './flow/core'
import type { PipelineAIChatHelpers } from './pipeline/core'
@@ -1679,7 +1679,7 @@ describe('AIChatManager queued messages', () => {
mocks.tryGetCurrentModel.mockReturnValue(a)
mocks.runChatLoop.mockImplementation(async (config: any) => {
// an iteration starts on B...
await config.onBeforeIteration?.([], config.helpers, b)
await config.onBeforeIteration?.(b)
// ...the user switches to C while B's request is in flight...
mocks.getCurrentModel.mockReturnValue(c)
mocks.tryGetCurrentModel.mockReturnValue(c)
@@ -1792,7 +1792,7 @@ describe('AIChatManager queued messages', () => {
// mid-loop switch to a model the deny-list doesn't know...
mocks.getCurrentModel.mockReturnValue(unlistedBlind)
mocks.tryGetCurrentModel.mockReturnValue(unlistedBlind)
await config.onBeforeIteration?.([], config.helpers, unlistedBlind)
await config.onBeforeIteration?.(unlistedBlind)
// ...its request carries the images and the provider rejects them
throw new Error('400 this model does not support image input')
})
@@ -4565,6 +4565,7 @@ describe('AIChatManager tool views', () => {
vi.clearAllMocks()
mocks.getCurrentModel.mockReturnValue({ provider: 'openai', model: 'gpt-4o' })
mocks.tryGetCurrentModel.mockReturnValue({ provider: 'openai', model: 'gpt-4o' })
clearWorkspaceRoleCache()
})
// Every mode assigns the private base separately. A missed site leaves it empty on a fresh
@@ -4584,6 +4585,73 @@ describe('AIChatManager tool views', () => {
expect(manager.availableTools.length).toBeGreaterThan(0)
})
const OPERATOR_WHOAMI = {
username: 'op',
email: 'admin@test',
is_admin: false,
is_super_admin: false,
operator: true,
groups: [],
folders: [],
folders_read: []
}
// The capability filter is unit-tested against a profile handed to it directly; what is
// untested there is that the manager ever hands it one. This drives a real send and reads
// the toolset off the request, so moving the resolve out of the pre-flight, or dropping
// the rebuild when it lands, fails here rather than shipping an operator write tools.
it('withholds write and preview tools from an operator for the whole request', async () => {
onTestFinished(() => mocks.whoami.mockReset())
mocks.whoami.mockResolvedValue(OPERATOR_WHOAMI)
let sent: { tools: string[]; prompt: string; deployKinds: string[] } | undefined
mocks.runChatLoop.mockImplementation(async (config: any) => {
const deploy = config.tools.find((t: any) => t.def.function.name === 'deploy_workspace_item')
sent = {
tools: config.tools.map((t: any) => t.def.function.name),
prompt: config.systemMessage.content,
deployKinds: deploy?.def.function.parameters.properties.type.enum ?? []
}
return {
addedMessages: [],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
const manager = new AIChatManager()
manager.isSessionChat = true
await manager.sendRequest({ instructions: 'add a script', mode: AIMode.GLOBAL })
expect(sent?.tools).toEqual(expect.arrayContaining(['list_workspace_items', 'run_script']))
// Deploying ships — a draft can predate the role change — but only for the kinds an
// operator's token can land.
expect(sent?.deployKinds).toContain('schedule')
expect(sent?.deployKinds).not.toContain('script')
for (const withheld of ['write_script', 'test_run_script']) {
expect(sent?.tools).not.toContain(withheld)
// The prompt ships beside the tools, so the rebuild must have run after the
// profile landed — otherwise it still instructs the model to call these.
expect(sent?.prompt).not.toContain(withheld)
}
})
// The assistant settings modal resolves the profile on open, before any send, so the
// list it shows is the one the first request will carry, prompt included.
it('narrows the toolset and prompt when the profile resolves outside a send', async () => {
onTestFinished(() => mocks.whoami.mockReset())
mocks.whoami.mockResolvedValue(OPERATOR_WHOAMI)
const manager = new AIChatManager()
manager.mode = AIMode.GLOBAL
manager.isSessionChat = true
manager.configureGlobalMode()
expect(manager.availableTools.map((t) => t.def.function.name)).toContain('write_script')
await manager.refreshSessionAccess()
expect(manager.availableTools.map((t) => t.def.function.name)).not.toContain('write_script')
expect(manager.systemMessage.content).not.toContain('write_script')
})
// Which transition each posture offers is planModeController.test.ts's; what this pins is
// that only `tools` follows the picker. `isSessionChat` is what offers plan mode at all.
it('holds availableTools steady across the autonomy picker', async () => {
@@ -16,7 +16,7 @@
import { Bot, ExternalLink } from 'lucide-svelte'
import CopyButton from '$lib/components/common/button/CopyButton.svelte'
import { base } from '$lib/base'
import { displayDate } from '$lib/utils'
import { displayDate, truncateRev } from '$lib/utils'
interface Props {
message: DisplayMessage
@@ -191,7 +191,7 @@
class="inline-flex items-center gap-1 hover:text-primary hover:underline"
title="Open this run"
>
<span>job <span class="font-mono">{jobId?.slice(0, 8)}</span></span>
<span>job <span class="font-mono">{truncateRev(jobId ?? '', 8)}</span></span>
<ExternalLink size={11} class="shrink-0" />
</a>
{/if}
@@ -149,6 +149,7 @@ callers that already know which section they mean, such as the "+" menu's Manage
if (!aiChatManager.loading && !aiChatManager.sendInFlight) {
void aiChatManager.refreshGlobalSkills()
void aiChatManager.refreshMcpServers()
void aiChatManager.refreshSessionAccess()
}
}
</script>
@@ -10,9 +10,11 @@
FileText,
PanelRight,
Lock,
ExternalLink
ExternalLink,
BookOpen
} from 'lucide-svelte'
import { base } from '$lib/base'
import { truncateRev } from '$lib/utils'
import {
EXIT_PLAN_MODE_TOOL,
isPlanCardTool,
@@ -171,6 +173,18 @@
<span class="text-2xs text-tertiary truncate">{message.toolName}</span>
{/if}
</div>
{:else if message.heldForFolderInstructions}
<!-- The call never ran: the model gets the folder's instructions and calls it again.
A diff or run card here would show a write that did not happen. -->
<div class="font-mono text-xs flex items-center gap-2 py-0.5 my-0.5 min-w-0">
<BookOpen class="w-3.5 h-3.5 text-tertiary shrink-0" />
<span class="font-medium text-2xs text-tertiary shrink-0">
{message.content}
</span>
{#if message.toolName}
<span class="text-2xs text-tertiary truncate">{message.toolName} held until read</span>
{/if}
</div>
{:else if isRunCard}
<RunScriptCard {message} />
{:else if isDiffCard}
@@ -278,7 +292,7 @@
class="shrink-0 inline-flex items-center gap-1 font-main text-2xs text-tertiary hover:text-primary hover:underline"
title="Open this run"
>
<span>job <span class="font-mono">{message.jobId?.slice(0, 8)}</span></span>
<span>job <span class="font-mono">{truncateRev(message.jobId ?? '', 8)}</span></span>
<ExternalLink size={11} class="shrink-0" />
</a>
{/snippet}
@@ -446,7 +446,8 @@ export async function parseAnthropicCompletion(
toolCall,
helpers,
toolCallbacks: callbacks,
workspace: options?.workspace
workspace: options?.workspace,
messages
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { createToolDef, type Tool, type ToolCallbacks } from '../shared'
import { createToolDef, type ToolCallbacks } from '../shared'
import { NONE, type SessionTool } from '../sessionCapabilities'
import { artifactOverflowBytes, MAX_ARTIFACT_BYTES, normalizeChangeNote } from './artifactLimits'
import {
currentVersion,
@@ -98,8 +99,9 @@ function targetsPlan(args: unknown, h: ArtifactToolHelpers): boolean {
)
}
export const artifactTools: Tool<{}>[] = [
export const artifactTools: SessionTool<{}>[] = [
{
requires: NONE,
def: createToolDef(
createArtifactSchema,
'create_artifact',
@@ -156,6 +158,7 @@ export const artifactTools: Tool<{}>[] = [
}
},
{
requires: NONE,
def: createToolDef(
updateArtifactSchema,
'update_artifact',
@@ -208,6 +211,7 @@ export const artifactTools: Tool<{}>[] = [
}
},
{
requires: NONE,
def: createToolDef(
listArtifactsSchema,
'list_artifacts',
@@ -242,6 +246,7 @@ export const artifactTools: Tool<{}>[] = [
}
},
{
requires: NONE,
def: createToolDef(
readArtifactSchema,
'read_artifact',
@@ -303,6 +308,7 @@ export const artifactTools: Tool<{}>[] = [
}
},
{
requires: NONE,
def: createToolDef(
listArtifactVersionsSchema,
'list_artifact_versions',
@@ -569,6 +569,32 @@ const assistantTools = (...ids: string[]): ChatCompletionMessageParam => ({
function: { name: 'do_thing', arguments: '{}' }
}))
})
describe('runChatLoop per-chat tool schemas', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.resolveRequestReasoning.mockReturnValue(undefined)
mocks.getOpenAIResponsesCompletion.mockResolvedValue({})
mocks.parseOpenAIResponsesCompletion.mockResolvedValue({ shouldContinue: false, tokenUsage })
})
// The def `schemaFor` returns is the chat's own: it must be what the provider is sent and
// what the tool is dispatched with, while the shared tool object keeps its own.
it('sends and dispatches the def schemaFor returns, leaving the shared tool alone', async () => {
const base = { type: 'function', function: { name: 'dyn', parameters: {} } } as any
const perChat = {
type: 'function',
function: { name: 'dyn', parameters: { marked: 1 } }
} as any
const shared = { def: base, fn: async () => '', schemaFor: async () => perChat }
await runChatLoop({ ...createConfig({ workspace: `ws-${randomUUID()}` }), tools: [shared] })
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][2]).toEqual([perChat])
expect(mocks.parseOpenAIResponsesCompletion.mock.calls[0][4][0].def).toBe(perChat)
expect(shared.def).toBe(base)
})
})
const tool = (id: string): ChatCompletionMessageParam => ({
role: 'tool',
tool_call_id: id,
@@ -71,13 +71,8 @@ export interface ChatLoopConfig {
* lets the caller recover partial output if the loop throws or is aborted.
*/
addedMessages?: ChatCompletionMessageParam[]
/** Called before each iteration (e.g. to refresh tool schemas, or to record
* which model the iteration is about to use). */
onBeforeIteration?: (
tools: Tool<any>[],
helpers: any,
modelProvider: ReasoningProviderModel
) => Promise<void>
/** Called before each iteration (e.g. to record which model it is about to use). */
onBeforeIteration?: (modelProvider: ReasoningProviderModel) => Promise<void>
/** Fired for each completed provider response, before the loop continues. The
* loop can fail or be aborted at any iteration, so spend has to be handed over
* as it happens — a callback only at the end would discard everything the
@@ -399,7 +394,6 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
// Re-read these from config each iteration so that mode changes
// (e.g. changeModeTool in Navigator) take effect immediately.
// Callers can use JS getter properties to provide dynamic values.
const tools = config.tools
const helpers = config.helpers
const systemMessage = config.systemMessage
const modelProvider = config.modelProvider
@@ -411,8 +405,11 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
!unsupportedWebSearchCache.has(webSearchCacheKey)
if (onBeforeIteration) {
await onBeforeIteration(tools, helpers, modelProvider)
await onBeforeIteration(modelProvider)
}
const tools = await Promise.all(
config.tools.map(async (t) => (t.schemaFor ? { ...t, def: await t.schemaFor(helpers) } : t))
)
const pendingUserMessage = getPendingUserMessage?.()
@@ -6,10 +6,10 @@ import { datatableReference } from '$lib/components/dbTypes'
import {
createToolDef,
executeTestRun,
type Tool,
type ToolDisplayMessage,
type ChatJobResultFormat
} from './shared'
import { NONE, RUN_PREVIEW, type SessionTool } from './sessionCapabilities'
/**
* Workspace-scoped datatable tools, with no app whitelist and no creation policy.
@@ -246,9 +246,10 @@ export function formatChatJobCompletion(
* The unrestricted workspace datatable tools, for registration in global mode.
* Helper-free: each tool reads `workspace` directly from the tool call params.
*/
export function getDatatableTools(): Tool<{}>[] {
export function getDatatableTools(): SessionTool<{}>[] {
return [
{
requires: NONE,
def: getListDatatablesToolDef(),
planModeSafe: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
@@ -298,6 +299,7 @@ export function getDatatableTools(): Tool<{}>[] {
}
},
{
requires: NONE,
def: getGetDatatableTableSchemaToolDef(),
planModeSafe: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
@@ -337,6 +339,8 @@ export function getDatatableTools(): Tool<{}>[] {
}
},
{
// `runScript` below goes through /jobs/run/preview, which jobs.rs refuses operators.
requires: RUN_PREVIEW,
def: getExecDatatableSqlToolDef(),
requiresConfirmation: true,
confirmationMessage: 'Execute SQL on datatable',
@@ -1,4 +1,4 @@
import type { Tool } from '../shared'
import { NONE, type SessionTool } from '../sessionCapabilities'
import type { ChatCompletionTool } from 'openai/resources/index.mjs'
import { DocumentationService } from '$lib/gen'
@@ -33,7 +33,8 @@ const READ_DOCS_PAGE_TOOL: ChatCompletionTool = {
}
}
export const readDocsPageTool: Tool<{}> = {
export const readDocsPageTool: SessionTool<{}> = {
requires: NONE,
def: READ_DOCS_PAGE_TOOL,
planModeSafe: true,
fn: async ({ args, toolId, toolCallbacks }) => {
@@ -85,7 +86,8 @@ const SEARCH_DOCS_TOOL: ChatCompletionTool = {
}
}
export const searchDocsTool: Tool<{}> = {
export const searchDocsTool: SessionTool<{}> = {
requires: NONE,
def: SEARCH_DOCS_TOOL,
planModeSafe: true,
fn: async ({ args, toolId, toolCallbacks }) => {
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { DataMetricService, WorkspaceService } from '$lib/gen'
import { createToolDef, type Tool } from './shared'
import { createToolDef } from './shared'
import { NONE, type SessionTool } from './sessionCapabilities'
/**
* Workspace-scoped DuckLake tools, the pipeline counterpart to `list_datatables`
@@ -69,9 +70,10 @@ const NO_DATA_METRICS_NOTE =
const DEFAULT_DATA_METRICS_LIMIT = 200
/** The workspace DuckLake tools, for registration in global mode. */
export function getDucklakeTools(): Tool<{}>[] {
export function getDucklakeTools(): SessionTool<{}>[] {
return [
{
requires: NONE,
def: listDataMetricsToolDef,
planModeSafe: true,
showDetails: true,
@@ -106,6 +108,7 @@ export function getDucklakeTools(): Tool<{}>[] {
}
},
{
requires: NONE,
def: listDucklakesToolDef,
planModeSafe: true,
fn: async ({ workspace, toolId, toolCallbacks }) => {
@@ -8,7 +8,8 @@
*/
import { z } from 'zod'
import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs'
import { createToolDef, type Tool } from '../shared'
import { createToolDef } from '../shared'
import { NONE, type SessionTool } from '../sessionCapabilities'
import {
readFile,
searchFilesInWorker,
@@ -95,7 +96,8 @@ const searchFilesToolDef = createToolDef(
'Search the user-attached files with a regular expression and return matching lines with their line numbers. Use this to locate content before reading a specific window with read_file.'
)
export const searchFilesTool: Tool<{}> = {
export const searchFilesTool: SessionTool<{}> = {
requires: NONE,
def: searchFilesToolDef,
planModeSafe: true,
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
@@ -164,7 +166,8 @@ const readFileToolDef = createToolDef(
'Read a bounded window of lines from a user-attached file. Returns each line prefixed with its 1-based number (`<n>→<content>`) plus a pagination note. Files are not in context, so use this to inspect their contents.'
)
export const readFileTool: Tool<{}> = {
export const readFileTool: SessionTool<{}> = {
requires: NONE,
def: readFileToolDef,
planModeSafe: true,
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
@@ -193,7 +196,7 @@ export const readFileTool: Tool<{}> = {
}
}
export const fileTools: Tool<{}>[] = [searchFilesTool, readFileTool]
export const fileTools: SessionTool<{}>[] = [searchFilesTool, readFileTool]
function rosterLine(f: AttachedFile): string {
// Message rows are addressed by their stable id (names may collide); session
@@ -302,7 +302,7 @@ const patchFlowJsonToolDef = createToolDef(
'patch_flow_json',
'Make a quick exact text edit in the current compact flow JSON. Prefer this for small localized changes; use set_flow_json for larger structural rewrites.'
)
// Will be overridden by setSchema
// Replaced per chat by schemaFor
const testRunFlowSchema = z.object({
args: z
.object({})
@@ -435,11 +435,10 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
contextName: 'flow'
})
},
setSchema: async function (helpers: FlowAIChatHelpers) {
await buildSchemaForTool(this.def, async () => {
const flowInputsSchema = await helpers.getFlowInputsSchema()
return flowInputsSchema
})
schemaFor: async (helpers: FlowAIChatHelpers) => {
const def = structuredClone(testRunFlowToolDef)
await buildSchemaForTool(def, () => helpers.getFlowInputsSchema())
return def
},
requiresConfirmation: true,
confirmationMessage: 'Run a test of the current flow',
@@ -465,6 +464,18 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
const stepId = args.stepId
const stepArgs = args.args || {}
// An agent step runs on the step's own configuration with this run's changes on top, which
// the global chat's run form assembles; this tool sends only what the model named.
if (findModuleInFlow(flow.value, stepId)?.value.type === 'aiagent') {
toolCallbacks.setToolStatus(toolId, {
content: `Step "${stepId}" is an AI agent step`,
error: 'AI agent steps cannot be tested from this chat'
})
throw new Error(
'AI agent steps cannot be tested from this chat. Test it from the step\'s "Test this step" tab.'
)
}
return executeFlowStepTestRun({
flowValue: flow.value,
stepId,
@@ -0,0 +1,220 @@
import { ResourceService } from '$lib/gen'
import { truncateForPrompt } from './skills/skillResources'
/**
* Folder instructions are resources of this type: a markdown file resource
* (`format_extension = 'md'`) whose `value.content` the assistant follows for
* every item under the directory the resource lives in, like an AGENTS.md.
*/
export const FOLDER_INSTRUCTIONS_RESOURCE_TYPE = 'ai_instruction'
/** Bytes of one instruction body a tool result may carry. */
export const MAX_FOLDER_INSTRUCTIONS_LENGTH = 32 * 1024
/** Bytes all the bodies one tool result carries may add up to, so nested folders
* cannot stack several full-size bodies onto a single result. */
export const MAX_FOLDER_INSTRUCTIONS_TOTAL = 48 * 1024
/** Scopes the system prompt names. Past this the list is cut, not the delivery:
* a scope left out still has its instructions delivered on first touch. */
export const MAX_LISTED_FOLDER_INSTRUCTIONS = 50
export type FolderInstruction = {
/** The `ai_instruction` resource. */
path: string
/** The directory it covers, with a trailing `/`: `f/billing/AGENTS` covers `f/billing/`. */
scope: string
}
export function folderInstructionScope(path: string): string {
return path.slice(0, path.lastIndexOf('/') + 1)
}
const PAGE_SIZE = 100
/** Guard against a paging bug looping forever, not a product cap. */
const MAX_PAGES = 20
/** Every folder instruction the user can read in the workspace. Read access is the
* resource's own ACL, so a folder's instructions reach exactly who can see them. */
export async function listFolderInstructions(workspace: string): Promise<FolderInstruction[]> {
if (!workspace) return []
const rows: FolderInstruction[] = []
for (let page = 1; page <= MAX_PAGES; page++) {
const resources = await ResourceService.listResource({
workspace,
resourceType: FOLDER_INSTRUCTIONS_RESOURCE_TYPE,
page,
perPage: PAGE_SIZE
})
rows.push(...resources.map((r) => ({ path: r.path, scope: folderInstructionScope(r.path) })))
if (resources.length < PAGE_SIZE) break
}
return rows
}
/** Workspace paths a tool call names: every string argument called `path` or
* `*_path` that is a `u/`, `f/` or `g/` path, nested ones included — a trigger's target
* sits in `config.path`, and missing it would let that write through unheld. */
export function workspacePathsInArgs(args: unknown, depth = 0): string[] {
if (!args || typeof args !== 'object' || depth > 4) return []
if (Array.isArray(args)) return args.flatMap((v) => workspacePathsInArgs(v, depth + 1))
return Object.entries(args as Record<string, unknown>).flatMap(([key, value]) =>
typeof value === 'string'
? (key === 'path' || key.endsWith('_path')) && /^[ufg]\/[^/]+/.test(value)
? [value]
: []
: workspacePathsInArgs(value, depth + 1)
)
}
/** The instructions covering any of `paths`, outermost scope first, so a nested
* folder's instructions come last and read as the more specific ones. A path naming
* the folder itself (`f/billing`, as a pipeline's is) is covered too. */
export function instructionsCovering(
instructions: readonly FolderInstruction[],
paths: readonly string[]
): FolderInstruction[] {
return instructions
.filter((i) => paths.some((p) => `${p}/`.startsWith(i.scope)))
.sort((a, b) => a.scope.length - b.scope.length || a.path.localeCompare(b.path))
}
/** What a chat hands the tool loop: the instructions in play, and which tool call
* delivered which of them.
*
* A delivery counts only while its tool message is still in the conversation and
* carries the text: the id is recorded because a script quoting the tag in a result
* must not pass for a delivery, and nothing is marked on the message object because
* the completions path sends that to the provider verbatim. So compaction or a
* restored chat gets the instructions again. */
export type FolderInstructionsContext = {
list: () => readonly FolderInstruction[]
deliveredBy: Map<string, FolderInstructionsDelivery>
}
/** Keyed by workspace too: the chat keeps its conversation across a switch of
* operating workspace, and the same path there can hold different instructions. */
export type FolderInstructionsDelivery = { workspace: string; paths: readonly string[] }
/** Instructions delivered by the tool results in `messages`, split by whether the
* model has read them. Results after the latest assistant message answer the batch
* still being processed: the model has not seen those yet, so a delivery there
* must not let a change later in the same batch through. */
function deliveredIn(
ctx: FolderInstructionsContext,
messages: readonly unknown[],
workspace: string
): { read: Set<string>; thisBatch: Set<string> } {
const read = new Set<string>()
const thisBatch = new Set<string>()
let lastAssistant = -1
messages.forEach((m, i) => {
if ((m as { role?: unknown } | undefined)?.role === 'assistant') lastAssistant = i
})
messages.forEach((m, i) => {
const { tool_call_id: id, content } = (m ?? {}) as { tool_call_id?: unknown; content?: unknown }
if (typeof id !== 'string' || typeof content !== 'string') return
const delivery = ctx.deliveredBy.get(id)
if (delivery?.workspace !== workspace) return
for (const p of delivery.paths) {
// The id alone is not enough: a call stopped mid-run is answered with a
// placeholder under the same id, which carries no instructions.
if (content.includes(openingTag(p))) (i < lastAssistant ? read : thisBatch).add(p)
}
})
return { read, thisBatch }
}
/** The instructions covering the paths a tool call names that the model has not
* read yet, as the text to hand it, or undefined when there are none. `paths` are
* the bodies the text carries; one already carried earlier in the same batch is
* pointed to rather than repeated. A body that no longer reads (deleted or moved
* since the listing) is left out rather than failing the call it rides on. */
export async function pendingFolderInstructions(
ctx: FolderInstructionsContext | undefined,
messages: readonly unknown[],
args: unknown,
workspace: string
): Promise<{ paths: string[]; text: string } | undefined> {
if (!ctx || !workspace) return undefined
const paths = workspacePathsInArgs(args)
if (paths.length === 0) return undefined
const { read, thisBatch } = deliveredIn(ctx, messages, workspace)
const pending = instructionsCovering(ctx.list(), paths).filter((i) => !read.has(i.path))
const inBatch = pending.filter((i) => thisBatch.has(i.path))
const blocks = (
await Promise.all(
pending
.filter((i) => !thisBatch.has(i.path))
.map(async (instruction) => {
try {
return {
instruction,
body: await readFolderInstructionBody(workspace, instruction.path)
}
} catch (e) {
console.error(`Failed to read folder instructions ${instruction.path}`, e)
return undefined
}
})
)
).filter((b) => b !== undefined)
if (blocks.length === 0 && inBatch.length === 0) return undefined
const pointers = inBatch.map(
(i) =>
`The instructions for \`${i.scope}\` (${i.path}) are in an earlier result of this same batch of tool calls.`
)
return {
paths: blocks.map((b) => b.instruction.path),
text: [formatFolderInstructions(blocks), ...pointers].filter(Boolean).join('\n\n')
}
}
export async function readFolderInstructionBody(workspace: string, path: string): Promise<string> {
const value = (await ResourceService.getResourceValue({ workspace, path })) as
| { content?: unknown }
| undefined
if (typeof value?.content !== 'string') {
throw new Error(`resource ${path} has no string "content"`)
}
return value.content
}
function openingTag(path: string): string {
return `<folder_instructions path="${path}"`
}
export function formatFolderInstructions(
blocks: readonly { instruction: FolderInstruction; body: string }[]
): string {
// The budget goes innermost first: the most specific instructions are the last to
// be cut. An outer body the budget no longer reaches keeps its tag, so it still
// counts as delivered, with the cut marked.
let remaining = MAX_FOLDER_INSTRUCTIONS_TOTAL
const bodies = [...blocks].reverse().map(({ body }) => {
const cut = truncateForPrompt(body, Math.min(MAX_FOLDER_INSTRUCTIONS_LENGTH, remaining))
remaining = Math.max(0, remaining - new TextEncoder().encode(cut).byteLength)
return cut
})
bodies.reverse()
return blocks
.map(
({ instruction }, i) =>
`${openingTag(instruction.path)} scope="${instruction.scope}">\n${bodies[i]}\n</folder_instructions>`
)
.join('\n\n')
}
/** The system prompt section naming the scopes that carry instructions. */
export function getFolderInstructionsPromptSection(
instructions: readonly FolderInstruction[]
): string {
if (instructions.length === 0) return ''
const scopes = [...new Set(instructions.map((i) => i.scope))].sort()
const listed = scopes.slice(0, MAX_LISTED_FOLDER_INSTRUCTIONS)
const more = scopes.length > listed.length ? ` (and ${scopes.length - listed.length} more)` : ''
return `\n\nFOLDER INSTRUCTIONS:
These folders carry instructions (ai_instruction resources) for every item under them: ${listed.map((s) => `\`${s}\``).join(', ')}${more}.
- The first time a tool call names a path under one of them, its instructions are delivered in a <folder_instructions> block of the tool result. A call that would change something is held back until you have them: read them, then make the call again.
- Follow them for all work under that folder, together with any WORKSPACE INSTRUCTIONS and USER INSTRUCTIONS above: they add to those, they do not replace them.
- Only where two of them conflict on the same point, the more specific one wins: a nested folder's instructions over its parent folder's, a folder's over the WORKSPACE INSTRUCTIONS, and both over the USER INSTRUCTIONS, which are personal preferences. The user's explicit requests in this chat come before all of them.`
}
@@ -6122,6 +6122,237 @@ describe('global AI tools', () => {
})
})
// An agent step has no job of its own, so it runs as a flow preview of itself alone, opened on
// the same rows and the same values as the flow editor's own step test. A setting the step
// leaves alone is not offered at all, so a mounted widget cannot invent a value for it: an
// `enabled_tools` the form synthesized empty would advertise none of the agent's tools.
it("test_run_step opens an aiagent step on the step's own configuration", async () => {
const providerValue = { kind: 'openai', resource: '$res:u/admin/openai', model: 'gpt-4o' }
let opened: RunFormDisplay | undefined
await callGlobalTool('write_flow', {
path: 'f/flows/agent-step',
summary: 'Flow with an agent step',
modules: JSON.stringify([
{
id: 'ask',
value: {
type: 'aiagent',
tools: [
{
id: 'b',
summary: 'get_code',
value: {
tool_type: 'flowmodule',
type: 'rawscript',
language: 'bun',
content: 'export async function main() { return 1 }',
input_transforms: {}
}
}
],
input_transforms: {
provider: { type: 'static', value: providerValue },
user_message: { type: 'static', value: 'authored question' }
}
}
}
])
})
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_step',
{
path: 'f/flows/agent-step',
stepId: 'ask',
args: { user_message: 'What is the capital of France?' }
},
{
...toolCallbacks,
requestRunArgs: async (_toolId, form) => {
opened = form
return form.args
}
}
)
)
// The step configures these two, so the form offers them, filled in with what the step
// holds — and the model's own message wins over the authored one.
expect(opened?.args).toMatchObject({
provider: providerValue,
user_message: 'What is the capital of France?'
})
// Those two plus the rows the editor always shows, and nothing else the step is silent
// about: `enabled_tools` above all, whose synthesized empty value would advertise no tools.
expect(Object.keys(opened?.schema?.properties ?? {})).toEqual([
'provider',
'user_message',
'system_prompt'
])
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
value: {
modules: [
{
id: 'ask',
value: expect.objectContaining({
type: 'aiagent',
input_transforms: {
provider: { type: 'javascript', expr: 'flow_input.provider' },
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
// Every row of the form runs from it, the blank ones too.
system_prompt: { type: 'javascript', expr: 'flow_input.system_prompt' }
}
})
}
]
},
args: { provider: providerValue, user_message: 'What is the capital of France?' },
// Filed under the flow, as the editor's step test does: a managed memory keyed on a
// named memory id is found under it.
path: 'f/flows/agent-step'
}
})
})
// What the form submits is what runs, as in the editor's step test: a field cleared over the
// step's expression runs cleared rather than falling back to the expression, whether the
// mounted form sends it as the empty string or the bypass posture sends the form unchanged.
it.each([
{
answer: 'the mounted form',
// A field the form could not fill in comes back blank, never absent: text as the empty
// string, a list as `[]`.
callbacks: {
requestRunArgs: async (_toolId: string, form: any) => ({
...form.args,
user_message: '',
enabled_tools: form.args.enabled_tools ?? []
})
}
},
{
answer: 'the bypass posture',
callbacks: {
shouldAutoAcceptToolConfirmations: () => true,
requestRunArgs: async (_toolId: string, form: any) => form.args
}
}
])(
'test_run_step runs an aiagent field left blank by $answer as blank',
async ({ callbacks }) => {
await callGlobalTool('write_flow', {
path: 'f/flows/agent-expr',
summary: 'Agent fed by an earlier step',
modules: JSON.stringify([
{
id: 'first',
value: {
type: 'rawscript',
language: 'bun',
content: 'export async function main() { return { question: "hi" } }',
input_transforms: {}
}
},
{
id: 'ask',
value: {
type: 'aiagent',
tools: [],
input_transforms: {
provider: {
type: 'static',
value: { kind: 'openai', resource: '$res:u/admin/openai', model: 'gpt-4o' }
},
user_message: { type: 'javascript', expr: 'results.first.question' },
enabled_tools: { type: 'javascript', expr: 'results.first.tools' }
}
}
}
])
})
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_step',
{ path: 'f/flows/agent-expr', stepId: 'ask', args: {} },
{ ...toolCallbacks, ...callbacks }
)
)
const body = vi.mocked(JobService.runFlowPreview).mock.calls.at(-1)?.[0].requestBody as any
expect(body.value.modules[0].value.input_transforms.user_message).toEqual({
type: 'javascript',
expr: 'flow_input.user_message'
})
expect(body.args.enabled_tools).toEqual([])
}
)
// The memory picker resets a value whose kind it does not offer to its first option, so a
// step saved with a legacy kind would run with its history switched off. And a tool name typed
// by hand that matches none silently narrows the run, so the roster is offered as in the editor.
it("test_run_step offers an aiagent step the editor's choices", async () => {
let opened: RunFormDisplay | undefined
await callGlobalTool('write_flow', {
path: 'f/flows/agent-legacy-memory',
summary: 'Agent with legacy memory',
modules: JSON.stringify([
{
id: 'ask',
value: {
type: 'aiagent',
tools: [
{
id: 'b',
summary: 'get_code',
value: {
tool_type: 'flowmodule',
type: 'rawscript',
language: 'bun',
content: 'export async function main() { return 1 }',
input_transforms: {}
}
}
],
input_transforms: {
provider: {
type: 'static',
value: { kind: 'openai', resource: '$res:u/admin/openai', model: 'gpt-4o' }
},
user_message: { type: 'static', value: 'hi' },
memory: { type: 'static', value: { kind: 'auto', context_length: 5 } },
enabled_tools: { type: 'static', value: ['get_code'] }
}
}
}
])
})
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_step',
{ path: 'f/flows/agent-legacy-memory', stepId: 'ask', args: {} },
{
...toolCallbacks,
requestRunArgs: async (_toolId, form) => {
opened = form
return form.args
}
}
)
)
const kinds = opened?.schema.properties.memory.oneOf.map(
(variant: any) => variant.properties.kind.enum[0]
)
expect(kinds).toContain('auto')
expect(opened?.schema.properties.enabled_tools.items.enum).toEqual(['get_code'])
})
// A step is fed by its input transforms, so its arguments are its own and the flow's
// schema describes a different set entirely. Opening the form on the flow's would offer
// fields this job ignores and drop the ones it takes.
@@ -7652,36 +7883,14 @@ describe('buildOpenPageUrl runs filters', () => {
})
})
describe('buildOpenPageUrl compare selection', () => {
const itemsOf = (url: string) => new URL(url, 'http://x').searchParams.get('items')
it('explicit items win over the chat mask', () => {
const url = buildOpenPageUrl(
'compare',
{ page: 'compare', items: ['script:f/a/b'] },
{ workspaceId: 'ws', chatItems: ['flow:f/c/d'] }
)
expect(itemsOf(url)).toBe('script:f/a/b')
})
it('omitted items fall back to the chat-modified mask', () => {
const url = buildOpenPageUrl(
'compare',
{ page: 'compare' },
{ workspaceId: 'ws', chatItems: ['flow:f/c/d', 'script:f/a/b'] }
)
expect(itemsOf(url)).toBe('flow:f/c/d,script:f/a/b')
})
it('an empty or absent mask yields no items param (page select-all default)', () => {
expect(
itemsOf(
buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws', chatItems: [] })
)
).toBeNull()
expect(
itemsOf(buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws' }))
).toBeNull()
describe('buildOpenPageUrl compare', () => {
// Without an explicit mode the page auto-picks, which is the draft view outside a fork.
it('always opens the fork comparison', () => {
const params = new URL(
buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws' }),
'http://x'
).searchParams
expect(params.get('mode')).toBe('fork')
})
})
@@ -7691,9 +7900,9 @@ describe('open_page workspace gating', () => {
// A workspace the user belongs to but whose `whoami` never answers.
const FLAKY = 'flaky_ws'
const openPage = () => getGlobalTool('open_page')
const pageSchema = () => (openPage().def.function.parameters as any)?.properties?.page ?? {}
let def = openPage().def
const pageSchema = () => (def.function.parameters as any)?.properties?.page ?? {}
const advertisedPages = () => (pageSchema().enum ?? []) as string[]
const pristineDef = openPage().def
beforeEach(() => {
// Admin of the workspace being browsed, plain member of the one a session operates on.
@@ -7716,16 +7925,26 @@ describe('open_page workspace gating', () => {
superadmin.set(undefined)
whoamiByWorkspace.clear()
clearWorkspaceRoleCache()
openPage().def = pristineDef
})
// Every chat shares this tool object, so a schema written back to it would reach the
// other chats' requests.
it("never writes a chat's schema back to the shared tool", async () => {
const shared = openPage().def
const snapshot = structuredClone(shared)
def = await openPage().schemaFor!({ operatingWorkspace: SESSION })
expect(def).not.toBe(shared)
expect(openPage().def).toBe(shared)
expect(shared).toEqual(snapshot)
})
// Reading the ambient `userStore` instead offers a session the pages of the workspace
// the user happens to be browsing.
it('gates on the operating workspace, not the one userStore describes', async () => {
await openPage().setSchema?.({ operatingWorkspace: NAV })
def = await openPage().schemaFor!({ operatingWorkspace: NAV })
expect(advertisedPages()).toContain('workspace_settings')
await openPage().setSchema?.({ operatingWorkspace: SESSION })
def = await openPage().schemaFor!({ operatingWorkspace: SESSION })
expect(advertisedPages()).toContain('runs')
expect(advertisedPages()).not.toContain('workspace_settings')
await expect(
@@ -7740,7 +7959,7 @@ describe('open_page workspace gating', () => {
// Neither layer may read as a denial: the role was never established, and the model
// sees the schema before it can ever reach the handler's message.
it('advertises nothing and blames no denial when the role lookup fails', async () => {
await openPage().setSchema?.({ operatingWorkspace: FLAKY })
def = await openPage().schemaFor!({ operatingWorkspace: FLAKY })
expect(advertisedPages()).toEqual([])
expect(pageSchema().description).toContain("couldn't be checked")
const refusal = await callGlobalTool('open_page', { page: 'runs' }, toolCallbacks, {
@@ -7753,7 +7972,7 @@ describe('open_page workspace gating', () => {
// A workspace absent from `userWorkspaces` is settled, not unknown: inviting a retry
// would be false, and asking `whoami` at all only earns a 401 on every iteration.
it('reports a plain denial for a workspace the user is not a member of', async () => {
await openPage().setSchema?.({ operatingWorkspace: 'unreachable_ws' })
def = await openPage().schemaFor!({ operatingWorkspace: 'unreachable_ws' })
expect(advertisedPages()).toEqual([])
expect(pageSchema().description).not.toContain("couldn't be checked")
const refusal = await callGlobalTool('open_page', { page: 'runs' }, toolCallbacks, {
@@ -7770,7 +7989,7 @@ describe('open_page workspace gating', () => {
it('asks whoami while the workspace list is still unresolved', async () => {
usersWorkspaceStore.set(undefined)
await openPage().setSchema?.({ operatingWorkspace: SESSION })
def = await openPage().schemaFor!({ operatingWorkspace: SESSION })
expect(UserService.whoami).toHaveBeenCalledWith(expect.objectContaining({ workspace: SESSION }))
expect(advertisedPages()).not.toEqual([])
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,7 @@
* on another. Plan mode's decoration is not here: it is applied per request, later.
*/
import type { ChatCompletionSystemMessageParam } from 'openai/resources/index.mjs'
import type { Tool } from '../shared'
import type { SessionTool } from '../sessionCapabilities'
import {
getSessionContextPromptSection,
globalToolsFor,
@@ -13,6 +13,7 @@ import {
} from './core'
import { createMcpTools } from './mcpTools'
import { getPipelinePromptSection, pipelineTools, type PipelineContext } from '../pipeline/core'
import { getFolderInstructionsPromptSection, type FolderInstruction } from '../folderInstructions'
// Derived, not retyped: an option added to prepareGlobalSystemMessage is reachable
// here at once. Hand-listing the four would compile fine while leaving the new one
@@ -21,6 +22,7 @@ import { getPipelinePromptSection, pipelineTools, type PipelineContext } from '.
export type GlobalAssemblyOpts = NonNullable<Parameters<typeof prepareGlobalSystemMessage>[1]> & {
sessionContext?: SessionPromptContext
pipelineContext?: PipelineContext
folderInstructions?: readonly FolderInstruction[]
}
export function assembleGlobalSystemMessage(
@@ -28,16 +30,17 @@ export function assembleGlobalSystemMessage(
opts: GlobalAssemblyOpts
): ChatCompletionSystemMessageParam {
const systemMessage = prepareGlobalSystemMessage(instructions, opts)
systemMessage.content += getFolderInstructionsPromptSection(opts.folderInstructions ?? [])
if (opts.sessionContext) {
systemMessage.content += getSessionContextPromptSection(opts.sessionContext)
systemMessage.content += getSessionContextPromptSection(opts.sessionContext, opts.access)
}
if (opts.pipelineContext) {
systemMessage.content += getPipelinePromptSection(opts.pipelineContext)
systemMessage.content += getPipelinePromptSection(opts.pipelineContext, opts.access)
}
return systemMessage
}
export function assembleGlobalTools(opts: GlobalAssemblyOpts): Tool<any>[] {
export function assembleGlobalTools(opts: GlobalAssemblyOpts): SessionTool<any>[] {
return [
...globalToolsFor({ sessionPreview: opts.previewTools ?? false }),
...(opts.pipelineContext ? pipelineTools : []),
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { ResourceService, type GetMcpToolsResponse } from '$lib/gen'
import { createToolDef, type Tool } from '../shared'
import { createToolDef } from '../shared'
import { NONE, type SessionTool } from '../sessionCapabilities'
import { enabledMcpPaths } from '$lib/components/mcp/enabledServers'
import { isOwnOrSharedMcpPath, MCP_LIST_PER_PAGE, mcpViewer } from '$lib/components/mcp/ownServers'
@@ -349,9 +350,10 @@ const callMcpToolSchema = z.object({
* mode and behind the user's confirmation — building both from one body keeps
* them from drifting.
*/
function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}> {
function createCallTool(servers: McpServer[], mode: 'read' | 'write'): SessionTool<{}> {
const isRead = mode === 'read'
return {
requires: NONE,
def: createToolDef(
callMcpToolSchema,
isRead ? 'call_mcp_read_tool' : 'call_mcp_write_tool',
@@ -419,12 +421,13 @@ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}>
* are not registered at all, so a workspace without an MCP connection pays no
* per-iteration schema cost for them.
*/
export function createMcpTools(servers: McpServer[]): Tool<{}>[] {
export function createMcpTools(servers: McpServer[]): SessionTool<{}>[] {
if (servers.length === 0) return []
const serverList = servers.map((s) => s.path).join(', ')
return [
{
requires: NONE,
def: createToolDef(
searchMcpToolsSchema,
'search_mcp_tools',
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { whoami, deployRules } = vi.hoisted(() => ({
whoami: vi.fn(),
deployRules: vi.fn()
}))
vi.mock('$lib/gen', () => ({ UserService: { whoami } }))
vi.mock('$lib/utils_workspace_deploy', () => ({ checkDeployRules: deployRules }))
import { resolveSessionAccess } from './sessionAccess'
import { clearWorkspaceRoleCache } from '$lib/user'
type WhoamiOverrides = { is_admin?: boolean; is_super_admin?: boolean; operator?: boolean }
function user(overrides: WhoamiOverrides) {
return {
email: 'u@windmill.dev',
username: 'u',
is_admin: false,
is_super_admin: false,
operator: false,
created_at: '',
disabled: false,
groups: [],
folders: [],
folders_read: [],
folders_owners: [],
...overrides
}
}
async function capabilitiesFor(overrides: WhoamiOverrides, workspace = 'ws') {
whoami.mockResolvedValueOnce(user(overrides))
return await resolveSessionAccess(workspace)
}
describe('resolveSessionAccess', () => {
beforeEach(() => {
vi.clearAllMocks()
deployRules.mockResolvedValue({ ok: true })
// The role memo is app-wide, so without this each case answers from the previous one.
clearWorkspaceRoleCache()
})
it('gives a developer every capability but admin', async () => {
const caps = await capabilitiesFor({})
expect([...caps].sort()).toEqual(['deploy', 'manage_code', 'run_preview', 'write_draft'])
})
// The folder, resource and variable handlers run the rules but have no operator check,
// so withholding `deploy` from an operator would be stricter than the server.
it('leaves an operator the deploy-rule capability, and nothing their token refuses', async () => {
const caps = await capabilitiesFor({ operator: true })
expect([...caps]).toEqual(['deploy'])
})
it('takes deploy from the rules alone, so a rule blocks an operator too', async () => {
deployRules.mockResolvedValue({ ok: false, refusedBy: 'DisableDirectDeployment' })
const caps = await capabilitiesFor({ operator: true })
expect([...caps]).toEqual([])
})
// Both spellings of `authed.is_admin`, which the draft path honours and the handlers
// refusing `authed.is_operator` do not — the session path never clears that flag.
it.each([{ is_admin: true }, { is_super_admin: true }])(
'lets an admin who is also an operator draft and deploy, but not preview or manage code (%o)',
async (role) => {
const caps = await capabilitiesFor({ ...role, operator: true })
expect([...caps].sort()).toEqual(['admin', 'deploy', 'write_draft'])
}
)
it('keeps drafting when a rule refuses deploy', async () => {
deployRules.mockResolvedValue({
ok: false,
reason: 'restricted to deployers',
refusedBy: 'RestrictDeployToDeployers'
})
const caps = await capabilitiesFor({})
expect(caps.has('deploy')).toBe(false)
expect(caps.has('write_draft')).toBe(true)
})
it('grants everything when the role cannot be resolved', async () => {
whoami.mockRejectedValueOnce(new Error('network'))
const access = await resolveSessionAccess('ws')
expect(access.has('write_draft')).toBe(true)
expect(access.has('deploy')).toBe(true)
})
})
@@ -0,0 +1,68 @@
import { getWorkspaceRole } from '$lib/user'
import { checkDeployRules } from '$lib/utils_workspace_deploy'
import type { SessionAccess, SessionCapability } from '../sessionCapabilities'
const ALL_CAPABILITIES: SessionCapability[] = [
'write_draft',
'run_preview',
'deploy',
'manage_code',
'admin'
]
/** Fail open, here and at every resolution failure below: blanking a toolset on a
* transient error tells a developer mid-session that they cannot author anything,
* which is worse and far less legible than the 403 they get by trying. Matches
* `checkDeployPermission`, which fails open for the same reason. */
export function fullSessionAccess(): SessionAccess {
return new Set(ALL_CAPABILITIES)
}
/** Pure, so the profiles this can produce are enumerable rather than listed by hand.
* `isAdmin` is one field because the server's `authed.is_admin` is
* `usr.is_admin || super_admin` (auth.rs), which `whoami` reports as two. */
export function capabilitiesForRole(role: {
isAdmin: boolean
operator: boolean
deployRulesPass: boolean
}): SessionAccess {
const capabilities = new Set<SessionCapability>()
// Per-capability precedence, NOT a role ladder: drafts.rs `require_can_write_path`
// returns Ok on `authed.is_admin` BEFORE its operator branch, while jobs.rs
// `run_preview_*` and the script/flow/app handlers refuse `authed.is_operator` with no
// admin escape — and on the session path that flag is never cleared for an admin.
if (role.isAdmin || !role.operator) {
capabilities.add('write_draft')
}
if (!role.operator) {
capabilities.add('run_preview')
capabilities.add('manage_code')
}
// No operator term: the rules are their own gate, and the handlers that also refuse
// operators say so through `manage_code`. Admins bypass the rules inside the check.
if (role.deployRulesPass) {
capabilities.add('deploy')
}
if (role.isAdmin) {
capabilities.add('admin')
}
return capabilities
}
export async function resolveSessionAccess(workspace: string): Promise<SessionAccess> {
// Shares the 5-minute memo with the identity the same pre-flight resolves beside this
// one, so a send costs one `whoami` rather than two, at the cost of a role changed
// elsewhere landing within that window rather than on the very next message.
// `lookup_failed` covers a rejected request AND a body too malformed to map.
const lookup = await getWorkspaceRole(workspace)
if (lookup.kind !== 'resolved') {
return fullSessionAccess()
}
const me = lookup.user
return capabilitiesForRole({
isAdmin: !!me.is_admin || !!me.is_super_admin,
operator: !!me.operator,
deployRulesPass: (await checkDeployRules(workspace, me)).ok
})
}
@@ -0,0 +1,219 @@
import { describe, expect, it, vi } from 'vitest'
// The toolset pulls in the script/flow editor tools, hence monaco. Same stand-ins as
// global/core.test.ts; nothing here executes a tool, so bare shapes are enough.
vi.mock('monaco-editor', () => ({
editor: {},
languages: {},
KeyCode: {},
Uri: { parse: (value: string) => ({ toString: () => value }) },
MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 }
}))
vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({
getTypeScriptWorker: async () => async () => ({}),
typescriptVersion: 'test'
}))
vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) }))
vi.mock('$lib/components/vscode', () => ({}))
// `globalToolsFor` withholds take_screenshot off Blink, and node reports a `Node.js/<v>`
// user agent — so without this the description sweep below silently skips a tool that
// ships in every real Chromium session. Claim the maximal toolset instead.
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 Chrome/120.0.0.0' })
import { globalTools, prepareGlobalSystemMessage, type SessionPromptContext } from './core'
import { appendPlanModeInstructions } from '../planMode'
import { PlanModeController } from '../planModeController.svelte'
import { pipelineTools } from '../pipeline/core'
import { assembleGlobalSystemMessage, assembleGlobalTools } from './globalAssembly'
import { filterSessionTools, sessionToolAllowed } from './sessionToolset'
import { capabilitiesForRole, fullSessionAccess } from './sessionAccess'
import type { SessionAccess, SessionCapability, SessionTool } from '../sessionCapabilities'
const ASSEMBLY_OPTS = {
previewTools: true,
pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] },
mcpServers: [{ path: 'f/test/server' } as any]
}
/** Every tool that can reach a session toolset: what assembly builds, so a source added
* there needs no edit here, unioned with the static sources so the set cannot shrink if
* assembly stops reaching one, plus the plan tools the manager appends. */
function sessionReachableTools(): SessionTool<any>[] {
const plan = new PlanModeController({ available: true } as any).availableTools
const byName = new Map<string, SessionTool<any>>()
for (const t of [
...assembleGlobalTools(ASSEMBLY_OPTS),
...globalTools,
...pipelineTools,
...plan
]) {
byName.set(t.def.function.name, t)
}
return [...byName.values()]
}
function withheldNames(access: SessionAccess): string[] {
return sessionReachableTools()
.filter((t) => !sessionToolAllowed(t, access))
.map((t) => t.def.function.name)
}
/** The tools a session actually ships, through the same assembly production uses. */
function shippedSessionTools(access: SessionAccess) {
return filterSessionTools(assembleGlobalTools(ASSEMBLY_OPTS), access)
}
function accessWith(capabilities: SessionCapability[]): SessionAccess {
return new Set(capabilities)
}
/** The profiles a real session can hold, keyed for the test name, over every combination
* of the role facts `capabilitiesForRole` reads. */
const REACHABLE_PROFILES = [
...new Map(
[false, true].flatMap((isAdmin) =>
[false, true].flatMap((operator) =>
// `checkDeployRules` bypasses on admin, so an admin has only the one outcome.
(isAdmin ? [true] : [false, true]).map((deployRulesPass) => {
const access = capabilitiesForRole({ isAdmin, operator, deployRulesPass })
return [[...access].sort().join(',') || 'none', access] as const
})
)
)
).entries()
]
/** One per branch of getSessionContextPromptSection — each words the deploy target
* differently, so a gate fixed in one branch can still leak in another. */
const SESSION_CONTEXTS: SessionPromptContext[] = [
{ pendingForkOf: 'parent' },
{ workspaceId: 'dev', parentWorkspaceId: 'parent', isDevWorkspace: true },
{ workspaceId: 'fork', parentWorkspaceId: 'parent' },
{ workspaceId: 'fork', forkParentUnknown: true },
{ workspaceId: 'live' },
{}
]
describe('session tool policies', () => {
// Full access must be a no-op, or every existing session (and the ai_evals
// baseline measured against it) changes behaviour.
it('withholds nothing from a session with every capability', () => {
const tools = sessionReachableTools()
const filtered = filterSessionTools(tools, fullSessionAccess())
expect(filtered).toHaveLength(tools.length)
filtered.forEach((tool, i) => expect(tool).toBe(tools[i]))
})
it('withholds a tool that arrives without a policy', () => {
expect(sessionToolAllowed({ def: globalTools[0].def }, fullSessionAccess())).toBe(false)
})
it('passes the toolset through untouched when access is unresolved', () => {
const tools = globalTools.map((t) => ({ def: t.def }))
expect(filterSessionTools(tools, undefined)).toBe(tools)
})
// The kind is an argument, so its enum carries the permission, per the handler each kind
// reaches. Asserted on the shipped schema, since that is all the model sees.
it('offers the kind-taking tools only the kinds their handlers accept', () => {
const shipped = (name: string, access: SessionAccess) =>
shippedSessionTools(access).find((t) => t.def.function.name === name)!
const kindsOf = (name: string, access: SessionAccess) =>
(shipped(name, access).def.function.parameters as any).properties.type.enum
// A developer a protection rule refuses keeps the two kinds no rule reaches.
const refused = accessWith(['write_draft', 'run_preview', 'manage_code'])
expect(kindsOf('deploy_workspace_item', refused)).toEqual(['schedule', 'trigger'])
expect(kindsOf('delete_workspace_item', refused)).toEqual(['schedule', 'trigger'])
// An operator where no rule applies: only the code handlers refuse them.
const operator = accessWith(['deploy'])
expect(kindsOf('deploy_workspace_item', operator)).toEqual([
'schedule',
'trigger',
'resource',
'variable'
])
// Deleting a script is admin-only, where deploying one is not.
const developer = accessWith(['write_draft', 'run_preview', 'manage_code', 'deploy'])
expect(kindsOf('deploy_workspace_item', developer)).toContain('script')
expect(kindsOf('delete_workspace_item', developer)).not.toContain('script')
// Full access narrows nothing and ships the shared object itself, so the tool defs —
// part of every iteration's cached prefix — are byte for byte what they were.
const original = globalTools.find((t) => t.def.function.name === 'deploy_workspace_item')!
expect(shipped('deploy_workspace_item', fullSessionAccess())).toBe(original)
// And narrowing never writes through to it: the def is shared by every session, so a
// mutation here would strip one user's kinds from everyone else's schema.
expect((original.def.function.parameters as any).properties.type.enum).toContain('script')
})
// Naming a tool the model was not given produces invented calls. Asserted over the
// ASSEMBLED message: the session-state, pipeline and plan-mode sections are each
// appended by a different caller, and each can name a withheld tool.
it.each(REACHABLE_PROFILES)(
'never names a withheld tool in the assembled prompt (%s)',
(_label, access) => {
const withheld = withheldNames(access)
// The tool DEFINITIONS ship alongside the prompt, so a withheld name in a
// description is the same broken promise as one in the prompt.
const defs = JSON.stringify(shippedSessionTools(access).map((t) => t.def))
expect(withheld.filter((n) => defs.includes(n))).toEqual([])
for (const previewTools of [false, true]) {
for (const ctx of SESSION_CONTEXTS) {
const msg = assembleGlobalSystemMessage(undefined, {
previewTools,
user: { username: 'alex', folders: ['shared'], folders_read: ['shared'] },
access,
sessionContext: ctx,
pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] }
})
// Both decoration variants: the escalation one adds its own tool mentions.
for (const blocks of [0, 9]) {
const full = appendPlanModeInstructions(msg, blocks).content as string
expect(withheld.filter((n) => full.includes(n))).toEqual([])
}
}
}
}
)
// A tool result is the one place the sweep above cannot see, and `get_instructions`
// ships to every profile with guidance written around the draft tools by name.
it('does not hand authoring guidance naming withheld tools to a session that cannot draft', async () => {
const tool = globalTools.find((t) => t.def.function.name === 'get_instructions')!
const call = (access: SessionAccess) =>
tool.fn({
args: { subject: 'script', language: 'bun' },
workspace: 'ws',
helpers: { access },
toolId: 't1',
toolCallbacks: { setToolStatus: () => {} }
} as any) as Promise<string>
const readOnly = accessWith([])
const withheld = withheldNames(readOnly)
const restricted = await call(readOnly)
expect(withheld.filter((n) => restricted.includes(n))).toEqual([])
// The gate is the whole test, so pin that it is not simply refusing everyone.
expect(await call(fullSessionAccess())).toContain('write_script')
})
// A full-access profile must gate nothing at all: the text has to match the ungated
// build byte for byte, or every session's cached prefix and the ai_evals baseline
// move underneath us.
it('builds an unchanged prompt when every capability is present', () => {
const user = { username: 'alex', folders: ['shared'], folders_read: ['shared'] }
for (const previewTools of [false, true]) {
const ungated = prepareGlobalSystemMessage(undefined, { previewTools, user }).content
const full = prepareGlobalSystemMessage(undefined, {
previewTools,
user,
access: fullSessionAccess()
}).content
expect(full).toBe(ungated)
}
})
})
@@ -0,0 +1,46 @@
import type { ChatCompletionFunctionTool } from 'openai/resources/index.mjs'
import type { SessionAccess, SessionToolPolicy } from '../sessionCapabilities'
type FilterableTool = {
def: ChatCompletionFunctionTool
requires?: SessionToolPolicy
kindRequires?: Readonly<Partial<Record<string, SessionToolPolicy>>>
}
const satisfies = (policy: SessionToolPolicy, access: SessionAccess) =>
policy.every((c) => access.has(c))
/** Fails closed: every array that feeds a session is typed to declare `requires`, so a tool
* without one arrived some other way, and leaking it is the worse of the two answers. */
export function sessionToolAllowed(tool: FilterableTool, access: SessionAccess): boolean {
return tool.requires ? satisfies(tool.requires, access) : false
}
/** Filter an assembled toolset, and cut each kind-taking tool's `type` enum to the kinds
* `access` can land. `access` undefined means "not resolved yet, or not a session" — the
* toolset passes through untouched. */
export function filterSessionTools<T extends FilterableTool>(
tools: T[],
access: SessionAccess | undefined
): T[] {
if (!access) return tools
return tools.filter((t) => sessionToolAllowed(t, access)).map((t) => narrowKinds(t, access))
}
/** Returns the tool itself when every kind is allowed, so full access ships the same bytes:
* tool defs are part of every iteration's cached prefix. Builds new objects otherwise — the
* defs are module singletons shared by every session. */
function narrowKinds<T extends FilterableTool>(tool: T, access: SessionAccess): T {
const kindRequires = tool.kindRequires
if (!kindRequires) return tool
const parameters = tool.def.function.parameters as {
properties: Record<string, { enum?: string[] }>
}
const kinds = parameters.properties.type.enum ?? []
// A kind with no entry is withheld, as a tool with no `requires` is.
const allowed = kinds.filter((k) => kindRequires[k] && satisfies(kindRequires[k], access))
if (allowed.length === kinds.length) return tool
const def = structuredClone(tool.def)
;(def.function.parameters as typeof parameters).properties.type.enum = allowed
return { ...tool, def }
}
@@ -601,7 +601,8 @@ export async function parseOpenAIResponsesCompletion(
toolCall,
helpers,
toolCallbacks: callbacks,
workspace: options?.workspace
workspace: options?.workspace,
messages
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
@@ -1,7 +1,14 @@
import { z } from 'zod'
import { $ScriptLang } from '$lib/gen/schemas.gen'
import type { ScriptLang } from '$lib/gen'
import { createToolDef, executeTestRun, findAndReplace, type Tool } from '../shared'
import { createToolDef, executeTestRun, findAndReplace } from '../shared'
import {
NONE,
RUN_PREVIEW,
WRITE_DRAFT,
type SessionAccess,
type SessionTool
} from '../sessionCapabilities'
import type { PipelineOutputKind } from '$lib/components/assets/AssetGraph/pipelineTemplates'
// ============================================================================
@@ -113,7 +120,7 @@ const readPipelineNodeSchema = z.object({
const readPipelineNodeToolDef = createToolDef(
readPipelineNodeSchema,
'read_pipeline_node',
'Read the full source of one pipeline node (its in-flight draft body if it has unsaved edits, otherwise the deployed body). Use before edit_pipeline_node so edits target the exact current text.'
'Read the full source of one pipeline node (its in-flight draft body if it has unsaved edits, otherwise the deployed body). Use before editing a node so edits target the exact current text.'
)
// ----------------------------------------------------------------------------
@@ -205,8 +212,9 @@ function inferredLineageNote(reads: string[], writes: string[]): string {
return ` Inferred lineage: ${parts.join('; ')}.`
}
export const pipelineTools: Tool<PipelineToolHelpers>[] = [
export const pipelineTools: SessionTool<PipelineToolHelpers>[] = [
{
requires: NONE,
def: getPipelineGraphToolDef,
planModeSafe: true,
fn: async ({ helpers, toolId, toolCallbacks }) => {
@@ -221,6 +229,7 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
}
},
{
requires: NONE,
def: readPipelineNodeToolDef,
planModeSafe: true,
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
@@ -236,6 +245,7 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
}
},
{
requires: WRITE_DRAFT,
def: buildPipelineNodeToolDef,
streamArguments: true,
showDetails: true,
@@ -258,6 +268,7 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
}
},
{
requires: WRITE_DRAFT,
def: editPipelineNodeToolDef,
streamArguments: true,
showDetails: true,
@@ -286,6 +297,7 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
}
},
{
requires: WRITE_DRAFT,
def: removePipelineNodeToolDef,
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
const pipeline = requirePipeline(helpers)
@@ -300,6 +312,7 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
}
},
{
requires: RUN_PREVIEW,
def: testPipelineNodeToolDef,
requiresConfirmation: true,
confirmationMessage: 'Run pipeline node',
@@ -331,8 +344,14 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
* /pipeline editor is open. Describes the annotation model and the direct-draft
* workflow so the model uses the pipeline tools rather than the generic
* write_script draft tools.
*
* `access` is the session's resolved capabilities (undefined outside a session, or
* before they resolve). The node-authoring guidance is dropped without `write_draft`,
* since the tools it names are withheld from that session; the annotation model stays,
* as it is what lets the model read and explain an existing pipeline.
*/
export function getPipelinePromptSection(ctx: PipelineContext): string {
export function getPipelinePromptSection(ctx: PipelineContext, access?: SessionAccess): string {
const canWriteDraft = !access || access.has('write_draft')
return `
Data Pipeline editor (ACTIVE):
@@ -342,8 +361,12 @@ Data Pipeline editor (ACTIVE):
- \`materialize\` (the managed output): a managed \`// materialize ducklake://<name>/<table>\` means the runtime writes the node's output table FOR you — write the body as a single SELECT and the runtime wraps it in the create/replace, so do NOT also write your own CREATE TABLE / INSERT. The \`dbt://\` target below is the opposite: the node writes its own DDL and none of the write strategies apply to it. IMPORTANT: a MANAGED \`// materialize\` is **DuckDB-only** and its target MUST be a DuckLake table (\`ducklake://<name>/<table>\`) — deploy rejects a \`ducklake://\` target on any other language. For a \`python3\`/\`bun\`/\`postgresql\` node writing the lake, do NOT use \`// materialize\`; write the output via the SDK instead (e.g. \`wmill.writeS3File(...)\`, a \`CREATE TABLE\` in postgresql, or \`wmill.databaseUrlFromResource\`/ducklake helpers) and let the output be inferred. Reach for \`duckdb\` when a node should materialize a DuckLake table. The one target any language BUT DBT'S OWN may declare (a dbt project's writes come from its manifest, so \`// materialize\` on a dbt script is rejected at deploy) is a WAREHOUSE RELATION: \`// materialize manual dbt://<warehouse>/<schema>/<name>\`, with \`<warehouse>\` a warehouse the workspace configures under Settings → dbt. \`manual\` is its only mode — nothing generates warehouse DDL, so the node issues its own write and the annotation records the outcome. Use it on an ingestion node a dbt project reads as a \`source\`: the declared relation and the dbt model become ONE graph node, and a downstream \`// on dbt://<warehouse>/<schema>/<name>\` fires when that node completes. Write strategy: with no option it REPLACES the whole table each run (full refresh; the only mode whose output columns may change); \`// materialize <uri> append\` INSERT-appends rows (incremental); \`// materialize <uri> key=<col>\` merges/upserts on \`<col>\`. \`// materialize manual <uri>\` opts OUT of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage. \`materialize\` is paired with partitioning for incremental pipelines: a \`// partitioned <daily|hourly|weekly|monthly|dynamic>\` node runs once per partition (append/merge into a fixed-schema table), and the \`{partition}\` token — usable in any asset URI AND in the body SQL — is substituted with the current partition's IDENTITY string at run time. To filter the source to the active slice on a time grain, use the runtime-injected macro: \`WHERE wm_partition(<ts_col>) = {partition}\`. \`wm_partition(ts)\` buckets a timestamp with the exact identity format the runtime used (daily/hourly/weekly/monthly), so it always matches and you never hand-write a \`strftime\` format. Do NOT write \`= TIMESTAMP {partition}\`: the identity string is not a valid timestamp literal for hourly/weekly/monthly and errors at runtime. For \`dynamic\` partitioning the identity is your caller-supplied key (not a timestamp, no macro), so filter on it directly: \`WHERE <your_key_col> = {partition}\`. \`materialize\` is an output DECLARATION on the node — it is not a command; there is no "materialize run".
- \`measure\` / \`dimension\` (declared metrics): on a node that materializes a DuckLake table, \`// measure <name> = <aggregate> [where <predicate>]\` names the canonical way to aggregate that table (e.g. \`// measure revenue = sum(amount) where not is_refund\`), and \`// dimension <name> = <expr>\` names a way to slice it (e.g. \`// dimension region = region\`, \`// dimension month = date_trunc('month', ordered_at)\`). They execute nothing: they are catalogued at deploy so the editor and other agents can reuse the definition instead of re-deriving it and silently disagreeing. Keep the predicate in the \`where\` clause rather than folding it into the aggregate: it is rendered as \`<agg> FILTER (WHERE <pred>)\`, which is what lets two measures with different predicates sit under one GROUP BY. DuckLake-only, and only meaningful next to \`// materialize\`. Declare one when a number carries a judgement call someone else would get wrong (refunds excluded, test rows dropped, which column is the amount); do NOT blanket every table with measures, an obvious \`count(*)\` earns nothing. To USE a metric another node declares, read that node with read_pipeline_node and reuse its exact expression rather than guessing it.
- Use get_pipeline_graph to see the current nodes/assets/triggers, and read_pipeline_node before editing one.
- Every node of this pipeline lives at \`f/${ctx.folder}/<node_name>\` — \`${ctx.folder}\` is the folder name and \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`<node_name>\`).
- Every node of this pipeline lives at \`f/${ctx.folder}/<node_name>\` — \`${ctx.folder}\` is the folder name and \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`<node_name>\`).${
canWriteDraft
? `
- Build new nodes with build_pipeline_node and edit existing ones with edit_pipeline_node. These apply directly as unsaved drafts on the canvas (like the flow/script editor applies AI edits) — they DO NOT deploy. There is no separate Accept/Reject step. Prefer these over the generic write_script/edit_script draft tools while a pipeline is open.
- Reuse existing asset paths from the graph when wiring a downstream node to an upstream one (read the upstream's write asset, then \`// on\` that same URI).
- Only deploy when the user explicitly asks; the user deploys drafts from the canvas.`
: ''
}`
}
@@ -1,6 +1,7 @@
import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs'
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
import { createToolDef, type Tool, type ToolCallbacks } from './shared'
import { createToolDef, type ToolCallbacks } from './shared'
import { NONE, type SessionTool } from './sessionCapabilities'
import {
appendPlanModeInstructions,
derivePlanTitle,
@@ -100,7 +101,7 @@ export class PlanModeController {
/** Only the transition the current posture allows; auto-accepting exposes neither, since
* entering is the user's choice. */
get tools(): Tool<any>[] {
get tools(): SessionTool<any>[] {
if (!this.#host.available) return []
if (this.#host.autoAccepting) return []
return this.#host.active ? [this.exitTool] : [this.enterTool]
@@ -108,12 +109,16 @@ export class PlanModeController {
/** Both transitions, whichever posture is selected: what plan mode contributes to this
* chat's capabilities, rather than to the turn it is about to send. */
get availableTools(): Tool<any>[] {
get availableTools(): SessionTool<any>[] {
return this.#host.available ? [this.enterTool, this.exitTool] : []
}
// This safety tag is what keeps plan mode escapable through its handoff tool.
exitTool: Tool<any> = {
exitTool: SessionTool<any> = {
// Ungated even for a user who can change nothing: the posture is the USER's choice,
// and its instructions order this call to hand the plan over — withholding it
// strands the model in a round nothing else can end.
requires: NONE,
def: createToolDef(exitPlanModeArgs, EXIT_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL_DESCRIPTION),
planModeSafe: true,
requiresConfirmation: true,
@@ -162,7 +167,8 @@ export class PlanModeController {
}
}
enterTool: Tool<any> = {
enterTool: SessionTool<any> = {
requires: NONE,
def: createToolDef(enterPlanModeArgs, ENTER_PLAN_MODE_TOOL, ENTER_PLAN_MODE_TOOL_DESCRIPTION),
planModeSafe: true,
requiresConfirmation: true,
@@ -9,6 +9,7 @@ import type {
} from 'openai/resources/index.mjs'
import { type DBSchema, dbSchemas } from '$lib/stores'
import type { ContextElement } from '../context'
import { NONE, RUN_PREVIEW, type SessionTool } from '../sessionCapabilities'
import {
createSearchHubScriptsTool,
type Tool,
@@ -462,9 +463,12 @@ export const resourceTypeTool: Tool<ScriptChatHelpers> = {
// Generic DB schema tool factory shared by the script, flow and global modes
export function createDbSchemaTool<T>(
opts: { description?: string; updateEditorCache?: boolean } = {}
): Tool<T> {
): SessionTool<T> {
const { description, updateEditorCache = true } = opts
return {
// `getDbSchemas` below introspects by running a query job through /jobs/run/preview,
// which jobs.rs refuses operators — so this reads like a lookup but gates like a run.
requires: RUN_PREVIEW,
def: description
? {
...DB_SCHEMA_FUNCTION_DEF,
@@ -610,7 +614,8 @@ const SEARCH_NPM_PACKAGES_TOOL: ChatCompletionFunctionTool = {
}
// Helpers-agnostic so both script mode and global mode can offer it.
export const searchNpmPackagesTool: Tool<{}> = {
export const searchNpmPackagesTool: SessionTool<{}> = {
requires: NONE,
def: SEARCH_NPM_PACKAGES_TOOL,
planModeSafe: true,
fn: async ({ args, toolId, toolCallbacks }) => {
@@ -0,0 +1,52 @@
import type { Tool } from './shared'
// A leaf on purpose: every module defining a session-reachable tool imports these
// constants, so importing anything with side effects here drags it into all of them.
/**
* What a user may do in ONE workspace, as an AI session's toolset needs to know it.
* Best-effort, not a boundary — the token is the enforcement point, so this narrows
* what the model is offered and guarantees nothing.
*/
export type SessionCapability =
/** Passes drafts.rs `require_can_write_path` — for a draft of any kind, schedules and
* triggers included, though an operator may create those directly. */
| 'write_draft'
/** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */
| 'run_preview'
/** Passes `check_deploy_rules`. */
| 'deploy'
/** Passes the operator refusal the script, flow and app handlers make before creating or
* deleting one. The same condition as `run_preview` today, but another handler family's
* check, so each follows its own gate if the two ever diverge. */
| 'manage_code'
/** Passes `require_admin`. */
| 'admin'
export type SessionAccess = ReadonlySet<SessionCapability>
/**
* The capabilities without which a tool's call cannot succeed — usually because the
* backend refuses it, occasionally because only that capability produces the tool's
* input. Never a relevance judgement: a tool the server would accept ships, even when
* it is of little use to the session, because withholding it is a stricter answer than
* the one the user would get by trying.
*/
export type SessionToolPolicy = readonly SessionCapability[]
export const NONE: SessionToolPolicy = []
export const WRITE_DRAFT: SessionToolPolicy = ['write_draft']
export const RUN_PREVIEW: SessionToolPolicy = ['run_preview']
export const DEPLOY: SessionToolPolicy = ['deploy']
/**
* A tool that can reach an AI session's toolset. `requires` is mandatory, and declared
* beside the `fn` whose call is what proves it right.
*/
export type SessionTool<T> = Tool<T> & {
requires: SessionToolPolicy
/** For a tool taking the item kind as its `type` argument, where one verdict for the whole
* tool is either too strict or too loose: what each kind's handler needs. A session is
* offered only the kinds it can land, and the rest never appear in the schema it sees. */
kindRequires?: Readonly<Partial<Record<string, SessionToolPolicy>>>
}
@@ -54,7 +54,10 @@ vi.mock('$lib/gen', () => ({
AzureTriggerService: { createAzureTrigger: vi.fn() },
AmqpTriggerService: { createAmqpTrigger: vi.fn() },
EmailTriggerService: { createEmailTrigger: vi.fn() },
SettingService: { getGlobal: vi.fn() }
SettingService: { getGlobal: vi.fn() },
ResourceService: {
getResourceValue: vi.fn(async ({ path }: { path: string }) => ({ content: `body of ${path}` }))
}
}))
vi.mock('$lib/utils', () => ({
@@ -205,6 +208,106 @@ describe('buildContextString', () => {
})
describe('processToolCall', () => {
it('delivers folder instructions once, holding back a change until they are read', async () => {
const { createToolDef, processToolCall } = await import('./shared')
const read = vi.fn(async () => 'read ok')
const write = vi.fn(async () => 'write ok')
const tools = [
{ def: createToolDef(z.object({}), 'read_item', 'Read'), planModeSafe: true, fn: read },
{ def: createToolDef(z.object({}), 'write_item', 'Write'), fn: write }
]
const folderInstructions = {
list: () => [
{ path: 'f/billing/AGENTS', scope: 'f/billing/' },
{ path: 'f/billing/eu/AGENTS', scope: 'f/billing/eu/' },
{ path: 'f/other/AGENTS', scope: 'f/other/' },
{ path: 'g/ops/AGENTS', scope: 'g/ops/' }
],
deliveredBy: new Map<string, { workspace: string; paths: readonly string[] }>()
}
const messages: any[] = []
const call = async (
id: string,
name: string,
path: string | object,
workspace = 'test-workspace'
) => {
const message = await processToolCall({
tools,
toolCall: {
id,
type: 'function',
function: {
name,
arguments: JSON.stringify(typeof path === 'string' ? { path } : path)
}
},
helpers: {},
workspace,
messages,
toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn(), folderInstructions }
})
messages.push(message)
return message.content as string
}
const turn = () => messages.push({ role: 'assistant', content: '' })
turn()
const readResult = await call('c1', 'read_item', 'f/billing/eu/invoice')
expect(read).toHaveBeenCalledTimes(1)
expect(readResult.startsWith('read ok')).toBe(true)
// Outermost first, so the nested folder's instructions read as the specific ones.
expect(readResult.indexOf('body of f/billing/AGENTS')).toBeLessThan(
readResult.indexOf('body of f/billing/eu/AGENTS')
)
// Same batch: the model has not read c1's result yet, so the write still waits,
// pointed at that result rather than handed the bodies twice.
const sameBatch = await call('c2', 'write_item', 'f/billing/eu/invoice')
expect(write).not.toHaveBeenCalled()
expect(sameBatch).not.toContain('body of f/billing/AGENTS')
expect(sameBatch).toContain('earlier result of this same batch')
turn()
expect(await call('c3', 'write_item', 'f/billing/eu/invoice')).toBe('write ok')
const held = await call('c4', 'write_item', 'f/other/x')
expect(write).toHaveBeenCalledTimes(1)
expect(held).toContain('body of f/other/AGENTS')
expect(held).not.toContain('body of f/billing/AGENTS')
turn()
expect(await call('c5', 'write_item', 'f/other/x')).toBe('write ok')
expect(await call('g1', 'read_item', 'g/ops/runbook')).toContain('body of g/ops/AGENTS')
// The same path in another workspace is another resource, delivered afresh.
expect(await call('w1', 'read_item', 'f/other/x', 'other-workspace')).toContain(
'body of f/other/AGENTS'
)
// A delivery lost from the conversation (compaction) is made again.
messages.splice(0)
turn()
expect(await call('c6', 'read_item', 'f/billing/y')).toContain('body of f/billing/AGENTS')
// A trigger names its target inside its config.
messages.splice(0)
turn()
const nested = await call('t1', 'write_item', { kind: 'http', config: { path: 'f/other/t' } })
expect(nested).toContain('body of f/other/AGENTS')
expect(write).toHaveBeenCalledTimes(2)
// A call stopped mid-run is answered with a placeholder under its id: no delivery.
messages.splice(0)
folderInstructions.deliveredBy.set('stopped', {
workspace: 'test-workspace',
paths: ['f/billing/AGENTS']
})
messages.push({ role: 'tool', tool_call_id: 'stopped', content: 'Interrupted' })
turn()
expect(await call('c7', 'read_item', 'f/billing/y')).toContain('body of f/billing/AGENTS')
})
it('returns pre-confirmation validation errors without asking for confirmation', async () => {
const { createToolDef, processToolCall } = await import('./shared')
const error = 'the script needs to be deployed before doing this action'
@@ -7,6 +7,7 @@ import type { UserDraftItemKind } from '$lib/gen'
// The gate's two refusals, from a module that holds prose and one size limit: under the
// shallow-import rule below, the rest of plan mode is not reachable from here.
import { PLAN_MODE_MESSAGES } from './planModeMessages'
import { NONE } from './sessionCapabilities'
// Import-free leaf, so it satisfies the shallow-import rule below.
import {
openItemPreviewAction,
@@ -38,6 +39,7 @@ import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } fro
import { workspaceStore } from '$lib/stores'
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
import { findModuleInFlow, findModuleInModules } from '$lib/components/flows/flowTree'
import { agentTestInputTransforms } from '$lib/components/flows/agentFormFields'
import type { FunctionParameters } from 'openai/resources/shared.mjs'
import { z } from 'zod'
import {
@@ -59,6 +61,7 @@ import { forLater } from '$lib/forLater'
import { scriptLangToEditorLang } from '$lib/scripts'
import { getCurrentModel } from '$lib/aiStore'
import { type editor as meditor } from 'monaco-editor'
import { pendingFolderInstructions, type FolderInstructionsContext } from './folderInstructions'
// Prettify function for code arguments - extracts and formats code from JSON
function prettifyCodeArguments(content: string): string {
@@ -742,6 +745,8 @@ export type ToolDisplayMessage = {
/** Refused by the plan-mode gate. Renders as its own lean row rather than a tool
* error, so the transcript says the mode stopped it and not that the call failed. */
blockedByPlanMode?: boolean
/** Held back until the model has read the folder instructions it was handed. */
heldForFolderInstructions?: boolean
/** The user declined: the reject button, a Stop, or a posture switch. Set only there, so
* a decision is distinguishable from every other way a call errors. */
declinedByUser?: boolean
@@ -974,20 +979,29 @@ function stringifyErrorBody(body: unknown): string {
* (tab closed while a tool polls) logs nothing, so the statuses sum to the calls that
* finished, not to the calls made.
*/
type ToolCallStatus = 'ok' | 'error' | 'declined' | 'rejected' | 'blocked_plan_mode'
type ToolCallStatus =
| 'ok'
| 'error'
| 'declined'
| 'rejected'
| 'blocked_plan_mode'
| 'held_for_instructions'
export async function processToolCall<T>({
tools,
toolCall,
helpers,
toolCallbacks,
workspace
workspace,
messages = []
}: {
tools: Tool<T>[]
toolCall: ChatCompletionMessageFunctionToolCall
helpers: T
toolCallbacks: ToolCallbacks
workspace?: string
/** The conversation so far, for which folder instructions it already carries. */
messages?: readonly ChatCompletionMessageParam[]
}): Promise<ChatCompletionMessageParam> {
const tool = tools.find((t) => t.def.function.name === toolCall.function.name)
const workspaceId = workspace ?? get(workspaceStore) ?? ''
@@ -1071,6 +1085,43 @@ export async function processToolCall<T>({
}
}
// After the gates that refuse the call, so a refused call does not use up the
// delivery. A call that changes something is held back until the model has read
// the instructions: arriving with its result, they would be too late to shape it.
const folderInstructions = tool
? await pendingFolderInstructions(
toolCallbacks.folderInstructions,
messages,
args,
workspaceId
)
: undefined
if (folderInstructions?.paths.length) {
toolCallbacks.folderInstructions?.deliveredBy.set(toolCall.id, {
workspace: workspaceId,
paths: folderInstructions.paths
})
}
if (folderInstructions && tool?.planModeSafe !== true) {
logToolOutcome('held_for_instructions')
toolCallbacks.setToolStatus(toolCall.id, {
content: 'Read folder instructions',
heldForFolderInstructions: true,
parameters: args,
isLoading: false,
isQueued: false,
isStreamingArguments: false,
needsConfirmation: false,
showDetails: tool?.showDetails,
autoCollapseDetails: tool?.autoCollapseDetails
})
return {
role: 'tool' as const,
tool_call_id: toolCall.id,
content: `Not run: this call touches a folder whose instructions you had not read yet. Follow them, then make the call again (adjusted if they require it).\n\n${folderInstructions.text}`
}
}
const requiresConfirmation = tool?.requiresConfirmation === true
// By name: skipping the wait is itself an answer on the user's behalf, and one tool
// must not be answered for.
@@ -1175,7 +1226,7 @@ export async function processToolCall<T>({
const toAdd = {
role: 'tool' as const,
tool_call_id: toolCall.id,
content: result
content: folderInstructions ? `${result}\n\n${folderInstructions.text}` : result
}
return toAdd
} catch (err) {
@@ -1249,7 +1300,10 @@ export interface Tool<T> {
workspace: string
helpers: T
}) => MaybePromise<ToolRejection | undefined>
setSchema?: (helpers: any) => Promise<void>
/** This chat's definition of the tool, for a schema that depends on the chat (its workspace,
* its open flow). Returns a new def: tools are module singletons shared by every chat, so
* one written back leaks into the others. */
schemaFor?: (helpers: any) => Promise<ChatCompletionFunctionTool>
/** Safe to run while plan mode is active. Absence fails closed. */
planModeSafe?: boolean
/** The arguments a plan-mode-safe tool still refuses while the posture holds — for a tool
@@ -1450,6 +1504,9 @@ export interface ToolCallbacks {
attachToolImage?: (toolId: string, image: AttachedImage) => void
/** Drain every image buffered this batch (insertion order), clearing the buffer. */
takePendingToolImages?: () => AttachedImage[]
/** Wired only by the global/sessions chat: delivers the folder instructions
* covering the paths a tool call names, once per conversation. */
folderInstructions?: FolderInstructionsContext
}
export function createToolDef(
@@ -1549,6 +1606,7 @@ export function isHubPath(path: string): boolean {
}
export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
requires: NONE,
def: searchHubScriptsToolDef,
planModeSafe: true,
fn: async ({ args, toolId, toolCallbacks }) => {
@@ -2155,6 +2213,13 @@ type FlowStepRunConfig = {
toolId: string
loadScript?: FlowStepScriptLoader
loadSubflow?: FlowStepSubflowLoader
/** The path of the flow the step belongs to. An agent step's preview carries it, as the flow
* editor's own step test does: the run is filed under it, and a managed memory keyed on a
* memory id someone named reads the flow's conversation rather than a stray one. */
flowPath?: string
/** The flow editor's substitution of a linked agent's unsaved draft, passed in because it
* reaches the draft store, which this module's import list must not. */
withAgentDrafts?: (value: FlowValue) => Promise<FlowValue>
}
export type FlowStepTestRunConfig = FlowStepRunConfig & {
@@ -2179,8 +2244,9 @@ export type ResolvedFlowStepRun = {
startMessage: string
/** Takes the arguments as submitted. The preprocessor's entrypoint override is added
* here rather than by the caller: it is declared by no schema, so anything that
* conforms arguments to one would drop it. */
startJob: (args: Record<string, any>) => Promise<string>
* conforms arguments to one would drop it. An agent step also takes the run form's rows:
* each runs from its submitted value, including one the submission leaves out. */
startJob: (args: Record<string, any>, formKeys?: readonly string[]) => Promise<string>
}
function normalizeFlowStepArgs(args: Record<string, any> | null | undefined): Record<string, any> {
@@ -2224,7 +2290,9 @@ export async function resolveFlowStepRun({
toolCallbacks,
toolId,
loadScript = loadDeployedScriptForFlowStep,
loadSubflow
loadSubflow,
flowPath,
withAgentDrafts
}: FlowStepRunConfig): Promise<ResolvedFlowStepRun> {
const targetModule = findModuleInFlow(flowValue, stepId) ?? undefined
@@ -2311,12 +2379,51 @@ export async function resolveFlowStepRun({
}
}
if (moduleValue.type === 'aiagent') {
return {
module: targetModule,
// The job started is a flow preview: an agent step has none of its own, and the tool
// calls it makes are that flow's steps.
runnableKind: 'flow',
startMessage: `Starting test run of agent step "${stepId}"...`,
startJob: async (args, formKeys) => {
// The step alone, stripped of the settings around it: a mock would answer for it
// and a suspend would hold the run for an approval nobody is watching for.
const value: FlowValue = {
modules: [
{
id: targetModule.id,
value: {
...moduleValue,
input_transforms: agentTestInputTransforms(
moduleValue.input_transforms,
args,
formKeys
)
}
}
]
}
return JobService.runFlowPreview({
workspace,
requestBody: {
// After the inputs are wired, as the flow editor's step test does: a linked agent's
// draft brings its own brain and tools, and the step's run inputs stay on top.
value: withAgentDrafts ? await withAgentDrafts(value) : value,
args,
path: flowPath
}
})
}
}
}
toolCallbacks.setToolStatus(toolId, {
content: `Step type "${moduleValue.type}" not supported for testing`,
error: `Cannot test step of type "${moduleValue.type}"`
})
throw new Error(
`Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow`
`Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow, aiagent`
)
}
+2 -1
View File
@@ -1433,7 +1433,8 @@ export async function parseOpenAICompletion(
toolCall,
helpers,
toolCallbacks: callbacks,
workspace: options?.workspace
workspace: options?.workspace,
messages
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
@@ -6,6 +6,7 @@ import {
agentMemoryMode,
historyInputApplies,
agentFieldIsSet,
agentTestInputTransforms,
initialVisibleAgentFields
} from './agentFormFields'
@@ -147,3 +148,23 @@ describe('memoryPropertyFor', () => {
).toBeUndefined()
})
})
describe('agentTestInputTransforms', () => {
it('unsets a blank history input and feeds every other form key from flow_input', () => {
const authored = {
memory_id: { type: 'static' as const, value: 'thread-1' },
previous_messages: { type: 'static' as const, value: [{ role: 'user', content: 'hi' }] },
system_prompt: { type: 'javascript' as const, expr: 'results.a.prompt' }
}
expect(
agentTestInputTransforms(authored, { memory_id: '', previous_messages: [{ role: 'user' }] }, [
'memory_id',
'previous_messages',
'system_prompt'
])
).toEqual({
previous_messages: { type: 'javascript', expr: 'flow_input.previous_messages' },
system_prompt: { type: 'javascript', expr: 'flow_input.system_prompt' }
})
})
})
@@ -30,6 +30,28 @@ export const AGENT_TOOLS_ROW = 'tools'
export const AGENT_HISTORY_KEYS = ['memory_id', 'previous_messages'] as const
export type AgentHistoryKey = (typeof AGENT_HISTORY_KEYS)[number]
/** The input transforms a one-off test of an agent step runs with: each form key is fed from
* `flow_input`, over the step's own transforms. A blank history input is unset, as on the step:
* an expression evaluating to nothing reads as an empty memory id, and the step's transform is
* stale. */
export function agentTestInputTransforms(
stepTransforms: Record<string, InputTransform> | undefined,
args: Record<string, unknown>,
formKeys: readonly string[] = Object.keys(args)
): Record<string, InputTransform> {
const isBlank = (v: unknown) => v == undefined || v === '' || (Array.isArray(v) && !v.length)
const kept = (key: string) =>
!(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key])
return {
...Object.fromEntries(Object.entries(stepTransforms ?? {}).filter(([key]) => kept(key))),
...Object.fromEntries(
formKeys
.filter(kept)
.map((key) => [key, { type: 'javascript' as const, expr: `flow_input.${key}` }])
)
}
}
/** What turning managed memory on writes. */
export const DEFAULT_AGENT_MEMORY: MemoryConfig = { kind: 'window', context_length: 10 }
@@ -126,6 +126,11 @@ export function toolEnabledName(tool: AgentTool): string | undefined {
return toolDisplayName(tool)
}
/** The names `enabled_tools` can pick from: every tool in the roster that has one. */
export function enabledToolNames(tools: AgentTool[]): string[] {
return tools.map((tool) => toolEnabledName(tool)).filter((name): name is string => !!name)
}
/**
* Create an AI Agent tool (nested agent)
*/
@@ -48,7 +48,7 @@
import { AlertTriangle, Plus, X } from 'lucide-svelte'
import type { PickableProperties } from '../previousResults'
import type { FlowCopilotContext } from '$lib/components/copilot/flow'
import { toolEnabledName, type AgentTool } from '../agentToolUtils'
import { enabledToolNames, type AgentTool } from '../agentToolUtils'
import {
AGENT_FIELDS,
AGENT_FIELD_BY_KEY,
@@ -216,9 +216,7 @@
// By what each tool is named, not the summary alone: an MCP entry is added without one and is
// named by its resource path, so keying on `summary` would leave a whole server with no name
// to pick. `narrow_roster` matches that path for the same reason.
const names = tools
.map((tool) => toolEnabledName(tool))
.filter((name): name is string => !!name)
const names = enabledToolNames(tools)
const properties = schemaProperties
untrack(() => {
const list = properties['enabled_tools']
@@ -0,0 +1,263 @@
<!--
@component
Lists what pushing the workspace would commit, and pushes it. The host owns the
buttons — the setup dialog puts them in its own footer, the standalone modal in its
— and drives them through `apply()` and `status()`.
-->
<script lang="ts">
import { untrack } from 'svelte'
import { Alert, Button, CopyButton } from '$lib/components/common'
import { Loader2, RotateCw, XCircle, Terminal, ChevronDown, ChevronUp } from 'lucide-svelte'
import GitDiffPreview from '../GitDiffPreview.svelte'
import { JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
import { jobManager } from '$lib/services/JobManager'
import type { SyncResponse, SettingsObject } from '$lib/git-sync'
interface Props {
gitRepoResourcePath: string
uiState: SettingsObject
onSuccess?: () => void
/** Told when a push starts and ends, so a host that can outlive this component knows a
* push is in flight — this one is destroyed the moment its dialog closes. */
onApplyStateChange?: (applying: boolean) => void
}
let { gitRepoResourcePath, uiState, onSuccess, onApplyStateChange }: Props = $props()
let previewJobId = $state<string | null>(null)
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isPreviewLoading = $state(false)
let previewError = $state('')
let applyJobId = $state<string | null>(null)
let applyJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isApplying = $state(false)
let applyError = $state('')
let showCliInstructions = $state(false)
const cliInstructions = $derived(`# Setup (only needed if local folder not initialized yet)
npm install -g windmill-cli
wmill workspace add ${$workspaceStore} ${$workspaceStore} ${window.location.origin}
wmill init --workspace ${$workspaceStore} --repository ${gitRepoResourcePath}
# Pull workspace content to git repository
wmill sync pull --workspace ${$workspaceStore} --repository ${gitRepoResourcePath}`)
let previewResult = $state<SyncResponse | null>(null)
/** What the host needs to label and enable its buttons. */
export function status() {
return {
previewing: isPreviewLoading,
applying: isApplying,
// Nothing to push is not worth a push job.
canApply:
!!previewResult && !previewError && !isApplying && (previewResult.changes?.length ?? 0) > 0
}
}
export function apply() {
return executeJob(false)
}
// The list of changes is what this is for, so it loads on its own rather than on a button.
$effect(() => {
gitRepoResourcePath
untrack(() => void executeJob(true))
})
async function executeJob(isDryRun: boolean) {
const isPreview = isDryRun
if (isPreview) {
isPreviewLoading = true
previewError = ''
previewResult = null
previewJobId = null
previewJobStatus = undefined
} else {
isApplying = true
onApplyStateChange?.(true)
applyError = ''
applyJobId = null
applyJobStatus = undefined
}
try {
const workspace = $workspaceStore
if (!workspace) return
const payload = {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
dry_run: isDryRun,
pull: false,
settings_json: JSON.stringify(uiState)
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payload,
skipPreprocessor: true
})
if (isPreview) {
previewJobId = jobId
previewJobStatus = 'running'
} else {
applyJobId = jobId
applyJobStatus = 'running'
}
const result = await jobManager.runWithProgress(() => Promise.resolve(jobId), {
workspace,
timeout: 60000,
timeoutMessage: `${isPreview ? 'Preview' : 'Apply'} job timed out after 60s`,
onProgress: (status) => {
if (isPreview) {
previewJobStatus = status.status
} else {
applyJobStatus = status.status
}
if (status.status === 'failure') {
if (isPreview) {
previewError = status.error || 'Listing the changes failed'
} else {
applyError = status.error || 'Push failed'
}
}
}
})
if (isPreview) {
if (previewJobStatus === 'success') {
previewResult = result as SyncResponse
}
} else {
if (applyJobStatus === 'success') {
onSuccess?.()
}
}
} catch (e) {
const errorMsg = e?.message || 'Operation failed'
if (isPreview) {
previewJobStatus = 'failure'
previewError = errorMsg
} else {
applyJobStatus = 'failure'
applyError = errorMsg
}
} finally {
if (isPreview) {
isPreviewLoading = false
} else {
isApplying = false
onApplyStateChange?.(false)
}
}
}
</script>
<div class="flex flex-col gap-4 h-full">
{#if isPreviewLoading}
<div class="flex-1 min-h-24 flex flex-col items-center justify-center gap-2 text-sm text-hint">
<Loader2 size={36} class="animate-spin" />
Listing the changes to push...
</div>
{:else}
<p class="text-xs text-secondary">
Here is what will get pushed to the repository, according to the filters you set.
</p>
{/if}
{#if previewError}
<Alert type="error" title="Could not list the changes">
<div class="flex flex-col items-start gap-2">
<span>{previewError}</span>
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: RotateCw }}
onClick={() => void executeJob(true)}
>
Retry
</Button>
</div>
</Alert>
{/if}
{#if previewResult && !previewError}
<div class="flex flex-col gap-2 flex-1 min-h-0">
<h4 class="text-sm font-semibold text-primary">Changes to push</h4>
{#if previewResult.changes?.length > 0}
<GitDiffPreview {previewResult} heightClass="flex-1 min-h-0" />
{:else}
<div class="text-sm text-secondary">
The repository already matches this workspace: there is nothing to push.
</div>
{/if}
</div>
{/if}
{#if applyError}
<Alert type="error" title="Push failed">
{applyError}
</Alert>
{/if}
<div class="mt-auto flex flex-col gap-2">
{#each [{ id: previewJobId, status: previewJobStatus, label: 'Changes job', running: isPreviewLoading }, { id: applyJobId, status: applyJobStatus, label: 'Push job', running: isApplying }] as job (job.label)}
<!-- The row is held from the moment the job is started, since its id only arrives once
the server has taken it; it goes once the job succeeded, its outcome being on
screen already. -->
{#if job.running || (job.id && job.status !== 'success')}
<div class="flex items-center gap-2 min-h-4 text-2xs text-secondary">
{#if job.status === 'running'}
<Loader2 class="animate-spin" size={12} />
{:else if job.status === 'failure'}
<XCircle size={12} class="text-red-700" />
{/if}
{#if job.id}
<span class="text-hint">{job.label}:</span>
<a
target="_blank"
class="underline"
href={`/run/${job.id}?workspace=${$workspaceStore}`}
>
{job.id}
</a>
{/if}
</div>
{/if}
{/each}
<!-- Nothing to copy commands for until the changes are known. -->
<div class={isPreviewLoading ? 'hidden' : ''}>
<div class="flex">
<Button
variant="subtle"
unifiedSize="xs"
startIcon={{ icon: Terminal }}
endIcon={{ icon: showCliInstructions ? ChevronUp : ChevronDown }}
onClick={() => (showCliInstructions = !showCliInstructions)}
>
CLI instructions
</Button>
</div>
{#if showCliInstructions}
<div class="relative mt-2">
<CopyButton value={cliInstructions} class="absolute top-1 right-1" />
<pre
class="text-xs bg-surface-sunken rounded-lg p-3 pr-10 overflow-x-auto whitespace-pre-wrap break-all"
>{cliInstructions}</pre
>
</div>
{/if}
</div>
</div>
</div>
@@ -1,5 +1,5 @@
import { getContext, setContext } from 'svelte'
import { enterpriseLicense, userStore } from '$lib/stores'
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { get } from 'svelte/store'
import { sendUserToast } from '$lib/toast'
import { apiErrorMessage } from '$lib/utils'
@@ -46,7 +46,15 @@ export type GitSyncSettings = {
export type ModalState = {
push: { idx: number; repo: GitSyncRepository; open: boolean } | null
pull: { idx: number; repo: GitSyncRepository; open: boolean; settingsOnly?: boolean } | null
success: { open: boolean; savedWithoutInit?: boolean; autoPullOn?: boolean } | null
success: {
open: boolean
savedWithoutInit?: boolean
autoPullOn?: boolean
/** Why pulling fell back to polling, when it did. */
webhookError?: string
/** Whether this workspace is the one that would turn pulling on for this repository. */
ownsAutoPull?: boolean
} | null
}
export type ValidationState = {
@@ -202,6 +210,11 @@ export function createGitSyncContext(workspace: string) {
'flow',
'app',
'folder',
'resource',
'resourcetype',
'variable',
'schedule',
'trigger',
'workspacedependencies',
'datatablemigration'
]
@@ -290,8 +303,23 @@ export function createGitSyncContext(workspace: string) {
closeModal('pull')
}
function showSuccessModal(savedWithoutInit?: boolean, autoPullOn?: boolean) {
activeModals.success = { open: true, savedWithoutInit, autoPullOn }
/** Only a sync repository in a non-fork workspace has a pull toggle of its own: promotion
* repositories have none, and a fork's pulling is its parent's to set. */
function ownsAutoPull(repo: GitSyncRepository): boolean {
const ws = get(workspaceStore)
const isFork =
(ws?.startsWith('wm-fork-') ?? false) ||
!!get(userWorkspaces)?.find((w) => w.id === ws)?.parent_workspace_id
return !repo.use_individual_branch && !isFork
}
function showSuccessModal(
savedWithoutInit?: boolean,
autoPullOn?: boolean,
ownsAutoPull?: boolean,
webhookError?: string
) {
activeModals.success = { open: true, savedWithoutInit, autoPullOn, ownsAutoPull, webhookError }
}
function closeSuccessModal() {
@@ -314,8 +342,11 @@ export function createGitSyncContext(workspace: string) {
repo.detectionJobId = undefined
repo.detectionJobStatus = undefined
// Track the detection timestamp to avoid race conditions from old jobs
// Track the detection timestamp to avoid race conditions from old jobs. Stamped before
// the request, or a failure to even start the job reads as superseded and leaves the
// repository loading for good.
const detectionTimestamp = Date.now()
repo._detectionTimestamp = detectionTimestamp
try {
const jobId = await JobService.runScriptByPath({
@@ -335,7 +366,6 @@ export function createGitSyncContext(workspace: string) {
repo.detectionJobId = jobId
repo.detectionJobStatus = 'running'
repo._detectionTimestamp = detectionTimestamp
// Use JobManager for polling - result will be the actual job response
await jobManager.runWithProgress(() => Promise.resolve(jobId), {
@@ -506,7 +536,9 @@ export function createGitSyncContext(workspace: string) {
}
}
async function saveRepository(idx: number, savedWithoutInit = false) {
/** `announce` off for a caller that reports the save itself — the setup dialog ends on a
* step saying the same thing, and a modal over it would say it twice. */
async function saveRepository(idx: number, savedWithoutInit = false, announce = true) {
const repo = repositories[idx]
if (!repo || !validateRepository(repo, idx)) {
throw new Error('Cannot save invalid repository')
@@ -539,7 +571,8 @@ export function createGitSyncContext(workspace: string) {
if (repoToSave.auto_pull) {
repoToSave.auto_pull = {
...repoToSave.auto_pull,
enabled_by: repoToSave.auto_pull.enabled ? get(userStore)?.email : undefined
enabled_by: repoToSave.auto_pull.enabled ? get(userStore)?.email : undefined,
webhook_error: await readWebhookError(repoToSave)
}
}
@@ -553,7 +586,31 @@ export function createGitSyncContext(workspace: string) {
repoToSave.detectionState = undefined
repoToSave.extractedSettings = undefined
// Show success modal for new connections
showSuccessModal(savedWithoutInit, repoToSave.auto_pull?.enabled === true)
if (announce) {
showSuccessModal(
savedWithoutInit,
repoToSave.auto_pull?.enabled === true,
ownsAutoPull(repoToSave),
repoToSave.auto_pull?.webhook_error
)
}
}
}
/** The webhook is registered after the settings commit, best-effort, and a failure is
* reported only through the stored settings — so it stays invisible until they are read
* back. Without this, pulling reads as working when it has silently fallen back to
* polling (an app missing the webhook permission, an instance GitHub cannot reach). */
async function readWebhookError(repo: GitSyncRepository): Promise<string | undefined> {
if (!repo.auto_pull?.enabled) return undefined
try {
const settings = await WorkspaceService.getSettings({ workspace })
return settings.git_sync?.repositories?.find(
(r) => r.git_repo_resource_path.replace('$res:', '') === repo.git_repo_resource_path
)?.auto_pull?.webhook_error
} catch {
// Only costs the warning; the card shows it on the next load.
return undefined
}
}
@@ -704,6 +761,11 @@ export function createGitSyncContext(workspace: string) {
'flow',
'app',
'folder',
'resource',
'resourcetype',
'variable',
'schedule',
'trigger',
'workspacedependencies',
'datatablemigration'
]
@@ -712,9 +774,10 @@ export function createGitSyncContext(workspace: string) {
legacyImported: false,
isUnsavedConnection: true,
collapsed: false
// Pull-from-Git defaults are applied by the repository card once the
// selected resource resolves: only app-backed repos (instant webhook
// delivery) default to auto-pull on; polling is opt-in for token repos.
// Pull-from-Git defaults are applied once the selected resource resolves, by
// whoever is configuring the connection (see applyNewConnectionDefaults): only
// app-backed repos (instant webhook delivery) default to auto-pull on; polling is
// opt-in for token repos.
})
gitSyncTestJobs.push({
jobId: '',
@@ -741,6 +804,11 @@ export function createGitSyncContext(workspace: string) {
'flow',
'app',
'folder',
'resource',
'resourcetype',
'variable',
'schedule',
'trigger',
'workspacedependencies',
'datatablemigration'
]
@@ -111,5 +111,7 @@
bind:open={gitSyncContext.activeModals.success.open}
savedWithoutInit={gitSyncContext.activeModals.success.savedWithoutInit}
autoPullOn={gitSyncContext.activeModals.success.autoPullOn}
explainAutoPullOff={gitSyncContext.activeModals.success.ownsAutoPull ?? false}
webhookError={gitSyncContext.activeModals.success.webhookError}
/>
{/if}
@@ -1,7 +1,6 @@
<script lang="ts">
import {
Save,
Trash,
XCircle,
CheckCircle2,
RotateCw,
@@ -15,9 +14,9 @@
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import GitSyncFilterSettings from '$lib/components/workspaceSettings/GitSyncFilterSettings.svelte'
import DetectionFlow from './DetectionFlow.svelte'
import { applyNewConnectionDefaults } from './setup/connectionDefaults'
import { sendUserToast } from '$lib/toast'
import { apiErrorMessage } from '$lib/utils'
import { fade } from 'svelte/transition'
import { workspaceStore, userWorkspaces, enterpriseLicense } from '$lib/stores'
import type { GitSyncRepository } from './GitSyncContext.svelte'
import GitSyncModeDisplay from './GitSyncModeDisplay.svelte'
@@ -56,7 +55,6 @@
const repo = $derived(repository || (idx !== null ? gitSyncContext.getRepository(idx) : null))
const validation = $derived(idx !== null ? gitSyncContext.getValidation(idx) : null)
const gitSyncTestJob = $derived(idx !== null ? gitSyncContext.gitSyncTestJobs?.[idx] : null)
let confirmingDelete = $state(false)
function pullStatusDate(status: { at: number }): string {
return new Date(status.at * 1000).toISOString()
@@ -303,38 +301,13 @@
// Extract git URL from resource value
const value = resource.value as Record<string, any>
isGithubApp = value?.is_github_app === true
// A newly added sync connection defaults to pulling from Git only
// when the repository is app-backed (instant webhook delivery).
// Polling is opt-in for token repositories, and fork/dev workspaces
// never get the parent-only defaults (the backend rejects them).
// EE-only.
if (
repoMode === 'sync' &&
repo.isUnsavedConnection &&
hasManagedCredential &&
!isFork &&
$enterpriseLicense &&
repo.auto_pull === undefined
) {
repo.auto_pull = { enabled: true, mode: 'auto', sync_forks: true }
}
// Promotion deploys push wm_deploy/** branches that exist to be
// merged; without a PR the deploy is an orphaned branch. Default
// the managed PR on where Windmill can open it (app-backed).
// Fork PRs stay opt-in everywhere.
if (
repoMode === 'promotion' &&
repo.isUnsavedConnection &&
hasManagedCredential &&
$enterpriseLicense &&
repo.promotion_open_prs === undefined
) {
repo.promotion_open_prs = true
}
// Webhook with polling fallback is the only delivery for app repos.
if (isGithubApp && repo.auto_pull?.mode === 'polling') {
repo.auto_pull = { ...repo.auto_pull, mode: 'auto' }
}
applyNewConnectionDefaults(repo, {
mode: repoMode,
managedCredential: hasManagedCredential,
isGithubApp,
isFork,
ee: !!$enterpriseLicense
})
let gitUrl = value?.url || value?.git_url
if (gitUrl && typeof gitUrl === 'string') {
@@ -479,27 +452,6 @@
}
}
function initiateDelete() {
confirmingDelete = true
}
async function confirmDelete() {
if (idx === null) return
try {
await gitSyncContext.removeRepository(idx)
sendUserToast('Repository connection removed successfully')
} catch (error: any) {
console.error('Failed to remove repository:', error)
sendUserToast('Failed to remove repository: ' + apiErrorMessage(error), true)
} finally {
confirmingDelete = false
}
}
function cancelDelete() {
confirmingDelete = false
}
function runGitSyncTestJob() {
if (idx !== null && gitSyncContext.runTestJob) {
gitSyncContext.runTestJob(idx)
@@ -566,36 +518,6 @@
{/if}
</button>
{/if}
{#if !confirmingDelete}
<div transition:fade|local={{ duration: 100 }}>
<Button
size="xs"
variant="default"
onclick={initiateDelete}
startIcon={{ icon: Trash }}
destructive
>
Delete
</Button>
</div>
{:else}
<div class="flex gap-1">
<button
transition:fade|local={{ duration: 100 }}
class="px-3 py-1 text-xs bg-red-500 text-white rounded duration-200 hover:bg-red-600"
onclick={confirmDelete}
>
Confirm delete
</button>
<button
transition:fade|local={{ duration: 100 }}
class="px-2 py-1 text-xs bg-surface-secondary rounded duration-200 hover:bg-surface-hover"
onclick={cancelDelete}
>
<XCircle size={12} />
</button>
</div>
{/if}
{/snippet}
{#snippet repositoryContent()}
@@ -675,7 +597,7 @@
</div>
{/if}
{#if gitSyncTestJob && gitSyncTestJob.status !== undefined}
<div class="flex text-sm gap-1 items-center">
<div class="flex text-xs gap-1 items-center">
{#if gitSyncTestJob.status === 'running'}
<RotateCw size={14} class="animate-spin" />
{:else if gitSyncTestJob.status === 'success'}
@@ -1,14 +1,21 @@
<script lang="ts">
import { ExternalLink, ChevronDown, ChevronRight, Plus } from 'lucide-svelte'
import { Button, Alert } from '$lib/components/common'
import { ExternalLink, Plus, Settings, GitBranch, Trash } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { Button, Alert, Badge, Drawer, DrawerContent } from '$lib/components/common'
import GitSyncSetupModal from './GitSyncSetupModal.svelte'
import EEOnly from '$lib/components/EEOnly.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
import { setGitSyncContext } from './GitSyncContext.svelte'
import GitSyncRepositoryCard from './GitSyncRepositoryCard.svelte'
import type { GitSyncRepository } from './GitSyncContext.svelte'
import GitSyncModalManager from './GitSyncModalManager.svelte'
import { enterpriseLicense, workspaceStore, userWorkspaces } from '$lib/stores'
import { base } from '$lib/base'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { apiErrorMessage } from '$lib/utils'
import { untrack } from 'svelte'
// Create context reactively based on workspaceStore
@@ -78,14 +85,12 @@
// A dev workspace reuses the single repo it inherited from prod: whether it's
// currently in sync or promotion mode, it's the same one card, toggled between
// the two — so a dev never configures a separate promotion repo.
const devPrimaryRepo = $derived(isDevWorkspace ? (primarySync ?? primaryPromotion) : null)
// The single-repo dev UX (one card + promotion toggle, secondaries hidden) is
// only safe when the dev actually has one repo — i.e. it inherited prod's on
// fork. An ATTACHED dev keeps its own repos: with more than one, fall back to
// the normal layout so none are hidden and we don't present an unrelated repo
// as prod's promotion target.
const devSingleRepo = $derived(isDevWorkspace && (gitSyncContext?.repositories?.length ?? 0) <= 1)
const secondarySync = $derived(gitSyncContext?.getSecondarySyncRepositories() || [])
const secondaryPromotion = $derived(gitSyncContext?.getSecondaryPromotionRepositories() || [])
// Fork creation keeps only sync-mode repositories and a fork is refused a
// promotion one, so the single way a fork holds one is a dev workspace
@@ -104,15 +109,106 @@
return `${base}/workspace_settings?workspace=${parent}&tab=dev_workspace`
})
// State for collapsible sections
let secondarySyncExpanded = $state(false)
let secondaryPromotionExpanded = $state(false)
// Check if any secondary repositories are unsaved
const hasUnsavedSecondary = $derived(secondarySync.some((s) => s.repo.isUnsavedConnection))
const hasUnsavedSecondaryPromotion = $derived(
secondaryPromotion.some((s) => s.repo.isUnsavedConnection)
const repositories = $derived(gitSyncContext?.repositories ?? [])
const rows = $derived(repositories.map((repo, idx) => ({ repo, idx })))
const syncRows = $derived(rows.filter((r) => !r.repo.use_individual_branch))
const promotionRows = $derived(rows.filter((r) => r.repo.use_individual_branch))
const hasUnsavedConnection = $derived(repositories.some((r) => r.isUnsavedConnection))
const usedResourcePaths = $derived(
repositories.map((r) => r.git_repo_resource_path).filter((p) => !!p?.trim())
)
/** The repository the delete confirmation is about, kept with its path so a list that
* shifted under it is caught rather than removing the wrong one. */
let deleting: { idx: number; path: string } | undefined = $state(undefined)
let deletingBusy = $state(false)
async function confirmDelete() {
if (!deleting || !gitSyncContext) return
const { idx, path } = deleting
if (gitSyncContext.repositories[idx]?.git_repo_resource_path !== path) {
deleting = undefined
return
}
deletingBusy = true
try {
await gitSyncContext.removeRepository(idx)
sendUserToast('Repository connection removed')
deleting = undefined
} catch (e) {
sendUserToast('Failed to remove the repository: ' + apiErrorMessage(e), true)
} finally {
deletingBusy = false
}
}
let setupMode = $state<'sync' | 'promotion'>('sync')
let setupOpen = $state(false)
// Fixed when the dialog opens: the dialog adds its own unsaved repository to the list.
let setupTitle = $state('')
function openSetup(mode: 'sync' | 'promotion') {
setupMode = mode
setupTitle =
mode === 'promotion'
? primaryPromotion
? 'Add a secondary promotion repository'
: 'Add a promotion repository'
: primarySync
? 'Add a secondary sync repository'
: repositories.length === 0
? 'Configure Git Sync'
: 'Add a sync repository'
setupOpen = true
}
// Keyed by resource path rather than index: deleting a repository shifts the
// indexes, and reloading the settings replaces the objects.
let settingsPath: string | undefined = $state(undefined)
const settingsIdx = $derived(
settingsPath === undefined
? -1
: repositories.findIndex((r) => r.git_repo_resource_path === settingsPath)
)
let settingsDrawer: Drawer | undefined = $state(undefined)
$effect(() => {
if (settingsPath !== undefined && settingsIdx === -1)
untrack(() => settingsDrawer?.closeDrawer())
})
function openSettings(path: string) {
settingsPath = path
settingsDrawer?.openDrawer()
}
function cardProps(idx: number) {
const repo = repositories[idx]
if (devSingleRepo) {
return {
variant: 'primary-sync' as const,
mode: repo?.use_individual_branch ? ('promotion' as const) : ('sync' as const),
devPromotion: !!$enterpriseLicense
}
}
if (idx === primarySync?.idx) return { variant: 'primary-sync' as const, mode: 'sync' as const }
if (idx === primaryPromotion?.idx)
return { variant: 'primary-promotion' as const, mode: 'promotion' as const }
return { variant: 'secondary' as const, isSecondary: true }
}
function rowLabel(idx: number): string {
const repo = repositories[idx]
const promotion = !!repo?.use_individual_branch
if (devSingleRepo) return promotion ? 'Git Promotion' : 'Git Sync'
const primary = idx === primarySync?.idx || idx === primaryPromotion?.idx
return `${primary ? 'Primary' : 'Secondary'} ${promotion ? 'promotion' : 'sync'} repository`
}
// Shown without EE too, disabled, so CE users see what the upgrade unlocks. Offered with
// no sync repository as well: a workspace that configured promotion first still has none.
const showAddSync = $derived(!devSingleRepo && !hasUnsavedConnection)
const addSyncLabel = $derived(
primarySync ? 'Add secondary sync repository' : 'Add sync repository'
)
const showAddPromotion = $derived(showPromotion && !devSingleRepo && !hasUnsavedConnection)
</script>
{#if !gitSyncContext}
@@ -132,7 +228,7 @@
{#snippet actions()}
{#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined}
<Button
variant="accent"
variant={repositories.length > 0 ? 'accent' : 'default'}
target="_blank"
endIcon={{ icon: ExternalLink }}
href={`/runs?job_kinds=deploymentcallbacks&workspace=${$workspaceStore}`}
@@ -166,161 +262,82 @@
<div class="mb-2"></div>
{/if}
{#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined}
<!-- Primary Sync Repository -->
<div class="space-y-6 pt-6">
<GitSyncRepositoryCard
variant="primary-sync"
mode={devSingleRepo && devPrimaryRepo?.repo?.use_individual_branch ? 'promotion' : 'sync'}
idx={(devSingleRepo ? devPrimaryRepo : primarySync)?.idx ?? null}
repository={(devSingleRepo ? devPrimaryRepo : primarySync)?.repo ?? null}
onAdd={() => gitSyncContext.addSyncRepository()}
isCollapsible={false}
showEmptyState={(devSingleRepo ? devPrimaryRepo : primarySync)?.repo == null}
devPromotion={devSingleRepo && !!$enterpriseLicense}
/>
<div class="pt-6">
{#if repositories.length === 0}
<div
class="flex flex-col items-center gap-3 text-center py-10 px-4 border rounded-md bg-surface-tertiary"
>
<GitBranch size={24} class="text-secondary" />
<div class="flex flex-col gap-1 items-center">
<span class="font-semibold text-sm text-emphasis">No repository connected</span>
<p class="text-xs text-secondary max-w-sm">
Commit every deploy of this workspace to a Git repository, and deploy new commits back
into it.
</p>
</div>
<!-- Sync only: promotion pushes wm_deploy/** branches to be merged into the branch
another workspace syncs, so it is not where a workspace starts. -->
<Button unifiedSize="md" variant="accent" onClick={() => openSetup('sync')}>
Configure Git Sync
</Button>
</div>
{:else}
<div class="flex flex-col border rounded-md divide-y bg-surface-tertiary">
{#each syncRows as { repo, idx } (idx)}
{@render repoRow(repo, idx)}
{/each}
{#if promotionRows.length > 0}
<!-- The two kinds deploy differently, so the list says which is which rather than
leaving it to each row's own line. -->
<div class="px-4 pt-3 pb-1 text-2xs font-semibold text-hint">Promotion repositories</div>
{#each promotionRows as { repo, idx } (idx)}
{@render repoRow(repo, idx)}
{/each}
{/if}
</div>
{#if $enterpriseLicense}
<!-- Secondary Sync Repositories (EE only; a dev workspace has a single inherited repo) -->
{#if primarySync && !primarySync.repo?.isUnsavedConnection && !devSingleRepo}
{#if secondarySync.length > 0 || secondarySyncExpanded}
<div class="mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (secondarySyncExpanded = !secondarySyncExpanded)}
>
{#if secondarySyncExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
Secondary sync repositories ({secondarySync.length})
</button>
{#if secondarySyncExpanded}
<div class="mt-3 space-y-3">
{#if secondarySync.length === 0}
<div class="text-sm text-secondary italic">
No secondary sync repositories configured
</div>
{:else}
{#each secondarySync as { repo, idx } (repo.git_repo_resource_path)}
<div class="pl-4">
<GitSyncRepositoryCard variant="secondary" {idx} isSecondary={true} />
</div>
{/each}
{/if}
{#if !hasUnsavedSecondary}
<div class="pl-4">
<Button
size="xs"
variant="default"
startIcon={{ icon: Plus }}
onclick={() => gitSyncContext.addSyncRepository()}
>
Add secondary sync
</Button>
</div>
{/if}
</div>
{/if}
</div>
{:else}
<!-- Collapsed state when no secondary repos exist -->
{#if !hasUnsavedSecondary}
<div class="mt-2">
<button
class="text-xs text-primary hover:text-secondary transition-colors"
onclick={() => {
secondarySyncExpanded = true
gitSyncContext.addSyncRepository()
}}
{#if showAddSync || showAddPromotion}
<div class="flex gap-4 mt-3">
{#if showAddSync}
<div class="flex items-center gap-1">
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Plus }}
disabled={!$enterpriseLicense}
onClick={() => openSetup('sync')}
>
+ Add secondary sync repository
</button>
{addSyncLabel}
</Button>
{#if !$enterpriseLicense}<EEOnly />{/if}
</div>
{/if}
{/if}
{/if}
<!-- Primary Promotion Repository (EE only; roots only — a dev promotes via the
toggle on its single inherited repo, not a separate promotion repo) -->
{#if showPromotion && !devSingleRepo}
<div class="mt-6">
<GitSyncRepositoryCard
variant="primary-promotion"
mode="promotion"
idx={primaryPromotion?.idx ?? null}
repository={primaryPromotion?.repo ?? null}
onAdd={() => gitSyncContext.addPromotionRepository()}
isCollapsible={false}
showEmptyState={primaryPromotion?.repo === null}
/>
<!-- Secondary Promotion Repositories -->
{#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection}
{#if secondaryPromotion.length > 0 || secondaryPromotionExpanded}
<div class="mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (secondaryPromotionExpanded = !secondaryPromotionExpanded)}
>
{#if secondaryPromotionExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
Secondary promotion repositories ({secondaryPromotion.length})
</button>
{#if secondaryPromotionExpanded}
<div class="mt-3 space-y-3">
{#if secondaryPromotion.length === 0}
<div class="text-sm text-secondary italic">
No secondary promotion repositories configured
</div>
{:else}
{#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)}
<div class="pl-4">
<GitSyncRepositoryCard variant="secondary" {idx} isSecondary={true} />
</div>
{/each}
{/if}
{#if !hasUnsavedSecondaryPromotion}
<div class="pl-4">
<Button
size="xs"
variant="default"
startIcon={{ icon: Plus }}
onclick={() => gitSyncContext.addPromotionRepository()}
>
Add secondary promotion
</Button>
</div>
{/if}
</div>
{/if}
</div>
{:else}
<!-- Collapsed state when no secondary promotion repos exist -->
{#if !hasUnsavedSecondaryPromotion}
<div class="mt-2">
<button
class="text-xs text-primary hover:text-secondary transition-colors"
onclick={() => {
secondaryPromotionExpanded = true
gitSyncContext.addPromotionRepository()
}}
>
+ Add secondary promotion repository
</button>
</div>
{/if}
{/if}
{#if showAddPromotion}
<div class="flex items-center gap-1">
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Plus }}
disabled={!$enterpriseLicense}
onClick={() => openSetup('promotion')}
>
Add promotion repository
</Button>
<Tooltip documentationLink="https://www.windmill.dev/docs/advanced/deploy_gh_gl">
Each deploy in this workspace pushes its changes to a dedicated
<span class="font-mono">wm_deploy/**</span>
branch of the repository instead of committing to its tracked branch directly. Merging
that branch promotes the change: the workspace that syncs the tracked branch deploys
it on merge, so set up Git Sync there. Windmill can open the pull request for each
deploy branch, or you can use the open-pr-on-commit workflow.
</Tooltip>
{#if !$enterpriseLicense}<EEOnly />{/if}
</div>
{/if}
</div>
{:else if !showPromotion}
{/if}
{#if $enterpriseLicense && !showPromotion}
<div class="mt-6">
<Alert
type="info"
@@ -348,7 +365,92 @@
{/if}
</div>
<ConfirmationModal
open={!!deleting}
title="Remove the repository connection"
confirmationText="Remove"
loading={deletingBusy}
onConfirmed={confirmDelete}
onCanceled={() => (deleting = undefined)}
>
<span class="text-sm">
Deploys of this workspace will stop being committed to
<span class="font-mono">{deleting?.path}</span>. The repository itself and its resource are
left untouched.
</span>
</ConfirmationModal>
<GitSyncSetupModal
bind:opened={setupOpen}
title={setupTitle}
mode={setupMode}
{usedResourcePaths}
/>
<Drawer
bind:this={settingsDrawer}
size="1000px"
on:afterClose={() => (settingsPath = undefined)}
>
<DrawerContent
title={settingsIdx !== -1 ? rowLabel(settingsIdx) : 'Repository settings'}
on:close={() => settingsDrawer?.closeDrawer()}
>
{#if settingsIdx !== -1}
{#key settingsIdx}
<GitSyncRepositoryCard
idx={settingsIdx}
isCollapsible={false}
{...cardProps(settingsIdx)}
/>
{/key}
{/if}
</DrawerContent>
</Drawer>
<!-- Modals -->
<GitSyncModalManager />
{/if}
{/if}
{#snippet repoRow(repo: GitSyncRepository, idx: number)}
{@const validation = gitSyncContext?.getValidation(idx)}
<div class="flex items-center justify-between gap-4 px-4 py-3">
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<span class="text-xs font-medium text-emphasis truncate">
{repo.git_repo_resource_path || 'No resource selected'}
</span>
{#if repo.isUnsavedConnection}
<Badge small color="yellow">Not saved</Badge>
{:else if validation?.hasChanges}
<Badge small color="yellow">Unsaved changes</Badge>
{/if}
{#if repo.legacyImported}
<Badge small color="orange">Legacy configuration</Badge>
{/if}
</div>
<span class="text-2xs text-secondary">{rowLabel(idx)}</span>
</div>
<div class="flex items-center gap-1">
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Settings }}
onClick={() => openSettings(repo.git_repo_resource_path)}
>
Settings
</Button>
<DropdownV2
items={[
{
displayName: 'Delete',
icon: Trash,
type: 'delete',
action: () => (deleting = { idx, path: repo.git_repo_resource_path })
}
]}
/>
</div>
</div>
{/snippet}
@@ -0,0 +1,600 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { untrack } from 'svelte'
import { ArrowRight, Database } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import Alert from '../common/alert/Alert.svelte'
import Modal2 from '../common/modal/Modal2.svelte'
import Stepper from '../common/stepper/Stepper.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import Path from '../Path.svelte'
import TextInput from '../text_input/TextInput.svelte'
import EEOnly from '../EEOnly.svelte'
import RadioCard from '../common/radioCard/RadioCard.svelte'
import GithubIcon from '../icons/GithubIcon.svelte'
import GitlabIcon from '../icons/GitlabIcon.svelte'
import GitlabProjectConnectForm from './setup/GitlabProjectConnectForm.svelte'
import GithubAppConnectForm from './setup/GithubAppConnectForm.svelte'
import TokenUrlConnectForm from './setup/TokenUrlConnectForm.svelte'
import { createRepositoryResource, repoSlug, type RepoConnection } from './setup/repoConnection'
import { GithubAppSetup } from './setup/githubAppSetup.svelte'
import { onDestroy } from 'svelte'
import { getGitSyncContext } from './GitSyncContext.svelte'
import ConfigureSyncStep from './setup/ConfigureSyncStep.svelte'
import GitPushPreview from './GitPushPreview.svelte'
import GitSyncSuccessContent from './GitSyncSuccessContent.svelte'
import { ResourceService } from '$lib/gen'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { apiErrorMessage } from '$lib/utils'
type Provider = 'gitlab' | 'github_app' | 'github_pat' | 'existing'
type Props = {
opened: boolean
title: string
mode: 'sync' | 'promotion'
/** Resources already attached to a repository of this workspace, which cannot be picked twice. */
usedResourcePaths: string[]
}
let { opened = $bindable(), title, mode, usedResourcePaths }: Props = $props()
const ctx = getGitSyncContext()
const STEPS = [
'Choose a provider',
'Connect the repository',
'Configure sync',
'Initialize the repository',
'Done'
]
let step: 1 | 2 | 3 | 4 | 5 = $state(1)
/** What the last step reports, set by the save that got there. */
let done: { savedWithoutInit: boolean; autoPullOn: boolean; webhookError?: string } | undefined =
$state(undefined)
/** Resource path of the unsaved repository step 3 configures. By path rather than
* index, as the context's list can shift under it. */
let draftPath: string | undefined = $state(undefined)
const draftIdx = $derived(
draftPath === undefined
? -1
: ctx.repositories.findIndex((r) => r.git_repo_resource_path === draftPath)
)
const draft = $derived(draftIdx === -1 ? undefined : ctx.repositories[draftIdx])
let push: GitPushPreview | undefined = $state()
/** Each trip to step 3 is its own attempt, and readiness names the one it belongs to: a
* step destroyed mid-load still reports when its request lands, and that answer is about
* the attempt it was opened for — which may be for the same repository as the current one. */
let configureAttempt = $state(0)
let factsLoadedFor: number | undefined = $state(undefined)
const factsLoaded = $derived(factsLoadedFor === configureAttempt)
let saving = $state(false)
let saveError: string | undefined = $state(undefined)
let provider: Provider | undefined = $state(undefined)
let existingPath: string | undefined = $state(undefined)
/** The workspace has a git_repository resource no repository uses yet, so picking one is
* offered up front rather than behind the hint. */
let hasResource = $state(false)
let connection: RepoConnection | undefined = $state(undefined)
/** Path of the resource the run creates. `Path` keeps whatever non-empty path it is bound
* to, so it has to be cleared when the repository changes — otherwise the second
* repository is offered the first one's path, which is already taken. */
let newPath = $state('')
let newPathError = $state('')
let branch = $state('')
let folder = $state('')
let connecting = $state(false)
let connectError: string | undefined = $state(undefined)
let githubApp: GithubAppSetup | undefined = $state(undefined)
/** A push started from step 4. Owned here rather than read off `GitPushPreview`: closing
* the dialog destroys that component in the same flush as the dismissal, so its state is
* already gone by the time the dismissal is handled. */
let applying = $state(false)
/** Work in flight that a dismissal must not cut short. */
const busy = $derived(connecting || saving || applying)
/** The push of step 4 landed. What is left is saving the connection, so a failure there
* must not be offered as "save without initializing" — the repository already holds the
* workspace's content. */
let initialized = $state(false)
onDestroy(() => {
githubApp?.dispose()
discardDraft()
})
$effect(() => {
if (!opened)
untrack(() => {
githubApp?.dispose()
discardDraft()
})
})
/** Drops the unsaved repository, keeping the resource, which stays pickable.
*
* A push that is still running is left alone: it may yet initialize the repository, and
* the row it leaves behind is how the user finishes saving the connection. */
function discardDraft() {
if (busy) {
draftPath = undefined
return
}
if (draftIdx !== -1 && ctx.repositories[draftIdx]?.isUnsavedConnection) {
void ctx.removeRepository(draftIdx)
}
draftPath = undefined
}
/** Ignores a report from any attempt but the one on screen. */
function noteFacts(attempt: number, loaded: boolean) {
if (attempt !== configureAttempt) return
factsLoadedFor = loaded ? attempt : undefined
}
function enterConfigure(path: string) {
discardDraft()
configureAttempt += 1
factsLoadedFor = undefined
// Whatever was pushed belonged to the draft just discarded, not to this one.
initialized = false
const before = ctx.repositories.length
if (mode === 'promotion') ctx.addPromotionRepository()
else ctx.addSyncRepository()
// Refused (CE is limited to one repository): the context toasted why.
if (ctx.repositories.length === before) return
ctx.repositories[before].git_repo_resource_path = path
draftPath = path
saveError = undefined
step = 3
}
/** From step 3 the resource already exists, so going back offers it as the
* existing resource to use rather than reconnecting from scratch. */
function back() {
if (busy) return
if (step === 4) {
step = 3
return
}
if (step === 3 && draftPath) {
const path = draftPath
discardDraft()
provider = 'existing'
existingPath = path
}
step = 1
}
async function saveDraft(withoutInit = false) {
if (draftIdx === -1) return
saving = true
saveError = undefined
try {
// The last step says what the success modal would have, so it stays quiet.
await ctx.saveRepository(draftIdx, withoutInit, false)
done = {
savedWithoutInit: withoutInit,
autoPullOn: draft?.auto_pull?.enabled === true,
// Read back by the save: the webhook is registered after the settings commit and
// a failure only leaves polling on.
webhookError: draft?.auto_pull?.webhook_error
}
step = 5
} catch (e) {
saveError = apiErrorMessage(e)
} finally {
saving = false
}
}
async function reset() {
step = 1
done = undefined
applying = false
initialized = false
githubApp?.dispose()
githubApp = undefined
hasResource = false
provider = undefined
existingPath = undefined
connection = undefined
newPath = ''
newPathError = ''
namedFor = undefined
branch = ''
folder = ''
connectError = undefined
const workspace = $workspaceStore
if (!workspace) return
try {
const resources = await ResourceService.listResource({
workspace,
resourceType: 'git_repository'
})
const firstUnused = resources.find((r) => !usedResourcePaths.includes(r.path))
hasResource = !!firstUnused
if (provider === undefined && firstUnused) {
provider = 'existing'
existingPath = firstUnused.path
}
} catch (e) {
console.error('Failed to list git_repository resources', e)
}
}
$effect(() => {
if (opened) untrack(() => reset())
})
// Cast: the only writes to `connection` are the `bind:` on the provider forms, which the
// checker does not see as assignments — it narrows the variable to `undefined` without it.
const repoName = $derived.by(() => {
const url = (connection as RepoConnection | undefined)?.url
return url ? repoSlug(url) : undefined
})
let namedFor: string | undefined = $state(undefined)
$effect(() => {
const name = repoName
untrack(() => {
if (name === namedFor) return
namedFor = name
newPath = ''
newPathError = ''
})
})
// GitLab without EE has no server-side token store nor project listing, so it
// takes the same URL + token form as a GitHub PAT.
const gitlabHeld = $derived(provider === 'gitlab' && !!$enterpriseLicense)
const canContinue = $derived(
step === 1
? provider === 'existing'
? !!existingPath
: provider !== undefined
: step === 2
? !!connection && !!newPath && !newPathError && !connecting
: (draft?.detectionState === 'no-wmill' || draft?.detectionState === 'has-wmill') &&
factsLoaded &&
!saving
)
// Steps this run skips: connecting, when an existing resource is used, and initializing,
// when the repository already holds a Windmill configuration.
const disabledIndices = $derived([
...(provider === 'existing' ? [1] : []),
...(draft?.detectionState === 'has-wmill' ? [3] : [])
])
function selectProvider(p: Provider) {
if (p !== provider) connection = undefined
provider = p
if (p === 'github_app' && !githubApp && $workspaceStore) {
githubApp = new GithubAppSetup($workspaceStore)
void githubApp.reload()
}
}
async function next() {
if (step === 1) {
if (provider === 'existing') {
if (existingPath) enterConfigure(existingPath)
} else {
connectError = undefined
step = 2
// Nothing to pick from yet: go straight to installing. Only while this click
// is still being handled, or the browser blocks the tab.
if (provider === 'github_app' && githubApp?.loaded && githubApp.usable.length === 0) {
githubApp.openInstall()
}
}
return
}
const workspace = $workspaceStore
if (!connection || !workspace) return
connecting = true
connectError = undefined
// Read once: the fields stay editable while the request is in flight, and reading the
// path again afterwards would configure a repository this run never created.
const submitted = { path: newPath, connection, branch, folder }
try {
await createRepositoryResource(workspace, submitted.path, submitted.connection, {
branch: submitted.branch,
folder: submitted.folder
})
enterConfigure(submitted.path)
} catch (e) {
connectError = apiErrorMessage(e)
} finally {
connecting = false
}
}
</script>
<Modal2
bind:isOpen={opened}
target="#content"
formStyling
preventDismiss={busy}
{title}
contentClasses="flex flex-col"
fixedWidth="md"
fixedHeight="lg"
>
<div class="flex h-full flex-col gap-4">
<Stepper
tabs={STEPS}
selectedIndex={step - 1}
maxReachedIndex={step - 1}
{disabledIndices}
small
on:click={(e) => {
if (e.detail.index === 0 && step > 1) back()
}}
/>
<div class="flex-1 flex flex-col min-h-0">
<div class="flex-1 overflow-y-auto flex flex-col gap-2">
{#if step === 1}
{#snippet githubIcon()}
<GithubIcon height={18} width={18} />
{/snippet}
{#snippet gitlabIcon()}
<GitlabIcon height={18} width={18} />
{/snippet}
{#snippet resourceIcon()}
<Database size={18} class="text-secondary" />
{/snippet}
<div
role="radiogroup"
aria-label="How to connect the repository"
class="flex flex-col gap-2"
>
{@render providerCard(
'github_app',
githubIcon,
'GitHub App (recommended)',
'Connect with the GitHub UI.',
!$enterpriseLicense
)}
{@render providerCard(
'github_pat',
githubIcon,
'GitHub personal access token',
'Generate a token from GitHub to allow Windmill to access your account.'
)}
{@render providerCard(
'gitlab',
gitlabIcon,
'GitLab',
'Generate a token from GitLab to allow Windmill to access your account.'
)}
{#if hasResource || provider === 'existing'}
{@render providerCard(
'existing',
resourceIcon,
'Use an existing git_repository resource',
'Pick a resource that already holds the repository URL and its credentials.'
)}
{/if}
</div>
{#if provider === 'existing'}
<ResourcePicker
bind:value={existingPath}
resourceType="git_repository"
excludedValues={usedResourcePaths}
/>
{/if}
{:else if step === 5}
{#if done}
<GitSyncSuccessContent
savedWithoutInit={done.savedWithoutInit}
autoPullOn={done.autoPullOn}
webhookError={done.webhookError}
/>
{/if}
{:else if step === 4}
{#if draft}
<GitPushPreview
bind:this={push}
gitRepoResourcePath={draft.git_repo_resource_path}
uiState={draft.settings}
onApplyStateChange={(v) => (applying = v)}
onSuccess={() => {
initialized = true
void saveDraft()
}}
/>
{/if}
{#if saveError}
<Alert type="error" size="xs" title="Could not save the repository">
{saveError}
</Alert>
{/if}
{:else if step === 3}
{#if draftIdx !== -1}
<!-- One step per attempt, so a repository configured again starts from scratch
rather than reusing the instance that is still loading. -->
{#key configureAttempt}
<ConfigureSyncStep
idx={draftIdx}
{mode}
attempt={configureAttempt}
onFactsChange={noteFacts}
/>
{/key}
{/if}
{#if saveError}
<Alert type="error" size="xs" title="Could not save the repository">
{saveError}
</Alert>
{/if}
{:else if $workspaceStore}
<div class="flex flex-col gap-6">
{#if provider === 'github_app' && githubApp}
<GithubAppConnectForm workspace={$workspaceStore} setup={githubApp} bind:connection />
{:else if gitlabHeld}
<GitlabProjectConnectForm workspace={$workspaceStore} bind:connection />
{:else if provider === 'gitlab'}
<TokenUrlConnectForm provider="gitlab" bind:connection />
{:else}
<TokenUrlConnectForm provider="github" bind:connection />
{/if}
{#if connection}
<div class="flex flex-col gap-4 border-t pt-4">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Branch</span>
<span class="text-xs text-secondary">
The branch deploys are committed to. Leave empty for the repository's default
branch.
</span>
<TextInput bind:value={branch} inputProps={{ placeholder: 'Default branch' }} />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Folder</span>
<span class="text-xs text-secondary">
Where in the repository the workspace is kept, relative to its root. Leave empty
for the root. The folder must already exist in the repository.
</span>
<TextInput bind:value={folder} inputProps={{ placeholder: 'Repository root' }} />
</label>
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Save as resource</span>
<span class="text-xs text-secondary">
Windmill keeps the repository and its access as a <code>git_repository</code> resource
at this path.
</span>
{#key connection.url}
<Path
kind="resource"
initialPath=""
fullNamePlaceholder={repoSlug(connection.url)}
autofocus={false}
bind:path={newPath}
bind:error={newPathError}
/>
{/key}
</div>
</div>
{/if}
{#if connectError}
<Alert type="error" size="xs" title="Could not connect the repository">
{connectError}
</Alert>
{/if}
</div>
{/if}
</div>
<div class="flex justify-between items-center pt-3">
<div>
{#if step > 1 && step < 5}
<Button unifiedSize="sm" variant="default" disabled={busy} onClick={back}>Back</Button>
{:else if step === 1 && !hasResource && provider !== 'existing'}
<Button variant="subtle" unifiedSize="sm" onClick={() => selectProvider('existing')}>
Use an existing resource instead
</Button>
{/if}
</div>
{#if step === 5}
<Button unifiedSize="sm" variant="accent" onClick={() => (opened = false)}>Close</Button>
{:else if step === 4}
<div class="flex items-center gap-2">
{#if initialized}
<Button
unifiedSize="sm"
variant="accent"
disabled={saving}
loading={saving}
onClick={() => saveDraft()}
>
Retry saving the connection
</Button>
{:else}
<Button
unifiedSize="sm"
variant="default"
disabled={push?.status().applying || push?.status().previewing || saving}
loading={saving}
onClick={() => saveDraft(true)}
>
Save without initializing
</Button>
<Button
unifiedSize="sm"
variant="accent"
disabled={!push?.status().canApply || saving}
loading={push?.status().applying}
onClick={() => push?.apply()}
>
Initialize repository
</Button>
{/if}
</div>
{:else if step === 3}
{#if draft?.detectionState === 'no-wmill'}
<Button
unifiedSize="sm"
variant="accent"
disabled={!canContinue}
endIcon={{ icon: ArrowRight }}
onClick={() => (step = 4)}
>
Next
</Button>
{:else}
<Button
unifiedSize="sm"
variant="accent"
disabled={!canContinue}
loading={saving}
onClick={() => void saveDraft()}
>
Save and connect
</Button>
{/if}
{:else}
<Button
unifiedSize="sm"
variant="accent"
disabled={!canContinue}
loading={connecting}
endIcon={connecting ? undefined : { icon: ArrowRight }}
onClick={next}
>
{step === 2 ? 'Connect' : 'Next'}
</Button>
{/if}
</div>
</div>
</div>
</Modal2>
{#snippet providerCard(
key: Provider,
icon: Snippet,
title: string,
subtitle: string,
eeOnly: boolean = false
)}
{#snippet description()}
<span class="flex items-center gap-2">
<span>{subtitle}</span>
{#if eeOnly}<EEOnly />{/if}
</span>
{/snippet}
<RadioCard
label={title}
{description}
selected={provider === key}
disabled={eeOnly}
{icon}
showRadio={false}
onSelect={() => selectProvider(key)}
/>
{/snippet}
@@ -0,0 +1,72 @@
<!--
@component
What the user is told once a git sync connection is saved: shown as the last step of the
setup dialog, and in the standalone modal for a connection saved outside it.
-->
<script lang="ts">
import { Alert } from '$lib/components/common'
import { CheckCircle2 } from 'lucide-svelte'
interface Props {
savedWithoutInit?: boolean
autoPullOn?: boolean
/** Whether to say what pulling off means. The setup dialog says it where it offers the
* toggle, so only the standalone modal — whose save came from a card that says it
* nowhere — needs it here. */
explainAutoPullOff?: boolean
/** Set when pulling is on but its webhook could not be registered. */
webhookError?: string
}
let {
savedWithoutInit = false,
autoPullOn = false,
explainAutoPullOff = false,
webhookError = undefined
}: Props = $props()
</script>
<!-- The notices belong at the top, where the steps before them put their own; what the dialog
ends on is centered in what is left. -->
<div class="flex flex-col gap-4 h-full">
{#if savedWithoutInit}
<Alert type="info" title="Repository saved without initialization">
Only new changes will be pushed to this repository. What this workspace holds today has not
been pushed to it.
</Alert>
{/if}
{#if autoPullOn && webhookError}
<Alert type="warning" title="Pull from Git is on, but falling back to polling">
{webhookError}
</Alert>
{:else if autoPullOn}
<Alert type="success" title="Pull from Git is on">
New commits to the tracked branch deploy into this workspace automatically. You can adjust
this anytime on the repository's settings.
</Alert>
{:else if explainAutoPullOff}
<Alert
type="warning"
title="Deploy changes from Git back to Windmill"
documentationLink="https://www.windmill.dev/docs/advanced/deploy_gh_gl#github-actions-setup"
>
Turn on "Automatically deploy changes from Git" on the repository to have Windmill pull new
commits into this workspace for you. Prefer to control deployment from your own pipeline
(tests, custom gating, deploy on PR merge)? Set up GitHub Actions or a similar CI/CD workflow
instead.
</Alert>
{/if}
<!-- `pb`: centered in what the notices leave, it would otherwise read as sitting low on the
step rather than in the middle of it. -->
<div class="flex-1 min-h-24 pb-16 flex flex-col items-center justify-center gap-2 text-center">
<CheckCircle2 class="h-8 w-8 text-green-600" />
<div class="flex flex-col gap-1">
<h3 class="text-lg font-semibold text-primary">Git sync connection saved successfully!</h3>
<p class="text-sm text-secondary">
Your repository is now configured to receive changes from Windmill.
</p>
</div>
</div>
</div>
@@ -1,81 +1,24 @@
<script lang="ts">
import Modal from '$lib/components/common/modal/Modal.svelte'
import { CheckCircle2, ExternalLink, ArrowRight } from 'lucide-svelte'
import GitSyncSuccessContent from './GitSyncSuccessContent.svelte'
interface Props {
open: boolean
savedWithoutInit?: boolean
autoPullOn?: boolean
explainAutoPullOff?: boolean
webhookError?: string
}
let { open = $bindable(false), savedWithoutInit = false, autoPullOn = false }: Props = $props()
let {
open = $bindable(false),
savedWithoutInit = false,
autoPullOn = false,
explainAutoPullOff = false,
webhookError = undefined
}: Props = $props()
</script>
<Modal bind:open title="Git Sync Connection Saved" class="sm:max-w-4xl" cancelText="Close">
<div class="flex flex-col gap-6 p-6">
<!-- Success header -->
<div class="flex items-center gap-3">
<div class="flex-shrink-0">
<CheckCircle2 class="h-8 w-8 text-green-600" />
</div>
<div>
<h3 class="text-lg font-semibold text-primary">Git sync connection saved successfully!</h3>
<p class="text-sm text-secondary mt-1"
>Your repository is now configured to receive changes from Windmill.</p
>
</div>
</div>
<!-- Info box for saved without init -->
{#if savedWithoutInit}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 class="font-medium text-blue-900 mb-2">Repository saved without initialization</h4>
<p class="text-sm text-blue-800">
Only new changes will be pushed to this repository. Existing content in Windmill has not
been initialized to the repository.
</p>
</div>
{/if}
<!-- Optional setup section -->
{#if autoPullOn}
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 class="font-medium text-green-900 mb-2 flex items-center gap-2">
<ArrowRight class="h-4 w-4" />
Pull from Git is on
</h4>
<p class="text-sm text-green-800">
New commits to the tracked branch deploy into this workspace automatically. You can
adjust this anytime on the repository card.
</p>
</div>
{:else}
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h4 class="font-medium text-amber-900 mb-2 flex items-center gap-2">
<ArrowRight class="h-4 w-4" />
Deploy changes from Git back to Windmill
</h4>
<p class="text-sm text-amber-800 mb-3">
Turn on "Automatically deploy changes from Git" on the repository to have Windmill pull
new commits into this workspace for you.
</p>
<div class="flex flex-col gap-2">
<p class="text-sm text-amber-700">
Prefer to control deployment from your own pipeline (tests, custom gating, deploy on PR
merge)? Set up GitHub Actions or similar CI/CD workflows instead.
</p>
<div class="mt-3">
<a
href="https://www.windmill.dev/docs/advanced/deploy_gh_gl#github-actions-setup"
target="_blank"
class="text-sm text-amber-700 hover:text-amber-900 underline flex items-center gap-1"
>
<ExternalLink class="h-3 w-3" />
Learn more about CI-based deployment
</a>
</div>
</div>
</div>
{/if}
</div>
<GitSyncSuccessContent {savedWithoutInit} {autoPullOn} {explainAutoPullOff} {webhookError} />
</Modal>
@@ -1,13 +1,9 @@
<script lang="ts">
import Modal from '$lib/components/common/modal/Modal.svelte'
import { Button, Alert } from '$lib/components/common'
import { Loader2, CheckCircle2, XCircle, Terminal, ChevronDown, ChevronUp } from 'lucide-svelte'
import GitDiffPreview from '../GitDiffPreview.svelte'
import { JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
import { jobManager } from '$lib/services/JobManager'
import type { SyncResponse, SettingsObject } from '$lib/git-sync'
import { Button } from '$lib/components/common'
import { Loader2 } from 'lucide-svelte'
import GitPushPreview from './GitPushPreview.svelte'
import type { SettingsObject } from '$lib/git-sync'
interface Props {
open: boolean
@@ -27,317 +23,63 @@
onSaveWithoutInit
}: Props = $props()
// Job state
let previewJobId = $state<string | null>(null)
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isPreviewLoading = $state(false)
let previewError = $state('')
let applyJobId = $state<string | null>(null)
let applyJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isApplying = $state(false)
let applyError = $state('')
// UI state
let showCliInstructions = $state(false)
let previewResult = $state<SyncResponse | null>(null)
// Note: Escape key is handled by the Modal component itself
// Reset state when modal opens/closes
$effect(() => {
if (!open) {
previewJobId = null
previewJobStatus = undefined
isPreviewLoading = false
previewError = ''
applyJobId = null
applyJobStatus = undefined
isApplying = false
applyError = ''
showCliInstructions = false
previewResult = null
}
})
// Execute job with dry run or actual execution
async function executeJob(isDryRun: boolean) {
const isPreview = isDryRun
if (isPreview) {
isPreviewLoading = true
previewError = ''
previewResult = null
previewJobId = null
previewJobStatus = undefined
} else {
isApplying = true
applyError = ''
applyJobId = null
applyJobStatus = undefined
}
try {
const workspace = $workspaceStore
if (!workspace) return
const payload = {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
dry_run: isDryRun,
pull: false,
settings_json: JSON.stringify(uiState)
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payload,
skipPreprocessor: true
})
if (isPreview) {
previewJobId = jobId
previewJobStatus = 'running'
} else {
applyJobId = jobId
applyJobStatus = 'running'
}
// Use JobManager instead of tryEvery
const result = await jobManager.runWithProgress(() => Promise.resolve(jobId), {
workspace,
timeout: 60000,
timeoutMessage: `${isPreview ? 'Preview' : 'Apply'} job timed out after 60s`,
onProgress: (status) => {
if (isPreview) {
previewJobStatus = status.status
} else {
applyJobStatus = status.status
}
// Handle failure status
if (status.status === 'failure') {
if (isPreview) {
previewError = status.error || 'Preview failed'
} else {
applyError = status.error || 'Push failed'
}
}
}
})
// Handle successful result
if (isPreview) {
if (previewJobStatus === 'success') {
previewResult = result as SyncResponse
}
} else {
if (applyJobStatus === 'success') {
onSuccess?.()
}
}
} catch (e) {
const errorMsg = e?.message || 'Operation failed'
if (isPreview) {
previewJobStatus = 'failure'
previewError = errorMsg
} else {
applyJobStatus = 'failure'
applyError = errorMsg
}
} finally {
if (isPreview) {
isPreviewLoading = false
} else {
isApplying = false
}
}
}
let push: GitPushPreview | undefined = $state()
const status = $derived(push?.status() ?? { previewing: true, applying: false, canApply: false })
</script>
<Modal bind:open title="Push Workspace to Git Repository" class="sm:max-w-4xl">
<div class="flex flex-col gap-4">
<!-- Description -->
<p class="text-sm text-secondary"
>Push your current workspace content to the connected Git repository based on the configured
filters.</p
>
<p class="text-sm text-primary"
>Note: This will not update git sync settings in wmill.yaml. Settings can only be pulled from
the repository as it is the source of truth.</p
>
<!-- Settings display for new connections -->
{#if isNewConnection}
<div class="bg-surface-secondary border border-border rounded-lg p-3">
<h4 class="text-sm font-medium text-primary mb-2"
>Settings that will be pushed to repository</h4
>
<div class="text-xs text-secondary space-y-1">
<div><strong>Include paths:</strong> {uiState.include_path?.join(', ') || 'None'}</div>
<div><strong>Exclude paths:</strong> {uiState.exclude_path?.join(', ') || 'None'}</div>
{#if uiState.extra_include_path?.length > 0}
<div><strong>Extra include paths:</strong> {uiState.extra_include_path.join(', ')}</div>
{/if}
<div><strong>Include types:</strong> {uiState.include_type?.join(', ') || 'None'}</div>
</div>
<p class="text-xs text-primary mt-2"
>To modify these settings, cancel and configure them in the workspace settings.</p
>
</div>
<!-- The setup dialog shows the filters on its own step; opened on its own, this is the only
place that says what the filters let through. -->
<div class="flex flex-col gap-1 text-xs text-secondary mb-4">
<div><span class="font-semibold text-emphasis">Include paths:</span> {uiState.include_path?.join(', ') || 'None'}</div>
<div><span class="font-semibold text-emphasis">Exclude paths:</span> {uiState.exclude_path?.join(', ') || 'None'}</div>
{#if uiState.extra_include_path?.length}
<div><span class="font-semibold text-emphasis">Extra include paths:</span> {uiState.extra_include_path.join(', ')}</div>
{/if}
<!-- Preview section -->
{#if !previewResult}
<div class="flex justify-start pt-4">
<Button
size="md"
variant="accent"
onclick={() => executeJob(true)}
disabled={isPreviewLoading}
startIcon={{
icon: isPreviewLoading ? Loader2 : undefined,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
{isPreviewLoading ? 'Previewing...' : 'Preview changes'}
</Button>
</div>
{/if}
<!-- Job status for preview -->
{#if previewJobId}
<div class="flex items-center gap-2 text-xs text-primary">
{#if previewJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if previewJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if previewJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Preview job:
<a
target="_blank"
class="underline"
href={`/run/${previewJobId}?workspace=${$workspaceStore}`}
>
{previewJobId}
</a>
</div>
{/if}
<!-- Preview error -->
{#if previewError}
<Alert type="error" title="Preview failed">
{previewError}
</Alert>
{/if}
<!-- Preview results -->
{#if previewResult && !previewError}
<div class="space-y-3">
<h4 class="text-sm font-semibold text-primary">Changes to Push</h4>
{#if previewResult.changes?.length > 0}
<GitDiffPreview {previewResult} />
{:else}
<div class="bg-surface-secondary rounded-lg p-3">
<div class="text-sm text-primary">No changes to push to the repository.</div>
</div>
{/if}
</div>
{/if}
<!-- Apply section (shown after successful preview) -->
{#if previewResult && !previewError}
{@const hasChanges = previewResult.changes?.length > 0}
{#if hasChanges}
<div class="border-t pt-4 mt-4">
<div class="flex justify-start gap-2">
<Button
size="xs"
onclick={() => executeJob(false)}
disabled={isApplying}
startIcon={{
icon: isApplying ? Loader2 : undefined,
classes: isApplying ? 'animate-spin' : ''
}}
>
{isApplying
? isNewConnection
? 'Initializing...'
: 'Pushing...'
: isNewConnection
? 'Initialize repo and save connection'
: 'Push to repository'}
</Button>
{#if isNewConnection && onSaveWithoutInit}
<Button size="xs" color="light" onclick={onSaveWithoutInit} disabled={isApplying}>
Save without initializing repo
</Button>
{/if}
</div>
</div>
{/if}
{/if}
<!-- Job status for apply -->
{#if applyJobId}
<div class="flex items-center gap-2 text-xs text-primary">
{#if applyJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if applyJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if applyJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Push job:
<a
target="_blank"
class="underline"
href={`/run/${applyJobId}?workspace=${$workspaceStore}`}
>
{applyJobId}
</a>
</div>
{/if}
<!-- Apply error -->
{#if applyError}
<Alert type="error" title="Push failed">
{applyError}
</Alert>
{/if}
<!-- CLI Instructions (collapsible) -->
<div class="border-t pt-4 mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (showCliInstructions = !showCliInstructions)}
>
<Terminal size={16} />
<span>CLI Instructions</span>
{#if showCliInstructions}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if showCliInstructions}
<div class="mt-3 bg-surface-secondary rounded-lg p-3">
<pre class="text-xs bg-surface p-3 rounded overflow-x-auto whitespace-pre-wrap break-all">
# Setup (only needed if local folder not initialized yet)
npm install -g windmill-cli
wmill workspace add {$workspaceStore} {$workspaceStore} {window.location.origin}
wmill init --workspace {$workspaceStore} --repository {gitRepoResourcePath}
# Pull workspace content to git repository
wmill sync pull --workspace {$workspaceStore} --repository {gitRepoResourcePath}</pre
>
</div>
{/if}
</div>
<div><span class="font-semibold text-emphasis">Included types:</span> {uiState.include_type?.join(', ') || 'None'}</div>
<p class="mt-2">
This does not update the git sync settings in <span class="font-mono">wmill.yaml</span>: they
can only be pulled from the repository, which is their source of truth. To change what is
pushed, cancel and edit the filters in the workspace settings.
</p>
</div>
{#if open}
{#key gitRepoResourcePath}
<GitPushPreview bind:this={push} {gitRepoResourcePath} {uiState} {onSuccess} />
{/key}
{/if}
<!-- Reversed row: whatever is rendered first lands rightmost, so the primary action ends up at
the far right and Cancel on the left. -->
{#snippet actions()}
<Button
unifiedSize="sm"
variant="accent"
onclick={() => push?.apply()}
disabled={!status.canApply}
startIcon={{
icon: status.applying ? Loader2 : undefined,
classes: status.applying ? 'animate-spin' : ''
}}
>
{status.applying
? isNewConnection
? 'Initializing...'
: 'Pushing...'
: isNewConnection
? 'Initialize repo and save connection'
: 'Push to repository'}
</Button>
{#if isNewConnection && onSaveWithoutInit}
<Button
unifiedSize="sm"
variant="default"
onclick={onSaveWithoutInit}
disabled={status.applying || status.previewing}
>
Save without initializing repo
</Button>
{/if}
{/snippet}
</Modal>
@@ -0,0 +1,314 @@
<script lang="ts">
import { untrack } from 'svelte'
import { CheckCircle2, Loader2, RotateCw, XCircle } from 'lucide-svelte'
import { Alert, Button } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import EEOnly from '$lib/components/EEOnly.svelte'
import GitSyncFilterSettings from '$lib/components/workspaceSettings/GitSyncFilterSettings.svelte'
import { ApiError, GitSyncService, ResourceService } from '$lib/gen'
import { enterpriseLicense, userWorkspaces, workspaceStore } from '$lib/stores'
import { apiErrorMessage } from '$lib/utils'
import { getGitSyncContext } from '../GitSyncContext.svelte'
import GitSyncModeDisplay from '../GitSyncModeDisplay.svelte'
import { applyNewConnectionDefaults } from './connectionDefaults'
type Props = {
/** The unsaved repository this step configures, in the git sync context. */
idx: number
mode: 'sync' | 'promotion'
/** Identifies the run this step belongs to, so a load outliving it cannot answer for a
* later one — the same repository can be configured again after going Back. */
attempt: number
/** Reports whether the resource and its credential have been read. What Windmill holds
* decides the defaults and which toggles exist, so the run must not be saved before
* they land. */
onFactsChange?: (attempt: number, loaded: boolean) => void
}
let { idx, mode, attempt, onFactsChange }: Props = $props()
const ctx = getGitSyncContext()
const repo = $derived(ctx.getRepository(idx))
const currentWorkspace = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
const isFork = $derived(
($workspaceStore?.startsWith('wm-fork-') ?? false) || !!currentWorkspace?.parent_workspace_id
)
let managedCredential = $state(false)
let targetBranch: string | undefined = $state(undefined)
let factsError: string | undefined = $state(undefined)
async function loadResourceFacts(path: string) {
const workspace = $workspaceStore
if (!workspace) return
// Read now, not when the request lands: a destroyed step still evaluates its props
// against the dialog's current state, which would make this answer for a later run.
const run = attempt
onFactsChange?.(run, false)
factsError = undefined
let resource: Awaited<ReturnType<typeof ResourceService.getResource>> | undefined
let origin: Awaited<ReturnType<typeof GitSyncService.getCredentialOrigin>> | undefined
try {
;[resource, origin] = await Promise.all([
ResourceService.getResource({ workspace, path }),
GitSyncService.getCredentialOrigin({ workspace, path }).catch((e) => {
// The route only exists on EE, where its absence is the answer: Windmill holds
// no credential. Any other failure leaves that unknown, and answering "none"
// would quietly save the repository without the defaults a managed one gets.
if (e instanceof ApiError && e.status === 404) return undefined
throw e
})
])
} catch (e) {
if (repo?.git_repo_resource_path !== path) return
factsError = apiErrorMessage(e)
return
}
// The step may have been rebuilt for another repository while this was in flight.
if (repo?.git_repo_resource_path !== path) return
const isGithubApp = (resource?.value as any)?.is_github_app === true
managedCredential = isGithubApp || origin?.origin !== undefined
if (repo) {
applyNewConnectionDefaults(repo, {
mode,
managedCredential,
isGithubApp,
isFork,
ee: !!$enterpriseLicense
})
targetBranch = await ctx.getTargetBranch(repo).catch(() => undefined)
}
onFactsChange?.(run, true)
}
async function detect() {
try {
await ctx.detectRepository(idx)
} catch (e) {
if (repo) {
repo.detectionState = 'error'
repo.detectionError = apiErrorMessage(e)
}
}
}
$effect(() => {
const path = repo?.git_repo_resource_path
if (!path) return
untrack(() => {
void loadResourceFacts(path)
if (!repo?.detectionState || repo.detectionState === 'idle') void detect()
})
})
// Applied to every fork of this workspace, so they are the parent's to set — a fork has
// neither toggle, matching the repository card.
function setSyncForks(v: boolean) {
if (repo?.auto_pull) repo.auto_pull = { ...repo.auto_pull, sync_forks: v }
}
function setForkOpenPrs(v: boolean) {
if (repo) repo.fork_open_prs = v
}
function setPromotionOpenPrs(v: boolean) {
if (repo) repo.promotion_open_prs = v
}
function setAutoPull(enabled: boolean) {
if (!repo) return
repo.auto_pull = enabled
? {
...(repo.auto_pull ?? {}),
enabled: true,
mode: repo.auto_pull?.mode ?? 'auto',
sync_forks: repo.auto_pull?.sync_forks ?? true
}
: repo.auto_pull
? { ...repo.auto_pull, enabled: false }
: undefined
}
</script>
{#if repo}
<div class="flex flex-col gap-4 h-full">
{#if factsError}
<Alert type="error" size="xs" title="Could not read the connection">
<div class="flex flex-col items-start gap-2">
<span>{factsError}</span>
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: RotateCw }}
onClick={() => {
const path = repo?.git_repo_resource_path
if (path) void loadResourceFacts(path)
}}
>
Retry
</Button>
</div>
</Alert>
{/if}
{#if !repo.detectionState || repo.detectionState === 'idle' || repo.detectionState === 'loading'}
<div class="flex-1 flex flex-col items-center justify-center gap-2 text-sm text-hint">
<Loader2 size={36} class="animate-spin" />
Checking the repository...
</div>
{:else if repo.detectionState === 'error'}
<Alert type="error" size="xs" title="Could not read the repository">
<div class="flex flex-col items-start gap-2">
<span>{repo.detectionError || 'The check failed.'}</span>
<span> Check the URL and the token, or go back and connect the repository again. </span>
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: RotateCw }}
onClick={() => void detect()}
>
Retry
</Button>
</div>
</Alert>
{:else if repo.detectionState === 'no-wmill'}
<Alert type="info" size="xs" bgClass="border-0" title="New repository">
The repository has no Windmill configuration yet. Choose what to sync, then initialize it
with the current content of this workspace.
</Alert>
{:else if repo.detectionState === 'has-wmill'}
<Alert type="success" size="xs" bgClass="border-0" title="Existing Windmill repository">
The repository already holds a Windmill configuration. Its sync settings are loaded below.
</Alert>
{/if}
{#if repo.detectionState === 'no-wmill' || repo.detectionState === 'has-wmill'}
<GitSyncFilterSettings
git_repo_resource_path={repo.git_repo_resource_path}
bind:include_path={repo.settings.include_path}
bind:include_type={repo.settings.include_type}
bind:exclude_types_override={repo.exclude_types_override}
isLegacyRepo={false}
bind:excludes={repo.settings.exclude_path}
bind:extraIncludes={repo.settings.extra_include_path}
isInitialSetup={repo.detectionState === 'no-wmill'}
requiresMigration={false}
useIndividualBranch={repo.use_individual_branch}
>
{#snippet subtitle()}
<GitSyncModeDisplay {mode} {targetBranch} repository={repo} />
{/snippet}
</GitSyncFilterSettings>
{#if mode === 'promotion'}
<Toggle
bind:checked={repo.group_by_folder}
options={{
right: 'Group all changes from the same folder in the same branch',
rightTooltip:
'Instead of creating a branch per item, Windmill creates a branch per folder containing the items being deployed.'
}}
/>
{#if managedCredential}
<Toggle
checked={repo.promotion_open_prs ?? false}
options={{
right: 'Open a pull request for each deploy branch',
rightTooltip:
'After a deploy pushes its wm_deploy/** branch, Windmill opens a pull request to the target branch. Runs from the deploy itself, so it works without inbound webhooks.'
}}
on:change={(e) => setPromotionOpenPrs(e.detail)}
/>
{:else}
<span class="text-2xs text-secondary">
To open a pull request for each deploy branch, connect the repository through the GitHub
App or give a GitLab repository a project access token — otherwise set up the
<span class="font-mono">open-pr-on-commit</span> workflow in the repository.
</span>
{/if}
{:else if !isFork}
<div class="flex flex-col gap-1">
<Toggle
checked={repo.auto_pull?.enabled ?? false}
disabled={!$enterpriseLicense}
options={{
right: 'Automatically deploy changes from Git',
rightTooltip:
'Windmill deploys new commits from the tracked branch into this workspace.'
}}
on:change={(e) => setAutoPull(e.detail)}
>
{#snippet right()}
{#if !$enterpriseLicense}<EEOnly />{/if}
{/snippet}
</Toggle>
{#if repo.auto_pull?.enabled}
<span class="text-2xs text-secondary">
{managedCredential
? 'New commits are delivered instantly by webhook, with polling as a fallback.'
: 'The branch is checked for new commits about every minute.'}
</span>
{:else}
<span class="text-2xs text-secondary">
Leave it off to control deployment from your own pipeline (tests, custom gating,
deploy on PR merge) with
<a
href="https://www.windmill.dev/docs/advanced/deploy_gh_gl#github-actions-setup"
target="_blank"
rel="noreferrer"
class="text-accent hover:underline">GitHub Actions or a similar CI/CD workflow</a
>.
</span>
{/if}
<Toggle
disabled={!repo.auto_pull?.enabled}
checked={!!(repo.auto_pull?.enabled && repo.auto_pull?.sync_forks)}
options={{
right: 'Automatically sync forks with git branches',
rightTooltip: repo.auto_pull?.enabled
? "When a fork's wm-fork/** branch changes in the repository (for example after merging the tracked branch into it), Windmill deploys those commits into the fork workspace. Configured once here, applied to every fork of this workspace."
: 'Requires automatic deploy from Git to be enabled above.'
}}
on:change={(e) => setSyncForks(e.detail)}
/>
{#if managedCredential}
<Toggle
checked={repo.fork_open_prs ?? false}
disabled={!$enterpriseLicense}
options={{
right: 'Open a pull request when an item is deployed in a fork',
rightTooltip:
"After an item deployed in a fork is pushed to the fork's branch (wm-fork/**, or the dev branch for a dev workspace), Windmill opens a pull request to the tracked branch of the shared repository. Runs from the deploy itself, so it works without inbound webhooks."
}}
on:change={(e) => setForkOpenPrs(e.detail)}
>
{#snippet right()}
{#if !$enterpriseLicense}<EEOnly />{/if}
{/snippet}
</Toggle>
{/if}
</div>
{/if}
{/if}
{#if repo.detectionState !== 'no-wmill' && repo.detectionState !== 'has-wmill'}
<!-- Holds its row from the start: the job id only arrives once the check job has been
started, and letting the row appear then shifts everything above it. -->
<div class="mt-auto flex items-center gap-2 min-h-4 text-2xs text-secondary">
{#if repo.detectionJobId}
{#if repo.detectionJobStatus === 'success'}
<CheckCircle2 size={12} class="text-green-600" />
{:else if repo.detectionJobStatus === 'failure'}
<XCircle size={12} class="text-red-700" />
{/if}
<span class="text-hint">Check job:</span>
<a
target="_blank"
class="underline"
href={`/run/${repo.detectionJobId}?workspace=${$workspaceStore}`}
>
{repo.detectionJobId}
</a>
{/if}
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,232 @@
<script lang="ts">
import { Download, ExternalLink, Loader2, Minus, RotateCw, Upload } from 'lucide-svelte'
import { Alert, Button } from '$lib/components/common'
import Select from '$lib/components/select/Select.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import RepositorySelector from '$lib/components/RepositorySelector.svelte'
import {
addInstallationToWorkspace,
deleteInstallation,
exportInstallation,
importInstallation
} from '$lib/githubApp'
import type { RepoConnection } from './repoConnection'
import type { GithubAppSetup } from './githubAppSetup.svelte'
type Props = {
workspace: string
setup: GithubAppSetup
connection: RepoConnection | undefined
}
let { workspace, setup, connection = $bindable() }: Props = $props()
const gh = $derived(setup.gh)
const usable = $derived(setup.usable)
const broken = $derived(gh.workspaceGithubInstallations.filter((i) => !!i.error))
// Installations reachable from another workspace the user is in, deduplicated.
const elsewhere = $derived(
gh.githubInstallations.filter(
(i, idx, all) =>
!i.error &&
!gh.workspaceGithubInstallations.some((w) => w.installation_id === i.installation_id) &&
all.findIndex((o) => o.installation_id === i.installation_id) === idx
)
)
const selected = $derived(
usable.find((i) => i.installation_id === gh.selectedGHAppInstallationId)
)
const reload = () => setup.reload()
$effect(() => {
connection = gh.selectedGHAppRepository
? { url: gh.selectedGHAppRepository, isGithubApp: true }
: undefined
})
async function useFrom(installationId: number, sourceWorkspace: string) {
await addInstallationToWorkspace(workspace, installationId, sourceWorkspace, reload)
}
/** Pasting a JWT exported from another Windmill instance. */
let importing = $state(false)
// Export hands out a JWT the other instance imports; an installation reached through a
// GitHub Enterprise Server of its own is not transferable that way.
const installationActions = $derived([
...(selected && !selected.github_base_url
? [
{
displayName: 'Copy installation token (to another instance)',
icon: Download,
action: () => void exportInstallation(workspace, selected.installation_id)
}
]
: []),
...(gh.isGhesSelfManaged
? []
: [
{
displayName: 'Import an installation from another instance',
icon: Upload,
action: () => (importing = true)
}
]),
...(selected && !selected.provisioned_by_admin
? [
{
displayName: 'Remove installation from this workspace',
icon: Minus,
type: 'delete' as const,
action: () => void deleteInstallation(workspace, selected.installation_id, reload)
}
]
: [])
])
</script>
<div class="flex flex-col gap-4">
{#if (!setup.loaded || gh.loadingGithubInstallations) && gh.workspaceGithubInstallations.length === 0}
<div class="flex items-center gap-2 text-xs text-secondary">
<Loader2 size={14} class="animate-spin" /> Loading GitHub App installations...
</div>
{:else}
{#if usable.length === 0}
<Alert type="info" size="xs" bgClass="border-0" title="">
{#if gh.isCheckingInstallation}
Finish installing the Windmill GitHub App in the tab that opened. This dialog picks up the
installation on its own.
{:else}
Install the Windmill GitHub App on the GitHub account or organization that owns the
repository, then come back here.
{/if}
</Alert>
{:else}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">GitHub account</span>
<Select
items={usable.map((i) => ({
label: i.account_id,
value: i.installation_id,
subtitle: `${i.total_count} repositories`
}))}
bind:value={
() => gh.selectedGHAppInstallationId,
(v) => {
gh.selectedGHAppInstallationId = v
gh.selectedGHAppRepository = undefined
}
}
clearable={false}
/>
</label>
{#if selected}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Repository</span>
<!-- RepositorySelector snapshots its repositories at mount -->
{#key selected.installation_id}
<RepositorySelector
bind:selectedRepository={gh.selectedGHAppRepository}
installationId={selected.installation_id}
initialRepositories={selected.repositories}
totalCount={selected.total_count}
perPage={selected.per_page}
/>
{/key}
</label>
{/if}
{/if}
{#if broken.length > 0}
<Alert type="warning" size="xs" title="Some installations cannot be used">
{broken.map((i) => `${i.account_id}: ${i.error}`).join('; ')}
</Alert>
{/if}
<div class="flex items-center gap-2">
<Button
variant="default"
unifiedSize="sm"
disabled={!gh.githubInstallationUrl}
startIcon={gh.isCheckingInstallation
? { icon: Loader2, classes: 'animate-spin' }
: undefined}
endIcon={{ icon: ExternalLink }}
title={gh.isCheckingInstallation ? 'Open the GitHub installation page again' : undefined}
onClick={() => setup.openInstall()}
>
{gh.isCheckingInstallation
? 'Waiting for the installation...'
: usable.length === 0
? 'Install the GitHub App'
: 'Install on another account'}
</Button>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
title="Refresh installations"
startIcon={{ icon: RotateCw }}
disabled={gh.loadingGithubInstallations}
onClick={reload}
/>
{#if installationActions.length > 0}
<DropdownV2 items={installationActions} />
{/if}
</div>
{#if importing}
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Installation token</span>
<span class="text-xs text-secondary">
Paste the token copied from the instance that holds the installation.
</span>
<div class="flex items-center gap-2">
<TextInput
bind:value={gh.importJwt}
size="sm"
class="flex-1 min-w-0"
inputProps={{ placeholder: 'JWT token', autocomplete: 'off' }}
/>
<Button
variant="default"
unifiedSize="sm"
disabled={!gh.importJwt}
onClick={async () => {
await importInstallation(workspace, gh.importJwt, () => {
gh.importJwt = ''
importing = false
void reload()
})
}}
>
Import
</Button>
</div>
</div>
{/if}
{#if elsewhere.length > 0}
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Installed in your other workspaces</span>
{#each elsewhere as inst (inst.installation_id)}
<div class="flex items-center justify-between gap-2 text-xs">
<span class="text-primary">
{inst.account_id}
<span class="text-secondary">from {inst.workspace_id}</span>
</span>
<Button
variant="default"
unifiedSize="xs"
onClick={() => inst.workspace_id && useFrom(inst.installation_id, inst.workspace_id)}
>
Use here
</Button>
</div>
{/each}
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,114 @@
<script lang="ts">
import { GitBranch, Loader2 } from 'lucide-svelte'
import { Alert, Button } from '$lib/components/common'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Select from '$lib/components/select/Select.svelte'
import { GitSyncService, type GitlabProject } from '$lib/gen'
import { apiErrorMessage } from '$lib/utils'
import type { RepoConnection } from './repoConnection'
type Props = {
workspace: string
connection: RepoConnection | undefined
}
let { workspace, connection = $bindable() }: Props = $props()
let baseUrl = $state('https://gitlab.com')
let token = $state('')
let projects: GitlabProject[] = $state([])
let selectedProject: string | undefined = $state(undefined)
let loading = $state(false)
let listError: string | undefined = $state(undefined)
/** What the current listing was made with: a project chosen against another token or
* another instance must not be stored under the ones on screen now. */
let listedToken = $state('')
let listedBaseUrl = $state('')
const staleListing = $derived(
projects.length > 0 && (token !== listedToken || baseUrl !== listedBaseUrl)
)
const project = $derived(projects.find((p) => p.path_with_namespace === selectedProject))
$effect(() => {
connection =
project && !staleListing
? { url: project.http_url_to_repo, heldToken: listedToken }
: undefined
})
async function listProjects() {
loading = true
listError = undefined
const listedWith = token
const listedAgainst = baseUrl
try {
projects = await GitSyncService.listGitlabProjects({
workspace,
requestBody: { base_url: listedAgainst, token: listedWith }
})
listedToken = listedWith
listedBaseUrl = listedAgainst
selectedProject = projects[0]?.path_with_namespace
if (projects.length === 0) {
listError = 'The token can see no project with at least the Developer role'
}
} catch (e) {
listError = apiErrorMessage(e)
projects = []
selectedProject = undefined
} finally {
loading = false
}
}
</script>
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">GitLab instance</span>
<TextInput bind:value={baseUrl} />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Project access token</span>
<span class="text-xs text-secondary">
Create it on the project you are syncing, with the <code>api</code> scope and the
<code>Maintainer</code> role.
</span>
<TextInput bind:value={token} inputProps={{ type: 'password', autocomplete: 'off' }} />
<span class="text-2xs text-hint">
Windmill stores it and renews it before it expires. Use one token per project.
</span>
</label>
<div class="flex">
<Button
variant="default"
unifiedSize="sm"
disabled={!token.trim() || !baseUrl.trim() || loading}
startIcon={{ icon: loading ? Loader2 : GitBranch, classes: loading ? 'animate-spin' : '' }}
onClick={listProjects}
>
List projects
</Button>
</div>
{#if listError}
<Alert type="error" title="Could not list projects" size="xs">{listError}</Alert>
{/if}
{#if staleListing}
<span class="text-xs text-secondary">
The token or the instance changed. List the projects again to choose one they can
reach.
</span>
{:else if projects.length > 0}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Project</span>
<Select
items={projects.map((p) => ({
label: p.path_with_namespace,
value: p.path_with_namespace
}))}
bind:value={selectedProject}
clearable={false}
/>
</label>
{/if}
</div>
@@ -0,0 +1,95 @@
<script lang="ts">
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { ExternalLink } from 'lucide-svelte'
import {
parseRepoUrl,
withToken,
type RepoConnection,
type TokenProvider
} from './repoConnection'
type Props = {
provider: TokenProvider
connection: RepoConnection | undefined
}
let { provider, connection = $bindable() }: Props = $props()
let repoUrl = $state('')
let token = $state('')
const parsed = $derived(parseRepoUrl(repoUrl))
const urlError = $derived(
repoUrl.trim() && !parsed ? 'Enter the https URL of the repository' : ''
)
$effect(() => {
connection =
parsed && token.trim()
? { url: parsed.toString(), tokenUrl: withToken(parsed, token, provider) }
: undefined
})
/** Where this token is created. Built from the repository URL once there is one, so a
* self-hosted instance points at its own settings rather than github.com / gitlab.com. */
const tokenHelpUrl = $derived.by(() => {
const origin = parsed?.origin
if (provider === 'github') {
return `${origin ?? 'https://github.com'}/settings/personal-access-tokens/new`
}
// GitLab issues a project access token from the project itself.
const project = parsed?.pathname.replace(/\.git\/?$/, '').replace(/\/+$/, '')
return origin && project
? `${origin}${project}/-/settings/access_tokens`
: 'https://gitlab.com/-/user_settings/personal_access_tokens'
})
</script>
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Repository URL</span>
<TextInput
bind:value={repoUrl}
error={urlError}
inputProps={{
placeholder:
provider === 'github'
? 'https://github.com/acme/windmill-workspace'
: 'https://gitlab.com/acme/windmill-workspace'
}}
/>
{#if urlError}
<span class="text-2xs text-red-500">{urlError}</span>
{/if}
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">
{provider === 'github' ? 'Personal access token' : 'Access token'}
</span>
<span class="text-xs text-secondary">
{#if provider === 'github'}
A fine-grained token with read and write access to the repository's <b>Contents</b>.
{:else}
A project access token with the <code>write_repository</code> scope and the
<code>Developer</code> role.
{/if}
</span>
<TextInput bind:value={token} inputProps={{ type: 'password', autocomplete: 'off' }} />
<a
href={tokenHelpUrl}
target="_blank"
rel="noreferrer"
class="flex w-fit items-center gap-1 text-2xs text-accent hover:underline"
>
<ExternalLink size={12} />
{#if provider === 'github'}
Create one on GitHub{parsed ? ' — pick this repository, then Contents: read and write' : ''}
{:else}
Create one in the project's settings{parsed ? '' : ' — enter the repository URL first'}
{/if}
</a>
<span class="text-2xs text-hint">
Stored in a secret variable next to the resource. Renew it there before it expires.
</span>
</label>
</div>
@@ -0,0 +1,45 @@
import type { GitSyncRepository } from '../GitSyncContext.svelte'
/**
* Defaults for a connection that has not been saved yet, once its resource is known.
* Only fills what the user has not set, so it is safe to call again.
*/
export function applyNewConnectionDefaults(
repo: GitSyncRepository,
o: {
mode: 'sync' | 'promotion'
/** Windmill holds a credential for the repository: a GitHub App or a stored token. */
managedCredential: boolean
isGithubApp: boolean
isFork: boolean
ee: boolean
}
): void {
if (!repo.isUnsavedConnection) return
// Pulling from Git defaults on only where Windmill can receive webhooks. Polling is
// opt-in for token repositories, and forks never get the parent-only defaults (the
// backend rejects them).
if (
o.mode === 'sync' &&
o.managedCredential &&
!o.isFork &&
o.ee &&
repo.auto_pull === undefined
) {
repo.auto_pull = { enabled: true, mode: 'auto', sync_forks: true }
}
// A promotion deploy pushes a wm_deploy/** branch that exists to be merged; without a
// pull request it is an orphaned branch. Fork PRs stay opt-in everywhere.
if (
o.mode === 'promotion' &&
o.managedCredential &&
o.ee &&
repo.promotion_open_prs === undefined
) {
repo.promotion_open_prs = true
}
// Webhook with a polling fallback is the only delivery for app repositories.
if (o.isGithubApp && repo.auto_pull?.mode === 'polling') {
repo.auto_pull = { ...repo.auto_pull, mode: 'auto' }
}
}
@@ -0,0 +1,50 @@
import {
createGitHubAppState,
loadGithubInstallations,
startInstallationCheck,
stopInstallationCheck,
type GitHubAppState
} from '$lib/githubApp'
/**
* GitHub App installations for the setup dialog. Owned by the dialog rather than the
* connect form so the installations are known before the user leaves step 1: the
* install page has to be opened synchronously from that click, or the browser blocks
* the new tab.
*/
export class GithubAppSetup {
gh: GitHubAppState = $state(createGitHubAppState())
loaded = $state(false)
constructor(private workspace: string) {}
get usable() {
return this.gh.workspaceGithubInstallations.filter((i) => !i.error)
}
async reload(): Promise<void> {
try {
await loadGithubInstallations(this.gh, this.workspace)
} catch {
// loadGithubInstallations toasts its own failure
}
this.loaded = true
const usable = this.usable
if (!usable.some((i) => i.installation_id === this.gh.selectedGHAppInstallationId)) {
this.gh.selectedGHAppInstallationId = usable[0]?.installation_id
this.gh.selectedGHAppRepository = undefined
}
}
/** Must be called from a click handler, synchronously. */
openInstall(): void {
const url = this.gh.githubInstallationUrl
if (!url) return
window.open(url, '_blank', 'noopener')
startInstallationCheck(this.gh, this.workspace, () => void this.reload())
}
dispose(): void {
stopInstallationCheck(this.gh)
}
}
@@ -0,0 +1,29 @@
import { describe, expect, test } from 'vitest'
import { parseRepoUrl, repoSlug, withToken } from './repoConnection'
describe('parseRepoUrl', () => {
test('drops credentials, query and fragment from a pasted URL', () => {
const url = parseRepoUrl(' https://someone:secret@github.com/acme/infra.git?utm=1#readme ')
expect(url?.toString()).toBe('https://github.com/acme/infra.git')
})
test('refuses what cannot carry a token safely or name a repository', () => {
// http would put the token on the wire, and the rest name no repository at all.
expect(parseRepoUrl('http://github.com/acme/infra')).toBeUndefined()
expect(parseRepoUrl('ssh://git@github.com/acme/infra')).toBeUndefined()
expect(parseRepoUrl('github.com/acme/infra')).toBeUndefined()
expect(parseRepoUrl('https://github.com/acme')).toBeUndefined()
})
})
test('withToken encodes the token so a punctuated one survives the round trip', () => {
const token = 'glpat-a/b@c:d'
const embedded = withToken(parseRepoUrl('https://gitlab.com/acme/infra')!, token, 'gitlab')
expect(embedded).toBe(`https://oauth2:${encodeURIComponent(token)}@gitlab.com/acme/infra`)
expect(new URL(embedded).password).toBe(encodeURIComponent(token))
})
test('repoSlug names the resource after the repository', () => {
expect(repoSlug('https://github.com/acme/My-Repo.git')).toBe('my_repo')
expect(repoSlug('https://gitlab.com/group/sub/project/')).toBe('project')
})
@@ -0,0 +1,117 @@
import { GitSyncService, ResourceService, VariableService } from '$lib/gen'
/** What a provider form hands back once the user has picked a repository. */
export type RepoConnection = {
/** The repository URL, never carrying a secret. */
url: string
isGithubApp?: boolean
/** A token for Windmill to store and renew server-side (EE GitLab). */
heldToken?: string
/** `url` with the token embedded, filed as a secret variable because the
* resource value itself is readable by anyone who can read the resource. */
tokenUrl?: string
}
/** Username git sends alongside a token: GitHub ignores it for PATs, GitLab
* requires a non-empty one and documents `oauth2`. */
const TOKEN_USERNAME = { github: 'x-access-token', gitlab: 'oauth2' } as const
export type TokenProvider = keyof typeof TOKEN_USERNAME
/** Parses a pasted repository URL, dropping any credentials it carried. */
export function parseRepoUrl(raw: string): URL | undefined {
let u: URL
try {
u = new URL(raw.trim())
} catch {
return undefined
}
// https only: the token is embedded in this URL, and plain http would put it on the wire.
if (u.protocol !== 'https:') return undefined
if (u.pathname.replace(/\/+$/, '').split('/').filter(Boolean).length < 2) return undefined
u.username = ''
u.password = ''
u.search = ''
u.hash = ''
return u
}
export function withToken(url: URL, token: string, provider: TokenProvider): string {
const u = new URL(url.toString())
u.username = TOKEN_USERNAME[provider]
u.password = token.trim()
return u.toString()
}
/** A resource name derived from the repository, e.g. `acme/infra.git` → `infra`. */
export function repoSlug(url: string): string {
const last =
url
.replace(/\.git\/?$/, '')
.replace(/\/+$/, '')
.split('/')
.pop() ?? ''
return last.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() || 'git_repository'
}
/**
* Writes everything a connection needs and creates the `git_repository` resource at
* `path`. A token-bearing URL goes to a secret variable at the same path, which the
* backend renames along with the resource.
*/
export async function createRepositoryResource(
workspace: string,
path: string,
conn: RepoConnection,
location: { branch?: string; folder?: string }
): Promise<void> {
const branch = location.branch?.trim()
// Relative to the repository root, which is how the sync script joins it.
const folder = location.folder?.trim().replace(/^\/+|\/+$/g, '')
let url = conn.url
let createdVariable = false
if (conn.tokenUrl) {
if (await VariableService.existsVariable({ workspace, path })) {
throw new Error(`A variable already exists at ${path}. Pick another path.`)
}
await VariableService.createVariable({
workspace,
requestBody: {
path,
value: conn.tokenUrl,
is_secret: true,
description: `Authenticated URL of the git repository ${conn.url}`
}
})
createdVariable = true
url = `$var:${path}`
}
try {
if (conn.heldToken) {
await GitSyncService.setGitCredential({
workspace,
requestBody: { repo_url: conn.url, token: conn.heldToken }
})
}
await ResourceService.createResource({
workspace,
requestBody: {
path,
resource_type: 'git_repository',
description: `Git repository ${conn.url}`,
value: {
url,
...(branch ? { branch } : {}),
...(folder ? { folder } : {}),
is_github_app: !!conn.isGithubApp
}
}
})
} catch (e) {
// Otherwise a retry at the same path is refused over this run's own secret.
if (createdVariable) {
await VariableService.deleteVariable({ workspace, path }).catch(() => {})
}
throw e
}
}
@@ -4,7 +4,8 @@
import type { BadgeColor, BadgeIconProps } from '../common/badge/model'
import { appIconComponent } from '../icons'
import { integrationDisplayName } from '../resourceTypeDisplay'
import { onDestroy, onMount } from 'svelte'
import { untrack } from 'svelte'
import { useHostedPage } from '../hostedPage'
interface Props {
filters: string[]
@@ -39,42 +40,35 @@
inline = false
}: Props = $props()
const queryChange: (value: URL) => void = (url: URL) => {
if (syncQuery) {
window.history.pushState(history.state, '', `?${url?.searchParams.toString()}`)
}
}
const eventListener = (e: PopStateEvent) => {
if (syncQuery) {
loadFilterFromUrl()
}
}
onMount(() => {
window.addEventListener('popstate', eventListener)
})
onDestroy(() => {
window.removeEventListener('popstate', (e) => eventListener(e))
})
loadFilterFromUrl()
const hosted = useHostedPage()
function loadFilterFromUrl() {
let queryValue = new URL(window.location.href).searchParams.get(queryName) ?? undefined
selectedFilter = queryValue
const search = hosted ? hosted.search : window.location.search
selectedFilter = new URLSearchParams(search).get(queryName) ?? undefined
}
export async function setQuery(url: URL, key: string, value: string | undefined): Promise<void> {
if (value != undefined) {
url.searchParams.set(key, value)
} else {
url.searchParams.delete(key)
}
queryChange(url)
function writeQuery(value: string | undefined) {
if (!syncQuery) return
const params = new URLSearchParams(hosted ? hosted.search : window.location.search)
if (value != undefined) params.set(queryName, value)
else params.delete(queryName)
if (hosted) hosted.setSearch(params.size ? `?${params}` : '')
else window.history.pushState(history.state, '', `?${params}`)
}
$effect(() => {
if (!syncQuery) return
if (hosted) {
hosted.search
untrack(loadFilterFromUrl)
return
}
window.addEventListener('popstate', loadFilterFromUrl)
return () => window.removeEventListener('popstate', loadFilterFromUrl)
})
untrack(loadFilterFromUrl)
let filtersAndSelected = $derived(
selectedFilter
? filters.includes(selectedFilter)
@@ -109,11 +103,7 @@
class="inline-flex items-center gap-1 align-middle"
onclick={() => {
selectedFilter = selectedFilter == filter ? undefined : filter
if (selectedFilter) {
setQuery(new URL(window.location.href), queryName, selectedFilter)
} else {
setQuery(new URL(window.location.href), queryName, undefined)
}
writeQuery(selectedFilter)
}}
{color}
clickable
+31
View File
@@ -0,0 +1,31 @@
import { getContext, setContext } from 'svelte'
// A list page mounted somewhere other than its own route — an AI session's preview tab. The
// document URL there belongs to the host, so the page's view (the filters in its query) has to
// live wherever the host keeps it, and its rows and links open where the host decides instead of
// in a drawer or a top-level navigation. Components under such a host read this instead of
// `window.location`; outside one there is none, and they keep to the document.
export type HostedPage = {
/** The page's query, `''` or `?…`. Reactive: the host may re-point it. */
readonly search: string
/** Replace the page's query. */
setSearch(search: string): void
/** Open one of the page's rows, by path. */
openItem(path: string): void
/** Follow an in-app link (base included) the page would otherwise navigate to. */
openLink(href: string): void
}
const KEY = Symbol('hostedPage')
/** Reads context: call during component initialisation. */
export function setHostedPage(host: HostedPage): void {
setContext(KEY, host)
}
/** The host of this page, or undefined on the page's own route. Reads context: call during
* component initialisation. */
export function useHostedPage(): HostedPage | undefined {
return getContext<HostedPage | undefined>(KEY)
}
@@ -15,8 +15,10 @@
import { Badge } from '../common'
import { forLater } from '$lib/forLater'
import DurationMs from '../DurationMs.svelte'
import { workspaceStore } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const POPUP_HEIGHT = 320 as const
@@ -115,7 +117,7 @@
<span class="text-xs font-normal text-secondary">Arguments</span>
<JobArgs
id={job?.id}
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
workspace={job?.workspace_id ?? $operatingWorkspace ?? 'no_w'}
args={job?.args}
/>
</div>
File diff suppressed because it is too large Load Diff
@@ -76,15 +76,14 @@ export function buildResourcesFilterSchema({
icon: Tag,
description: 'Filter by label (comma-separated for multiple)'
},
...(showUserFoldersFilter
? {
user_folders_only: {
type: 'boolean' as const,
label: userFoldersLabel || 'User folders only',
icon: Users,
description: 'Show only resources in user folders'
}
}
: {})
// The key stays for every role, hidden where the filter does not apply: the list's URL sync
// reads the key set once, before the acting user that gates it may have loaded.
user_folders_only: {
type: 'boolean' as const,
label: userFoldersLabel || 'User folders only',
icon: Users,
description: 'Show only resources in user folders',
hidden: !showUserFoldersFilter
}
} satisfies FilterSchemaRec
}
@@ -0,0 +1,718 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import {
ScheduleService,
type ScheduleWJobs,
type WorkspaceDeployUISettings,
WorkspaceService
} from '$lib/gen'
import { canWrite, displayDate, getLocalSetting, storeLocalSetting } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { base } from '$app/paths'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Popover from '$lib/components/Popover.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { userWorkspaces, enterpriseLicense } from '$lib/stores'
import {
Calendar,
Circle,
Copy,
Eye,
FileUp,
List,
Loader2,
Pen,
Play,
Plus,
SearchX,
Shield,
Trash
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import FilterSearchbar, {
hasActiveFilters,
useUrlSyncedFilterInstance
} from '$lib/components/FilterSearchbar.svelte'
import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import JobPreview from '$lib/components/jobs/JobPreview.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { untrack } from 'svelte'
import { page } from '$app/stores'
import { useHostedPage } from '$lib/components/hostedPage'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import { runScheduleNow } from '$lib/components/triggers/scheduled/utils'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import {
useOperatingWorkspace,
useOperatingUser
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const hosted = useHostedPage()
function editSchedule(path: string, isFlow: boolean) {
if (hosted) hosted.openItem(path)
else scheduleEditor?.openEdit(path, isFlow)
}
type ScheduleW = ScheduleWJobs & { canWrite: boolean }
let schedules: ScheduleW[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let loadingSchedulesWithJobStats = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $operatingWorkspace! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadSchedules(): Promise<void> {
const currentFilters = filters.val
// Build API parameters from filters.
// `includeDraftOnly` surfaces per-user drafts at paths that have
// no deployed schedule — appended server-side when no narrowing
// filter is set, so the user sees AI-agent-created drafts on
// the home page.
const apiParams: any = {
workspace: $operatingWorkspace!,
includeDraftOnly: true
}
if (currentFilters.schedule_path) {
apiParams.schedulePath = currentFilters.schedule_path
}
if (currentFilters.path_start) {
apiParams.pathStart = currentFilters.path_start
}
if (currentFilters.path) {
apiParams.path = currentFilters.path
}
if (currentFilters.description) {
apiParams.description = currentFilters.description
}
if (currentFilters.summary) {
apiParams.summary = currentFilters.summary
}
if (currentFilters.args) {
apiParams.args = currentFilters.args
}
if (currentFilters._default_) {
apiParams.broadFilter = currentFilters._default_
}
if (currentFilters.label) {
apiParams.label = currentFilters.label
}
const result = (await ScheduleService.listSchedules(apiParams)).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, operatingUser.current), ...x }
})
// Extract unique values for autocomplete
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
allScriptPaths = Array.from(new Set(result.map((x) => x.script_path))).sort()
allLabels = Array.from(
new Set(result.flatMap((x) => [...(x.labels ?? []), ...(x.inherited_labels ?? [])]))
).sort()
schedules = result
loading = false
// after the schedule core data has been loaded, load all the job stats
// TODO: we could potentially not reload the job stats on every call to loadSchedules, but for now it's
// simpler to always call it. Update if performance becomes an issue.
loadSchedulesWithJobStats()
}
// Reload schedules when filters change
$effect(() => {
filters.val
if ($operatingWorkspace) {
untrack(() => loadSchedules())
}
})
async function loadSchedulesWithJobStats(): Promise<void> {
loadingSchedulesWithJobStats = true
let schedulesWithJobsByPath = new Map<string, ScheduleW>()
let schedulesWithJobsList = await ScheduleService.listSchedulesWithJobs({
workspace: $operatingWorkspace!
})
schedulesWithJobsList.map((x) => {
schedulesWithJobsByPath[x.path] = x
})
for (let schedule of schedules) {
if (schedulesWithJobsByPath[schedule.path]) {
schedule.jobs = schedulesWithJobsByPath[schedule.path].jobs
}
}
loadingSchedulesWithJobStats = false
}
// Per-path counter bumped when a schedule toggle is cancelled or errors,
// to force-remount that row's <Toggle>. Toggle uses `bind:checked` on
// its native input; once the user clicks, the local checkbox state
// diverges from the parent's prop expression, and Svelte 5 prop
// reactivity won't push a same-valued prop back down. Re-mounting
// re-initializes from the prop. List-page rows don't optimistically
// flip `enabled`, so they need this nudge — but only the affected row,
// not all rows on the page.
let toggleResetVersions = $state<Record<string, number>>({})
function bumpToggleReset(path: string) {
toggleResetVersions[path] = (toggleResetVersions[path] ?? 0) + 1
}
async function setScheduleEnabled(path: string, enabled: boolean): Promise<void> {
try {
const ok = await withForkConflictRetry(
(force) =>
ScheduleService.setScheduleEnabled({
path,
workspace: $operatingWorkspace!,
requestBody: { enabled, force }
}),
'schedule'
)
if (ok) {
loadSchedules()
} else {
// Cancelled — nothing changed on the server, skip the reload
// (which would re-fetch job stats and flash the loading flag)
// and just nudge the toggle back to the prop value.
bumpToggleReset(path)
}
} catch (err) {
sendUserToast(`Cannot ` + (enabled ? 'enable' : 'disable') + ` schedule: ${err.body}`, true)
bumpToggleReset(path)
loadSchedules()
}
}
$effect(() => {
if ($operatingWorkspace && operatingUser.current) {
untrack(() => {
loadSchedules()
})
}
})
let scheduleEditor: ScheduleEditor | undefined = $state()
// Deep link: #<path> opens that schedule's edit drawer. Tracks the last
// handled hash (not a one-shot flag) so a hash change on the already-mounted
// page (e.g. the AI session preview re-pointing its tab) opens the drawer
// too. Row links pre-set handledHash: their onclick already opens the drawer.
let handledHash = ''
$effect(() => {
// A host opens rows itself, and the document's hash is not this page's.
if (hosted) return
const hash = $page.url.hash
if (hash.length <= 1) {
// Navigating away from a drawer target must clear the tracker, or
// re-targeting the same schedule later would be skipped as already handled.
handledHash = ''
return
}
if (hash === handledHash || schedules.length === 0 || !scheduleEditor) return
const path = hash.slice(1)
const schedule = schedules.find((s) => s.path === path)
if (schedule) {
handledHash = hash
scheduleEditor.openEdit(path, schedule.is_flow)
}
})
// Collect unique values for filter autocomplete
let allPaths: string[] = $state([])
let allScriptPaths: string[] = $state([])
let allLabels: string[] = $state([])
// FilterSearchbar setup
let userFoldersFilterType = $derived(
operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')
? 'only f/*'
: operatingUser.current?.is_admin || operatingUser.current?.is_super_admin
? 'u/username and f/*'
: undefined
)
let schedulesFilterSchema = $derived(
buildSchedulesFilterSchema({
paths: allPaths,
scriptPaths: allScriptPaths,
labels: allLabels,
showUserFoldersFilter: userFoldersFilterType !== undefined,
userFoldersLabel:
userFoldersFilterType === 'only f/*'
? 'Only f/*'
: `Only u/${operatingUser.current?.username} and f/*`
})
)
let filters = useUrlSyncedFilterInstance(untrack(() => schedulesFilterSchema))
// A link can carry the user folders filter to someone it is not offered to, where it would sit
// set with no chip to clear it. Dropped only once a user is actually known: `resolved` holds for
// the navigation workspace even while its user is still loading.
$effect(() => {
if (!operatingUser.resolved($operatingWorkspace) || !operatingUser.current) return
if (userFoldersFilterType !== undefined) return
if (!filters.val.user_folders_only) return
untrack(() => {
const { user_folders_only: _, ...rest } = filters.val
filters.val = rest
})
})
let activeFilters = $derived(hasActiveFilters(filters.val))
let allFolders = $derived(
Array.from(
new Set(
(schedules ?? [])
.map((x) => x.path.split('/').slice(0, 2).join('/'))
.filter((x) => x.startsWith('f/'))
)
)
.sort()
.map((f) => f.replace(/^f\//, ''))
)
let presets = $derived([
...allFolders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })),
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l}` })),
...(!schedulesFilterSchema.user_folders_only.hidden
? [
{
name: schedulesFilterSchema.user_folders_only.label ?? '?',
value: 'user_folders_only:\\ true'
}
]
: [])
])
let nbDisplayed = $state(15)
let filterEnabledDisabled: 'all' | 'enabled' | 'disabled' = $state('all')
const SCHEDULE_PATH_KIND_FILTER_SETTING = 'schedulePathKindFilter'
let selectedFilterKind = $state(
(getLocalSetting(SCHEDULE_PATH_KIND_FILTER_SETTING) as 'schedule' | 'script_flow') ?? 'schedule'
)
$effect(() => {
storeLocalSetting(SCHEDULE_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
function filterItemsPathsBaseOnUserFilters(
item: ScheduleW,
selectedFilterKind: 'schedule' | 'script_flow',
userFoldersOnly: boolean
) {
if (userFoldersOnly) {
if (selectedFilterKind === 'schedule') {
return (
!item.path.startsWith('u/') ||
item.path.startsWith('u/' + operatingUser.current?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + operatingUser.current?.username + '/')
)
}
} else {
return true
}
}
function filterItemsBasedOnEnabledDisabled(
item: ScheduleW,
filterEnabledDisabled: 'all' | 'enabled' | 'disabled'
) {
// Draft-only rows have nothing deployed, so they read as disabled here
// too — matching the off, disabled toggle the row renders.
const enabled = !item.draft_only && item.enabled
if (filterEnabledDisabled === 'all') return true
if (filterEnabledDisabled === 'enabled') return enabled
if (filterEnabledDisabled === 'disabled') return !enabled
}
// Filter schedules client-side for enabled/disabled and user folders
let filteredItems = $derived.by(() => {
return schedules?.filter(
(x) =>
filterItemsPathsBaseOnUserFilters(
x,
selectedFilterKind,
userFoldersFilterType !== undefined && !!filters.val.user_folders_only
) && filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
)
})
let items = $derived(filteredItems)
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<ScheduleEditor onUpdate={loadSchedules} bind:this={scheduleEditor} />
{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.schedules}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Schedules"
tooltip="Trigger Scripts and Flows according to a cron schedule"
documentationLink="https://www.windmill.dev/docs/core_concepts/scheduling"
>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => scheduleEditor?.openNew(false)}
aiId="schedules-add-schedule"
aiDescription="Add schedule"
>
New schedule
</Button>
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="flex flex-row items-center justify-end gap-4 pb-4">
<ToggleButtonGroup bind:selected={filterEnabledDisabled} class="w-fit">
{#snippet children({ item })}
<ToggleButton value="all" label="All" {item} />
<ToggleButton value="enabled" label="Enabled" {item} />
<ToggleButton value="disabled" label="Disabled" {item} />
{/snippet}
</ToggleButtonGroup>
<FilterSearchbar
schema={schedulesFilterSchema}
bind:value={filters.val}
class="grow max-w-[26rem]"
{presets}
/>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !schedules?.length}
{#if activeFilters}
<EmptyState
icon={SearchX}
title="No schedules found"
description="No schedule matches the current filters. Try clearing or widening them."
/>
{:else}
<EmptyState
icon={Calendar}
title="No schedules yet"
description="Schedules run a script or flow automatically on a cron expression."
action={{
label: 'Add a schedule',
icon: Plus,
onClick: () => scheduleEditor?.openNew(false),
aiId: 'schedules-empty-add',
aiDescription: 'Add schedule'
}}
/>
{/if}
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)}
{@const hasDraft =
getLocalDraftHint($operatingWorkspace, 'trigger_schedule', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const avg_s = jobs
? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length
: undefined}
<div
class="bg-surface-tertiary hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-4 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => {
handledHash = `#${path}`
editSchedule(path, is_flow)
}}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
>
{summary || script_path}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left">
schedule: {path}
</div>
</a>
{#if labels?.length}
{#each labels as label}
<Badge
color="blue"
small
class="px-1"
title="Label: {label}"
clickable
onclick={() => {
const arr = (filters.val.label ?? '').split(',').filter(Boolean)
const idx = arr.indexOf(label)
if (idx >= 0) arr.splice(idx, 1)
else arr.push(label)
const newFilters = { ...filters.val }
if (arr.length) newFilters.label = arr.join(',')
else delete newFilters.label
filters.val = newFilters
}}>{label}</Badge
>
{/each}
{/if}
<InheritedLabels labels={inherited_labels} />
{#if paused_until && new Date(paused_until) > new Date()}
<div class="pb-1">
<Badge color="yellow"
>Paused until {new Date(paused_until).toLocaleString()}</Badge
>
</div>
{/if}
<div class="gap-2 items-center hidden md:flex">
<Badge large color="blue">{schedule}</Badge>
<Badge small color="gray">{timezone}</Badge>
</div>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<div class="w-10">
{#if error}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
The schedule disabled itself because there was an error scheduling the
next job: {error}
</div>
{/snippet}
</Popover>
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
{#key toggleResetVersions[path] ?? 0}
<Toggle
disabled={draft_only}
options={{
title: draft_only
? 'Draft only: deploy the schedule to enable it'
: hasDraft
? 'Enables/disables the deployed schedule; the draft is not affected'
: undefined
}}
checked={!draft_only && enabled}
on:change={(e) => {
if (canWrite) {
setScheduleEnabled(path, e.detail)
} else {
sendUserToast('not enough permission', true)
// Permission denied — bump the row's reset
// counter so the Toggle remounts back to the
// prop value. Without this, the local
// `bind:checked` flip from the user's click
// stays stuck on.
bumpToggleReset(path)
}
}}
/>
{/key}
</div>
<div class="flex gap-2 items-center justify-end">
<Button
href={`${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`}
disabled={draft_only}
unifiedSize="md"
startIcon={{ icon: List }}
variant="subtle"
>
Runs
</Button>
<Button
on:click={() => editSchedule(path, is_flow)}
unifiedSize="md"
startIcon={{ icon: canWrite ? Pen : Eye }}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
size="md"
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
if (hosted) hosted.openLink(`${base}${href}`)
else goto(href)
}
},
{
displayName: `Duplicate schedule`,
icon: Copy,
action: () => {
scheduleEditor?.openNew(is_flow, script_path, path)
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
await ScheduleService.deleteSchedule({
workspace: $operatingWorkspace ?? '',
path
})
loadSchedules()
}
},
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
editSchedule(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'schedules'
}
})
}
}
]
: []),
{
displayName: 'View runs',
icon: List,
href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`
},
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Run now',
icon: Play,
action: () => {
runScheduleNow(script_path, path, is_flow, $operatingWorkspace!)
}
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'schedule')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
{#if loadingSchedulesWithJobStats}
<div class="flex gap-1 ml-0.5 text-xs text-secondary items-center">
<Loader2 size={14} class="animate-spin" />
<span>Job stats loading...</span>
</div>
{:else}
<div class="flex gap-1.5 ml-0.5 items-baseline flex-row-reverse">
{#if avg_s}
<div class="pl-2 text-secondary text-xs"
>Avg: {(avg_s / 1000).toFixed(2)}s</div
>
{/if}
{#each jobs ?? [] as job}
{@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3}
<a href="{base}/run/{job.id}?workspace={$operatingWorkspace}">
<JobPreview id={job.id} class="p-4">
<div>
<div
class="{job.success ? 'bg-green-300' : 'bg-red-300'} mx-auto w-1.5"
style="height: {h}px"
></div>
<!-- <div class="text-[0.6em] mt-0.5 text-center text-primary"
>{(job.duration_ms / 1000).toFixed(2)}s</div
> -->
</div>
</JobPreview>
</a>
{/each}
</div>
{/if}
<div
class="flex flex-wrap text-xs text-secondary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<div class="flex items-center gap-4 text-xs font-semibold text-emphasis">
<span>{nbDisplayed} items out of {items.length}</span>
<Button unifiedSize="sm" variant="subtle" on:click={() => (nbDisplayed += 30)}>
Load 30 more
</Button>
</div>
{/if}
</CenteredPage>
{/if}
<ShareModal
bind:this={shareModal}
on:change={() => {
loadSchedules()
}}
/>
@@ -73,15 +73,14 @@ export function buildSchedulesFilterSchema({
icon: Tag,
description: 'Filter by label (comma-separated for multiple)'
},
...(showUserFoldersFilter
? {
user_folders_only: {
type: 'boolean' as const,
label: userFoldersLabel || 'User folders only',
icon: Users,
description: 'Show only schedules in user folders'
}
}
: {})
// The key stays for every role, hidden where the filter does not apply: the list's URL sync
// reads the key set once, before the acting user that gates it may have loaded.
user_folders_only: {
type: 'boolean' as const,
label: userFoldersLabel || 'User folders only',
icon: Users,
description: 'Show only schedules in user folders',
hidden: !showUserFoldersFilter
}
} satisfies FilterSchemaRec
}
@@ -18,6 +18,7 @@
let {
items,
placeholder = 'Select items',
searchPlaceholder = 'Search...',
value = $bindable(),
class: className = '',
style,
@@ -42,6 +43,9 @@
items?: Item[]
value: Value[]
placeholder?: string
/** Placeholder of the dropdown's search field. Worth setting where the list is there to
* create entries rather than pick from, so the field says what to type. */
searchPlaceholder?: string
class?: string
style?: string
filterText?: string
@@ -96,7 +100,12 @@
function onAddValue(item: ProcessedItem<Value>) {
if (item.__is_create && onCreateItem) {
const before = currentValue.length
onCreateItem(item.value)
// Cleared only once the entry exists: it is then in the list, and leaving it in the
// field would filter the list down to it. A consumer that refused the value (a
// duplicate, a name it rejects) keeps what was typed, to be corrected.
if ((value?.length ?? 0) !== before) filterText = ''
} else {
value = [...currentValue, item.value]
}
@@ -192,7 +201,7 @@
bind:value={filterText}
inputProps={{
onblur: (e) => (e.preventDefault(), searchInputEl?.focus()),
placeholder: 'Search...'
placeholder: searchPlaceholder
}}
class="!pr-7 !bg-surface"
/>
@@ -0,0 +1,131 @@
<script lang="ts">
import { setContext, untrack } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { setHostedPage, type HostedPage } from '$lib/components/hostedPage'
import { pageItemForListPath, parsePreviewItemRoute } from './previewPaths'
import { previewLocationLabel } from './previewRouter'
import { whereIs } from './sessionPreviewTabs.svelte'
import type { SessionPreviewTab } from './sessionState.svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
let {
runtime,
tab,
path,
workspaceId,
container,
reloadNonce = 0
}: {
runtime: SessionRuntime
tab: SessionPreviewTab
/** The list page, base-stripped. */
path: string
workspaceId: string
/** The tab's element, which also holds the list's portalled drawers and menus. */
container: HTMLElement
/** Bumped to load the list again from the server. */
reloadNonce?: number
} = $props()
// Mounted in the preview panel, outside the chat's own subtree, as PageItemEditorView is.
setContext(
'aiChatManager',
untrack(() => runtime.manager)
)
setOperatingWorkspace(() => workspaceId)
// The tab is where the list keeps its filters: written as the tab's observed location, so
// the chat reads them and a remount restores them, as it would for a frame.
const locationParts = () => {
const loc = whereIs(tab).split('#')[0]
const at = loc.indexOf('?')
return at < 0
? { pathname: loc, search: '' }
: { pathname: loc.slice(0, at), search: loc.slice(at) }
}
const host: HostedPage = {
get search() {
return locationParts().search
},
setSearch(search) {
runtime.previewTabs.observeLocation(tab.id, locationParts().pathname + search)
},
openItem(itemPath) {
const ref = pageItemForListPath(path, itemPath)
if (ref) runtime.previewTabs.open({ type: 'pageitem', ref })
},
openLink(href) {
const item = parsePreviewItemRoute(href)
if (item) {
runtime.previewTabs.open({
type: 'item',
item: { kind: item.kind, raw_app: item.raw_app, path: item.itemPath, summary: '' }
})
} else {
runtime.previewTabs.open({ type: 'page', href, label: previewLocationLabel(href) })
}
}
}
setHostedPage(host)
// Any in-app link under the tab opens in the session rather than taking the whole window off
// it: a list, its drawers and its menus carry plain hrefs throughout (runs, audit logs, a
// job). A `#` link is a row's own: its handler opens the row through the host, and following
// it would write the row into the sessions page's own hash.
$effect(() => {
const el = container
function onClick(e: MouseEvent) {
if (e.defaultPrevented || e.button !== 0) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const a = (e.target as Element | null)?.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
host.openLink(url.pathname + url.search + url.hash)
}
el.addEventListener('click', onClick)
return () => el.removeEventListener('click', onClick)
})
const listed = $derived(pageItemForListPath(path, ''))
</script>
{#snippet loading()}
<div class="flex-1 flex items-center justify-center text-tertiary">
<Loader2 class="animate-spin" />
</div>
{/snippet}
<!-- Dynamic imports, as the editors: each list pulls in its item's editor. -->
<div class="flex h-full min-h-0 flex-col overflow-auto">
{#key `${path}:${workspaceId}:${reloadNonce}`}
{#if listed?.kind === 'variable'}
{#await import('$lib/components/variables/VariablesList.svelte')}
{@render loading()}
{:then Module}
<Module.default />
{/await}
{:else if listed?.kind === 'resource'}
{#await import('$lib/components/resources/ResourcesList.svelte')}
{@render loading()}
{:then Module}
<Module.default />
{/await}
{:else if listed?.kind === 'schedule'}
{#await import('$lib/components/schedules/SchedulesList.svelte')}
{@render loading()}
{:then Module}
<Module.default />
{/await}
{:else if listed?.kind === 'trigger'}
{#await import('$lib/components/triggers/TriggerList.svelte')}
{@render loading()}
{:then Module}
<Module.default triggerKind={listed.triggerKind} />
{/await}
{/if}
{/key}
</div>
@@ -105,6 +105,7 @@
// A page item's editor reads its draft only when it loads, so a reload remounts it.
let pageItemReloadNonce = $state(0)
let pageListReloadNonce = $state(0)
export function reload() {
// A live editor shares the runtime store the chat mutates, so generic chat
@@ -116,6 +117,10 @@
pageItemReloadNonce++
return
}
if (slot.kind === 'pagelist') {
pageListReloadNonce++
return
}
try {
const win = frame?.contentWindow
if (!win) return
@@ -334,6 +339,27 @@
{/await}
{/if}
</div>
{:else if slot.kind === 'pagelist' && mounted && runtime}
<div
bind:this={overlayHostEl}
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
aria-hidden={!active}
>
{#if overlayHostEl}
{#await import('./PageListView.svelte')}
{@render editorLoading()}
{:then Module}
<Module.default
{runtime}
{tab}
path={slot.path}
{workspaceId}
container={overlayHostEl}
reloadNonce={pageListReloadNonce}
/>
{/await}
{/if}
</div>
{:else if slot.kind === 'artifact' && mounted}
<div
bind:this={overlayHostEl}
@@ -21,7 +21,6 @@ import {
type TriggerKind
} from './previewPaths'
import type { OpenInSessionSource } from './OpenInSessionButton.svelte'
import { isSessionPreviewFrame } from './sessionMode.svelte'
// The draft each page's drawer edits. The preview loads the page in its own
// document and reads the draft back from the server, so opening a session has to
@@ -84,34 +83,6 @@ export function setPageDrawerAnchor(pagePath: string, itemPath: string | undefin
history.replaceState(history.state, '', `${pathname}${search}${anchor}`)
}
/**
* Inside a session preview frame, hand a list page row up to the session, which edits it in
* a tab of its own. True when handed off: the caller must then not open its drawer. False
* off that page, and outside a preview frame, where the drawer is how the row is edited.
*/
export function handOffPageDrawer(pagePath: string, itemPath: string | undefined): boolean {
if (!itemPath || !isSessionPreviewFrame()) return false
if (stripBase(window.location.pathname) !== pagePath) return false
try {
window.parent.postMessage(
{ type: 'wm.session.openPageItem', pagePath, path: itemPath },
window.location.origin
)
} catch {
return false
}
// A frame left on a row's hash claims a row nobody has open here, and reopens its tab on
// every reload. Not through the router: these pages open their drawer from the hash. Once
// more after the event: a row link's `href="#<path>"` lands after its click handler.
const dropAnchor = () => {
const { pathname, search, hash } = window.location
if (hash) history.replaceState(history.state, '', `${pathname}${search}`)
}
dropAnchor()
setTimeout(dropAnchor, 0)
return true
}
/**
* Drop the row a list page deep-links, once its drawer closes. The hash is how the row was
* requested; leaving it behind makes the location claim a drawer that is no longer open —
@@ -79,6 +79,11 @@ export function pageItemListPath(ref: PageItemRef): string {
}
}
/** Whether a (base-stripped) path is the list page of a page item kind. */
export function isPageItemListPath(path: string): boolean {
return pageItemForListPath(path, '') !== undefined
}
/** The page item a list page's row names, or undefined for a page that lists none. */
export function pageItemForListPath(pagePath: string, path: string): PageItemRef | undefined {
const clean = stripBase(pagePath)
@@ -284,6 +284,17 @@ describe('resolvePreviewTab', () => {
expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' })
})
it('mounts the list page of a page item kind in process, whatever its filters', () => {
expect(resolvePreviewTab('/variables?path_start=f%2Fa%2F')).toEqual({
kind: 'pagelist',
path: '/variables'
})
expect(resolvePreviewTab('/kafka_triggers')).toEqual({
kind: 'pagelist',
path: '/kafka_triggers'
})
})
it('routes any script item to a live editor', () => {
expect(resolvePreviewTab('/scripts/edit/f/foo/bar')).toEqual({
kind: 'editor',
@@ -3,6 +3,7 @@ import {
AUDIT_LOGS_PATH,
FOLDERS_PATH,
GROUPS_PATH,
isPageItemListPath,
pageItemForListPath,
pageItemListPath,
pageItemPageHref,
@@ -530,13 +531,14 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:')
// fallback. Any editable item of a wrappable kind (script, flow, raw app) mounts
// its per-(kind,path) cell editor; a `/pipeline/<folder>` route mounts the
// data-pipeline graph editor (single, shared runtime.pipelineEditorState — `path`
// is the folder); everything else (static pages, regular drag-and-drop apps, any
// other route) stays an iframe.
// is the folder); the list page of a page item kind mounts that list; everything
// else (static pages, regular drag-and-drop apps, any other route) stays an iframe.
export type PreviewSlot =
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
| { kind: 'artifact'; id: string; version?: number }
| { kind: 'runform'; toolCallId: string }
| { kind: 'pageitem'; ref: PageItemRef }
| { kind: 'pagelist'; path: string }
| { kind: 'iframe' }
export function resolvePreviewTab(url: string): PreviewSlot {
@@ -546,22 +548,24 @@ export function resolvePreviewTab(url: string): PreviewSlot {
if (runForm) return { kind: 'runform', toolCallId: runForm.toolCallId }
const pageItem = parsePageItemRoute(url)
if (pageItem) return { kind: 'pageitem', ref: pageItem }
const route = stripBase(url)
if (isPageItemListPath(route)) return { kind: 'pagelist', path: route }
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) {
return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
}
const route = parsePreviewItemRoute(url)
if (!route) return { kind: 'iframe' }
const item = parsePreviewItemRoute(url)
if (!item) return { kind: 'iframe' }
const editorKind: SessionTargetKind | undefined =
route.kind === 'script'
item.kind === 'script'
? 'script'
: route.kind === 'flow'
: item.kind === 'flow'
? 'flow'
: route.kind === 'app' && route.raw_app
: item.kind === 'app' && item.raw_app
? 'raw_app'
: undefined
if (!editorKind) return { kind: 'iframe' }
return { kind: 'editor', editorKind, path: route.itemPath }
return { kind: 'editor', editorKind, path: item.itemPath }
}
/** The full workspace page showing what a tab shows ("Open in workspace"), or undefined when
@@ -576,6 +580,7 @@ export function workspacePageHref(location: string): string | undefined {
case 'pageitem':
return pageItemPageHref(slot.ref)
case 'editor':
case 'pagelist':
case 'iframe':
return location
default: {
@@ -406,7 +406,8 @@ function createRuntime(session: Session): SessionRuntime {
const tab = owner?.displayedTab
if (!tab) return undefined
const slotKind = resolvePreviewTab(tab.url).kind
if (slotKind !== 'iframe' && slotKind !== 'pageitem') return undefined
if (slotKind !== 'iframe' && slotKind !== 'pageitem' && slotKind !== 'pagelist')
return undefined
return previewLocationContext(whereIs(tab))
}
// Pre-flight: materialise the (still-transient) session, then commit

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