mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
Merge remote-tracking branch 'origin/main' into glm/oauth_cloud_issue
# Conflicts: # backend/ee-repo-ref.txt
This commit is contained in:
+9
-13
@@ -55,22 +55,18 @@
|
||||
"mcp__claude_ai_Gmail__list_drafts"
|
||||
],
|
||||
"deny": [
|
||||
"Read(.env)",
|
||||
"Read(.env.*)",
|
||||
"Read(**/.env)",
|
||||
"Read(**/.env.*)",
|
||||
"Read(**/secrets/**)",
|
||||
"Read(**/*.pem)",
|
||||
"Read(**/*.key)",
|
||||
"Read(**/credentials.json)",
|
||||
"Read(**/.secret*)",
|
||||
"Read(**/.secrets*)",
|
||||
"Read(**/*.secret)",
|
||||
"Read(**/*.secrets)",
|
||||
"Edit(.env)",
|
||||
"Edit(.env.*)",
|
||||
"Edit(**/.env)",
|
||||
"Edit(**/.env.*)"
|
||||
"Edit(**/.env.*)",
|
||||
"Edit(**/secrets/**)",
|
||||
"Edit(**/*.pem)",
|
||||
"Edit(**/*.key)",
|
||||
"Edit(**/credentials.json)",
|
||||
"Edit(**/.secret*)",
|
||||
"Edit(**/.secrets*)",
|
||||
"Edit(**/*.secret)",
|
||||
"Edit(**/*.secrets)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(rmdir:*)",
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
bun-version: 1.4.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
- uses: astral-sh/setup-uv@v6.2.1
|
||||
with:
|
||||
version: "0.11.24"
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- "backend/windmill-api-integration-tests/tests/git_sync*"
|
||||
- "backend/ee-repo-ref.txt"
|
||||
- "backend/windmill-common/src/workspaces.rs"
|
||||
- "frontend/src/lib/hubPaths.json"
|
||||
- "backend/windmill-worker/src/result_processor.rs"
|
||||
- "backend/windmill-api-workspaces/**"
|
||||
- "cli/src/commands/sync/**"
|
||||
@@ -22,6 +23,7 @@ on:
|
||||
- "backend/windmill-api-integration-tests/tests/git_sync*"
|
||||
- "backend/ee-repo-ref.txt"
|
||||
- "backend/windmill-common/src/workspaces.rs"
|
||||
- "frontend/src/lib/hubPaths.json"
|
||||
- "backend/windmill-worker/src/result_processor.rs"
|
||||
- "backend/windmill-api-workspaces/**"
|
||||
- "cli/src/commands/sync/**"
|
||||
@@ -59,7 +61,7 @@ jobs:
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Direct git sync file changes — always relevant.
|
||||
if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-worker/src/result_processor\.rs|backend/windmill-api-workspaces/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|cli/src/commands/sync/|cli/src/utils/git\.ts|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then
|
||||
if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-worker/src/result_processor\.rs|backend/windmill-api-workspaces/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|frontend/src/lib/hubPaths\.json|cli/src/commands/sync/|cli/src/utils/git\.ts|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Relevant: direct git sync file changes"
|
||||
exit 0
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.800.1"
|
||||
".": "1.803.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
## [1.803.0](https://github.com/windmill-labs/windmill/compare/v1.802.0...v1.803.0) (2026-09-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* expose request headers to scripts invoked via MCP ([#10903](https://github.com/windmill-labs/windmill/issues/10903)) ([e474e88](https://github.com/windmill-labs/windmill/commit/e474e8803ce2ff5c2df09a58dab51d45f5c922ca))
|
||||
* reuse an existing workspace resource in the project import wizard ([#10935](https://github.com/windmill-labs/windmill/issues/10935)) ([582761e](https://github.com/windmill-labs/windmill/commit/582761e37c776e92dc1c6ebfee8c4efe7c35d822))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump git sync hub scripts to cli 1.802.1, test the fork ui pull ([#10955](https://github.com/windmill-labs/windmill/issues/10955)) ([ca88009](https://github.com/windmill-labs/windmill/commit/ca8800959aa6a0017cc29bad187c9f49e0d13cc4))
|
||||
* **cli:** make a sync push into a fork converge on schedules and inline names ([#10951](https://github.com/windmill-labs/windmill/issues/10951)) ([0f5a1db](https://github.com/windmill-labs/windmill/commit/0f5a1db2abba8df30a2f975f4498e269f13cf93d))
|
||||
* fade the home Build with AI placeholder every 10s instead of typing it ([#10953](https://github.com/windmill-labs/windmill/issues/10953)) ([3d089b5](https://github.com/windmill-labs/windmill/commit/3d089b57344f5814086e6176301c5031dc519674))
|
||||
* let operators use wmill.datatable() from within running jobs ([#10931](https://github.com/windmill-labs/windmill/issues/10931)) ([9b64a89](https://github.com/windmill-labs/windmill/commit/9b64a89cd46ae718d6c58fa12f925fa041fb1032))
|
||||
|
||||
## [1.802.0](https://github.com/windmill-labs/windmill/compare/v1.801.0...v1.802.0) (2026-09-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add retention cleanup for the otel_traces table ([#10949](https://github.com/windmill-labs/windmill/issues/10949)) ([d472193](https://github.com/windmill-labs/windmill/commit/d472193e5bf5f6428e0096a402eb2c9299634fb2))
|
||||
* open path links from chat messages in the session preview panel ([#10881](https://github.com/windmill-labs/windmill/issues/10881)) ([f10ac6c](https://github.com/windmill-labs/windmill/commit/f10ac6c2b3644fb16697e650efbc4f7cd3c6944c))
|
||||
* restore owner and label filter chips on the homepage ([#10942](https://github.com/windmill-labs/windmill/issues/10942)) ([ccf8476](https://github.com/windmill-labs/windmill/commit/ccf84761dd664b9228dfe2f65867e8c32cd20c21))
|
||||
* **sessions:** offer the item you came from when starting a new session ([#10940](https://github.com/windmill-labs/windmill/issues/10940)) ([d3747d6](https://github.com/windmill-labs/windmill/commit/d3747d62555ebcb09c78cfabcaa3b6177758d6ea))
|
||||
* workspace setting to hide the AI assistant, agent steps unaffected ([#10941](https://github.com/windmill-labs/windmill/issues/10941)) ([fdd3b36](https://github.com/windmill-labs/windmill/commit/fdd3b36423344a2e1a464674179406581074e926))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* apply object-storage test SSRF validation to all non-super-admins ([#10933](https://github.com/windmill-labs/windmill/issues/10933)) ([4fef119](https://github.com/windmill-labs/windmill/commit/4fef1195adaa9fa036a219884bd6c996460ca37f))
|
||||
* connect to dev server instead of localhost ([#10912](https://github.com/windmill-labs/windmill/issues/10912)) ([337154b](https://github.com/windmill-labs/windmill/commit/337154b8304a5969f35216add627b5c1153c0f6c))
|
||||
* preselect first row of AI agent and AI sandbox insert panes ([#10937](https://github.com/windmill-labs/windmill/issues/10937)) ([95b6bbd](https://github.com/windmill-labs/windmill/commit/95b6bbd46ada11d96a914ae5b0e92aba4dd02530))
|
||||
* record supplied script lock hashes so importers can skip relocking ([#10915](https://github.com/windmill-labs/windmill/issues/10915)) ([17ba521](https://github.com/windmill-labs/windmill/commit/17ba521c352aec65a8270893752bbadd7f3d6eaa))
|
||||
* sandbox script-controlled content types in result_to_response ([#10932](https://github.com/windmill-labs/windmill/issues/10932)) ([419741e](https://github.com/windmill-labs/windmill/commit/419741e5d226c67c51429094fb6ded9474afed99))
|
||||
|
||||
## [1.801.0](https://github.com/windmill-labs/windmill/compare/v1.800.1...v1.801.0) (2026-09-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **ai-chat:** make reusable skills ai_skill resources you select per workspace ([#10914](https://github.com/windmill-labs/windmill/issues/10914)) ([cfcfe29](https://github.com/windmill-labs/windmill/commit/cfcfe298dd9ab50196bd64926ef78c4563f58c2c))
|
||||
* **ai-sessions:** show a running session across tabs and reload finished turns ([#10916](https://github.com/windmill-labs/windmill/issues/10916)) ([816dc9d](https://github.com/windmill-labs/windmill/commit/816dc9dcd2c310e499d2d210a0abcd403469f29c))
|
||||
* edit folders and groups in a drawer that saves once ([#10873](https://github.com/windmill-labs/windmill/issues/10873)) ([5d5ad4e](https://github.com/windmill-labs/windmill/commit/5d5ad4e8974e076ef53a26a5584e4209255a2248))
|
||||
* make the home Build with AI composer dismissible, quiet the rest of the home page ([#10930](https://github.com/windmill-labs/windmill/issues/10930)) ([772fafe](https://github.com/windmill-labs/windmill/commit/772fafec8316a1e0c0e76b9a0737cc41d40a9a8c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* let a principal without a login account own a draft ([#10925](https://github.com/windmill-labs/windmill/issues/10925)) ([94af8d0](https://github.com/windmill-labs/windmill/commit/94af8d0fb5aceebe83936fd6761c6c1c02c75323))
|
||||
* resolve chat path links against the session's operating workspace ([#10924](https://github.com/windmill-labs/windmill/issues/10924)) ([9074de2](https://github.com/windmill-labs/windmill/commit/9074de25ea730ca02653c9a2e2b8b99eda6f3137))
|
||||
* tolerate string app_id in GHES app config deserialization ([#10923](https://github.com/windmill-labs/windmill/issues/10923)) ([af8ff38](https://github.com/windmill-labs/windmill/commit/af8ff3868748412cb658c803ebc8a71edc3cd8fb))
|
||||
|
||||
## [1.800.1](https://github.com/windmill-labs/windmill/compare/v1.800.0...v1.800.1) (2026-09-01)
|
||||
|
||||
|
||||
|
||||
+19
@@ -36,3 +36,22 @@ _Avoid_: argument field, param
|
||||
**Expression input**:
|
||||
Any other place a property can be picked into: the loop iterator, skip and early-stop predicates, the retry condition, a branch predicate, timeout. Its prop picker opens in a popover from the connect button rather than taking a pane.
|
||||
_Avoid_: JS field, code input
|
||||
|
||||
### Permissions
|
||||
|
||||
**Member**:
|
||||
A user or group granted a role on a folder, a group, or an item's extra ACL. The list of them is
|
||||
"Members (n)" everywhere it is shown, and one is added with "Add member".
|
||||
_Avoid_: participant, collaborator, owner, ACL entry, permission (that names the concept, not the people)
|
||||
|
||||
**Role**:
|
||||
The access level a member holds: viewer, writer or admin on a folder; member or admin on a group.
|
||||
Viewers read, writers also edit, admins also manage the members. A group role of **manager** —
|
||||
manages the group without belonging to it — is a legacy state the UI shows and can leave, but
|
||||
offers no way to enter.
|
||||
_Avoid_: permission level, access level, rank
|
||||
|
||||
**Owner**:
|
||||
Reserved for the path prefix that says where an item lives — `u/alice` or `f/team`. A folder's
|
||||
`owners` column in the database is its admin members; call those admins, never owners, in the UI.
|
||||
_Avoid_: using "owner" for a folder admin
|
||||
|
||||
+5
-5
@@ -141,9 +141,9 @@ FROM ${DEBIAN_IMAGE}
|
||||
ARG TARGETPLATFORM
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ARG KUBECTL_VERSION=1.36.2
|
||||
ARG HELM_VERSION=3.21.2
|
||||
ARG HELM_VERSION=3.21.4
|
||||
# NOTE: If changing, also change go version in workspace dependencies template at WorkspaceDependenciesEditor.svelte
|
||||
ARG GO_VERSION=1.26.0
|
||||
ARG GO_VERSION=1.26.8
|
||||
ARG APP=/usr/src/app
|
||||
ARG WITH_POWERSHELL=true
|
||||
ARG WITH_KUBECTL=true
|
||||
@@ -250,8 +250,8 @@ RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_r
|
||||
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode
|
||||
|
||||
|
||||
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
||||
RUN apt-get -y update && apt-get install -y curl procps nodejs awscli && apt-get clean \
|
||||
RUN curl -sL https://deb.nodesource.com/setup_24.x | bash -
|
||||
RUN apt-get -y update && apt-get install -y --no-install-recommends curl procps nodejs awscli && apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# go build is slower the first time it is ran, so we prewarm it in the build
|
||||
@@ -299,7 +299,7 @@ RUN bun install -g windmill-cli \
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash \
|
||||
&& cp /root/.local/share/claude/versions/* /usr/bin/claude
|
||||
|
||||
COPY --from=php:8.3.30-cli-trixie /usr/local/bin/php /usr/bin/php
|
||||
COPY --from=php:8.3.33-cli-trixie /usr/local/bin/php /usr/bin/php
|
||||
COPY --from=composer:2.9.5 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# add the docker client to call docker from a worker if enabled
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ListableApp,
|
||||
ListableResource,
|
||||
ListableVariable,
|
||||
Resource,
|
||||
Script
|
||||
} from '../../../frontend/src/lib/gen'
|
||||
import type {
|
||||
@@ -81,6 +82,15 @@ export interface BenchmarkWorkspaceAiProvider {
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
/** A plain (non-AI) resource of the benchmark workspace, for cases about referencing a
|
||||
* credential — passing one as a run argument, say. `value` is what `get_resource` returns. */
|
||||
export interface BenchmarkWorkspaceResource {
|
||||
path: string
|
||||
resource_type: string
|
||||
value?: Record<string, unknown>
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface BenchmarkWorkspaceJob {
|
||||
/** Stable id so a case prompt can reference a specific run (e.g. for get_job_logs). */
|
||||
id?: string
|
||||
@@ -98,6 +108,7 @@ export interface BenchmarkWorkspaceRunnables {
|
||||
apps?: BenchmarkWorkspaceApp[]
|
||||
variables?: BenchmarkWorkspaceVariable[]
|
||||
aiProviders?: BenchmarkWorkspaceAiProvider[]
|
||||
resources?: BenchmarkWorkspaceResource[]
|
||||
datatables?: BenchmarkDatatableSeed[]
|
||||
jobs?: BenchmarkWorkspaceJob[]
|
||||
}
|
||||
@@ -284,15 +295,71 @@ export function listBenchmarkAiProviderResources(workspace: string): ListableRes
|
||||
}))
|
||||
}
|
||||
|
||||
/** The value of a seeded AI provider resource. Only the endpoint fields are modelled — a key is
|
||||
* never needed, because no eval run calls the provider through this resource. */
|
||||
/** Plain seeded resources of a benchmark workspace, shaped like `ResourceService.listResource`
|
||||
* rows. Null when the workspace is not a benchmark one. */
|
||||
export function listBenchmarkPlainResources(workspace: string): ListableResource[] | null {
|
||||
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
||||
if (!runnables) {
|
||||
return null
|
||||
}
|
||||
return (runnables.resources ?? []).map((seed) => ({
|
||||
workspace_id: workspace,
|
||||
path: seed.path,
|
||||
resource_type: seed.resource_type,
|
||||
description: seed.description,
|
||||
value: null,
|
||||
is_oauth: false,
|
||||
is_linked: false,
|
||||
is_refreshed: false,
|
||||
extra_perms: {},
|
||||
edited_at: BENCHMARK_TIMESTAMP
|
||||
}))
|
||||
}
|
||||
|
||||
/** A seeded resource with its value, as `ResourceService.getResource` returns it. Covers both
|
||||
* seed kinds, so it agrees with `existsResource` and `listResource` — both of those report AI
|
||||
* providers too, and a case that lists resources and then reads one by path would otherwise get
|
||||
* a row it cannot fetch. */
|
||||
export function getBenchmarkResource(workspace: string, path: string): Resource | null {
|
||||
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
||||
const seed = runnables?.resources?.find((entry) => entry.path === path)
|
||||
if (seed) {
|
||||
return {
|
||||
workspace_id: workspace,
|
||||
path: seed.path,
|
||||
resource_type: seed.resource_type,
|
||||
description: seed.description,
|
||||
value: seed.value ?? {},
|
||||
is_oauth: false,
|
||||
extra_perms: {}
|
||||
} as Resource
|
||||
}
|
||||
const provider = runnables?.aiProviders?.find((entry) => entry.path === path)
|
||||
if (!provider) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
workspace_id: workspace,
|
||||
path: provider.path,
|
||||
resource_type: provider.kind,
|
||||
value: getBenchmarkResourceValue(workspace, path) ?? {},
|
||||
is_oauth: false,
|
||||
extra_perms: {}
|
||||
} as Resource
|
||||
}
|
||||
|
||||
/** The value of a seeded resource. For an AI provider only the endpoint fields are modelled — a
|
||||
* key is never needed, because no eval run calls the provider through this resource. */
|
||||
export function getBenchmarkResourceValue(
|
||||
workspace: string,
|
||||
path: string
|
||||
): Record<string, unknown> | null {
|
||||
const seed = benchmarkWorkspaceRunnables
|
||||
.get(workspace)
|
||||
?.aiProviders?.find((entry) => entry.path === path)
|
||||
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
||||
const plain = runnables?.resources?.find((entry) => entry.path === path)
|
||||
if (plain) {
|
||||
return plain.value ?? {}
|
||||
}
|
||||
const seed = runnables?.aiProviders?.find((entry) => entry.path === path)
|
||||
if (!seed) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -70,7 +70,9 @@ vi.mock('$lib/gen', async () => {
|
||||
getBenchmarkResourceValue,
|
||||
getBenchmarkVariableByPath,
|
||||
hasBenchmarkWorkspace,
|
||||
getBenchmarkResource,
|
||||
listBenchmarkAiProviderResources,
|
||||
listBenchmarkPlainResources,
|
||||
listBenchmarkApps,
|
||||
listBenchmarkDatatables,
|
||||
listBenchmarkDrafts,
|
||||
@@ -359,18 +361,24 @@ vi.mock('$lib/gen', async () => {
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? Boolean(getBenchmarkResourceValue(data.workspace, data.path))
|
||||
: actual.ResourceService.existsResource(data),
|
||||
// Only AI provider resources are modelled: they are what an AI agent step references.
|
||||
listResource: async (data: { workspace: string; resourceType?: string }) => {
|
||||
if (!hasBenchmarkWorkspace(data.workspace)) {
|
||||
return actual.ResourceService.listResource(data)
|
||||
}
|
||||
const seeded = listBenchmarkAiProviderResources(data.workspace) ?? []
|
||||
const seeded = [
|
||||
...(listBenchmarkAiProviderResources(data.workspace) ?? []),
|
||||
...(listBenchmarkPlainResources(data.workspace) ?? [])
|
||||
]
|
||||
const wanted = data.resourceType?.split(',')
|
||||
return wanted ? seeded.filter((r) => wanted.includes(r.resource_type)) : seeded
|
||||
},
|
||||
getResource: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Resource "${data.path}" not found in benchmark workspace`)
|
||||
const resource = getBenchmarkResource(data.workspace, data.path)
|
||||
if (!resource) {
|
||||
throw new Error(`Resource "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return resource
|
||||
}
|
||||
return actual.ResourceService.getResource(data)
|
||||
},
|
||||
|
||||
@@ -2365,3 +2365,37 @@
|
||||
- the step uses the workspace's anthropic resource f/evals/global/anthropic_main
|
||||
- the model is the Opus one the user asked for, taken from the models that resource serves
|
||||
- the diff flow input reaches the agent
|
||||
|
||||
# The failure this pins: passing a resource as `{"$res": "<path>"}` (or as a bare path), which
|
||||
# reaches the script unresolved because the backend only substitutes a string value that itself
|
||||
# starts with `$res:`. The mock preview echoes args back and reports success, so nothing in the
|
||||
# loop corrects a wrong shape — the arg form is the whole test.
|
||||
- id: global-run-arg-resource-reference
|
||||
prompt: |-
|
||||
Run `f/evals/global/github_repo_stats` against the `windmill-labs/windmill` repo, passing our
|
||||
GitHub credentials at `f/evals/global/github_main` as its `gh_auth` input, and tell me whether
|
||||
it went through.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/github_repo_stats_script.json
|
||||
runtime:
|
||||
maxTurns: 10
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- test_run_script
|
||||
forbiddenToolsUsed:
|
||||
- write_script
|
||||
- deploy_workspace_item
|
||||
toolCallArgs:
|
||||
# Exact: the mock never resolves the reference, so a near-miss path like
|
||||
# `$res:f/evals/global/github_main_backup` would otherwise pass.
|
||||
- tool: test_run_script
|
||||
field: args.gh_auth
|
||||
stringEqualsAnyOf:
|
||||
- "$res:f/evals/global/github_main"
|
||||
# The judge only sees drafts, and this case makes none — the deliverable is the shape of the
|
||||
# run argument, checked deterministically above.
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- runs the existing script rather than rewriting it
|
||||
- passes the GitHub resource as the bare string $res:f/evals/global/github_main
|
||||
|
||||
@@ -160,6 +160,13 @@ export interface ToolCallArgumentRule {
|
||||
field: string;
|
||||
stringStartsWithAnyOf?: string[];
|
||||
stringMustNotStartWithAnyOf?: string[];
|
||||
/**
|
||||
* Universal over calls: every recorded call to `tool` must carry `field` as
|
||||
* exactly one of these strings. Use when a near-miss would still satisfy a
|
||||
* prefix — a resource reference like `$res:f/a/b` shares its prefix with the
|
||||
* wrong `$res:f/a/b_backup`, and the mock never resolves it to catch that.
|
||||
*/
|
||||
stringEqualsAnyOf?: string[];
|
||||
/**
|
||||
* Case-insensitive "contains", existential over calls: at least one recorded
|
||||
* call to `tool` must have `field` containing one of these substrings. Other
|
||||
|
||||
@@ -228,6 +228,43 @@ describe("validateToolExpectations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A resource reference shares its prefix with a wrong sibling path, and the mock
|
||||
// never resolves it, so only exact matching separates the two.
|
||||
it("rejects a resource reference whose path merely shares the prefix", () => {
|
||||
const checks = validateToolExpectations({
|
||||
run: {
|
||||
success: true,
|
||||
actual: {},
|
||||
assistantMessageCount: 1,
|
||||
toolCallCount: 1,
|
||||
toolsUsed: ["test_run_script"],
|
||||
toolCallDetails: [
|
||||
{
|
||||
name: "test_run_script",
|
||||
arguments: { args: { gh_auth: "$res:f/evals/global/github_main_backup" } },
|
||||
},
|
||||
],
|
||||
skillsInvoked: [],
|
||||
},
|
||||
toolExpect: {
|
||||
toolCallArgs: [
|
||||
{
|
||||
tool: "test_run_script",
|
||||
field: "args.gh_auth",
|
||||
stringEqualsAnyOf: ["$res:f/evals/global/github_main"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(checks).toContainEqual({
|
||||
name: "test_run_script.args.gh_auth matches an accepted value",
|
||||
passed: false,
|
||||
details:
|
||||
'accepted values: $res:f/evals/global/github_main; values: "$res:f/evals/global/github_main_backup"',
|
||||
});
|
||||
});
|
||||
|
||||
// The whole point of the same-call rule: the per-field rules are existential over
|
||||
// calls, so two single-filter pages would satisfy them while never opening the
|
||||
// combined view the case asks for.
|
||||
|
||||
@@ -278,6 +278,20 @@ export function validateToolExpectations(input: {
|
||||
);
|
||||
}
|
||||
|
||||
if (rule.stringEqualsAnyOf && rule.stringEqualsAnyOf.length > 0) {
|
||||
const invalidValues = values.filter(
|
||||
(value) =>
|
||||
typeof value !== "string" || !rule.stringEqualsAnyOf!.includes(value)
|
||||
);
|
||||
checks.push(
|
||||
check(
|
||||
`${rule.tool}.${rule.field} matches an accepted value`,
|
||||
invalidValues.length === 0,
|
||||
`accepted values: ${rule.stringEqualsAnyOf.join(", ")}; values: ${summarizeToolValues(values)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (rule.stringMustNotStartWithAnyOf && rule.stringMustNotStartWithAnyOf.length > 0) {
|
||||
const invalidValues = values.filter(
|
||||
(value) =>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"workspace": {
|
||||
"resources": [
|
||||
{
|
||||
"path": "f/evals/global/github_main",
|
||||
"resource_type": "github",
|
||||
"description": "GitHub credentials",
|
||||
"value": { "token": "$var:f/evals/global/github_token" }
|
||||
}
|
||||
],
|
||||
"scripts": [
|
||||
{
|
||||
"path": "f/evals/global/github_repo_stats",
|
||||
"summary": "Count open issues on a GitHub repository",
|
||||
"description": "Reads the open issue count for a repository using GitHub credentials.",
|
||||
"language": "bun",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gh_auth": {
|
||||
"type": "object",
|
||||
"format": "resource-github",
|
||||
"description": "GitHub credentials"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository in owner/name form"
|
||||
}
|
||||
},
|
||||
"required": ["gh_auth", "repo"]
|
||||
},
|
||||
"content": "type Github = { token: string }\n\nexport async function main(gh_auth: Github, repo: string) {\n const res = await fetch(`https://api.github.com/repos/${repo}/issues?state=open`, {\n headers: { Authorization: `Bearer ${gh_auth.token}` }\n })\n const issues = await res.json()\n return { repo, open_issues: issues.length }\n}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) as \"username?\",\n d.created_at as \"draft_saved_at!\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n LEFT JOIN password p\n ON p.email = d.email\n AND p.super_admin = true\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)\n ORDER BY d.email NULLS LAST",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username?",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "draft_saved_at!",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github",
|
||||
"data_pipeline",
|
||||
"trigger_amqp"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "032b51ce97c2f31dc2aea8ddf64e6971818ea3b19b4d4866d16e1bf9f7f2ec6f"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft(workspace_id, path, typ, value, email) VALUES\n ('test-workspace', 'u/ext/s', 'script', '{}'::json, 'ext-jwt@windmill.dev'),\n ('test-workspace', 'u/two/s', 'script', '{\"summary\": \"moving\"}'::json, 'test2@windmill.dev'),\n ('test-workspace', 'u/two/s', 'script', '{\"summary\": \"displaced\"}'::json, 'renamed@windmill.dev'),\n ('test-workspace', 'u/three/s', 'script', '{}'::json, 'test3@windmill.dev')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0a02678e3f062c8854226d6d5eb7e493c229d205048eeac78a7cbe328c689b88"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT lockfile_hash FROM lock_hash WHERE workspace_id = $1 AND path = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "lockfile_hash",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1572b7348a05b7e357031f8d44b5bbee155569488352c10b334ce57d83ce1c0a"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT trace_id FROM otel_traces ORDER BY trace_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "trace_id",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "16b174aaa944fd94458ce3108f0fec23514ae0419f77b632064b75473b5636c3"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft dest\n WHERE dest.email = $1\n AND EXISTS (SELECT 1 FROM draft src\n WHERE src.email = $2\n AND src.workspace_id = dest.workspace_id\n AND src.path = dest.path\n AND src.typ = dest.typ)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "188d024a53b2ef37442412824f73ab5cd81242501d9e7a476698ea7acccd4aef"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM otel_traces WHERE ctid IN (\n SELECT ctid FROM otel_traces\n WHERE start_time_unix_nano < EXTRACT(\n EPOCH FROM now() - ($1::bigint::text || ' s')::interval\n )::bigint * 1000000000\n LIMIT $2\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1b244f65ee6a2607ebc1c333d4359fbbf8be5a81276a3050a42770e4a5b5aa5e"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft SET email = $1 WHERE email = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "43ea7d0cf7f3c50ec1e79b4d2384d49b6c65bc442263f228912a24c1c5740cc8"
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH update_lock AS (\n UPDATE script SET lock = $1, modules = COALESCE($6, modules) WHERE hash = $2 AND workspace_id = $3\n )\n INSERT INTO lock_hash (workspace_id, path, lockfile_hash)\n VALUES ($3, $4, $5)\n ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = $5",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "49b18e987e2dfa3c7ab915757ff3b9c0e6e371136b565f9b0f5a3393ef8d8d57"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4bc43a5e1c95cb8989962aeb1393a50df05437a2f1909ad5d303e2c2b89a0668"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO otel_traces (trace_id, span_id, name, kind, start_time_unix_nano, end_time_unix_nano)\n VALUES ($1, $2, 'GET /', 3, $3, $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "62ed1fe52bc2c22d199101309cbbadb9842318c4c7a1d2526ac567ce41b7fbdc"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT ON (path) path, lock FROM script\n WHERE workspace_id = $1 AND NOT archived AND NOT deleted AND lock IS NOT NULL\n ORDER BY path, created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "lock",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "8da59f1ace46dc9830cb0fc5a640df68f552e38d4b587839e0e41285a2d55455"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "96aa1db2859c80d37ffecc2885442ae6d6d4b6731c3f62e7b85ae7a4f1b666a7"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft(workspace_id, path, typ, value, email) VALUES\n ('test-workspace', 'u/ext/s', 'script', '{}'::json, 'ext-jwt@windmill.dev'),\n ('test-workspace', 'u/two/s', 'script', '{}'::json, 'test2@windmill.dev'),\n ('test-workspace', 'u/three/s', 'script', '{}'::json, 'test3@windmill.dev')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "97691b88d43442e1f1984562372590428895b2bab67687c12efefe0b0d48881e"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, value->>'summary' AS summary FROM draft WHERE path = 'u/two/s'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "summary",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9e503b65fe8bb1509f0d486829ce13ec9d93bb94192ddd85c3dacb9ff16cd032"
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH update_lock AS (\n UPDATE script SET lock = $1, modules = COALESCE($6, modules) WHERE hash = $2 AND workspace_id = $3\n )\n INSERT INTO lock_hash (workspace_id, path, lockfile_hash)\n VALUES ($3, $4, $5)\n ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = $5",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a0ec5048ddb7640b4407013ed45b7a545959c209c550e48bf108734f293e18e4"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) FROM script WHERE workspace_id = $1 AND path = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "abdf62ef0e4eeb8c3213d2e8837e7032f710fe20dd272b7840c9bfbdb92554db"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO lock_hash (workspace_id, path, lockfile_hash)\n SELECT $1, * FROM UNNEST($2::text[], $3::bigint[])\n ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = EXCLUDED.lockfile_hash\n WHERE lock_hash.lockfile_hash IS DISTINCT FROM EXCLUDED.lockfile_hash",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cc507843e00c83a42cc4a463999656ce9a8b0499d6b9282a3ecfae3164b17c2a"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "VACUUM v2_job, v2_job_completed, job_result_stream_v2, job_stats, job_logs, job_perms, concurrency_key, log_file, metrics",
|
||||
"query": "VACUUM v2_job, v2_job_completed, job_result_stream_v2, job_stats, job_logs, job_perms, concurrency_key, log_file, metrics, otel_traces",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,5 +8,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f0070b36f7c4fc84dc9c23bb6c73d8ba80993a28b2c2e5df70968acf6d7cebe4"
|
||||
"hash": "cfdd5ac1dfc7276fc37d49ddfe1b8880eaafb2d3fe71d75b676f1719e26f660f"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM lock_hash WHERE workspace_id = $1 AND path = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dd15827cbac128ec53cf03fa305e4cad34e540a2cc09c92e262491145a0de05a"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email FROM draft WHERE path = 'u/two/s'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e3c4922e315b75951b5ea07cdfca4cfb32747b52dc62c9e3eccacf9c69e29b3a"
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels, lock_error_logs, created_at)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, $4::text, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, COALESCE($5::jsonb, modules), labels, $6::text, clock_timestamp()\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ede523c0b0027f7bc1dacd3a5448031783fa7fbbbf86a291b6f8f8f875d45637"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path FROM draft ORDER BY path",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f496f06f117f5c91104fc759df35b883070da938dea7c7975332afb876cc4691"
|
||||
}
|
||||
Generated
+99
-97
@@ -970,9 +970,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.0"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
@@ -981,9 +981,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.44.0"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -5177,9 +5177,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "frostem"
|
||||
version = "1.20260821.3"
|
||||
version = "1.20260821.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca2e81eab151ba68484704bb3d21b4b4d2747314d3081fa86fa6c7300a4c42e2"
|
||||
checksum = "481ace7f781f5ae54a5c0a6d6d8edb30adba737cfa1230fbd5632d63ba8dfd80"
|
||||
|
||||
[[package]]
|
||||
name = "fs3"
|
||||
@@ -7262,9 +7262,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.21"
|
||||
version = "0.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96"
|
||||
checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"libc",
|
||||
@@ -7387,9 +7387,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.3"
|
||||
version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
|
||||
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
@@ -7776,9 +7776,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
@@ -7870,10 +7870,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mysql_async"
|
||||
version = "0.37.0"
|
||||
version = "0.37.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3519e91b0d254ac1ffa495bc42053286cb2172ad7241d5b3b1b9f8a891f21ee2"
|
||||
checksum = "40d11da0e2d9fad4640c9f9198ee431c6d68444568f83ef1f10f3367270071e4"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"crossbeam-queue",
|
||||
"crossbeam-utils",
|
||||
@@ -7882,7 +7883,7 @@ dependencies = [
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"keyed_priority_queue",
|
||||
"lru 0.18.3",
|
||||
"lru 0.18.4",
|
||||
"mysql_common",
|
||||
"native-tls",
|
||||
"pem 3.0.6",
|
||||
@@ -11753,9 +11754,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -12812,7 +12813,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"levenshtein_automata",
|
||||
"log",
|
||||
"lru 0.18.3",
|
||||
"lru 0.18.4",
|
||||
"lz4_flex 0.14.0",
|
||||
"measure_time",
|
||||
"memmap2",
|
||||
@@ -14746,7 +14747,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14831,7 +14832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -14864,7 +14865,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14877,7 +14878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15017,7 +15018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15040,7 +15041,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15057,7 +15058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15083,7 +15084,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15093,7 +15094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15110,7 +15111,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
@@ -15132,7 +15133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15155,7 +15156,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15171,7 +15172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15193,7 +15194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15214,7 +15215,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15228,7 +15229,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15263,7 +15264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15288,7 +15289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15316,7 +15317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15338,7 +15339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15358,7 +15359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15396,7 +15397,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15424,7 +15425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -15436,7 +15437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -15460,7 +15461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15474,7 +15475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15509,7 +15510,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -15523,7 +15524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15542,7 +15543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -15646,9 +15647,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -15665,7 +15667,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -15680,7 +15682,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -15707,7 +15709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15724,7 +15726,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -15740,7 +15742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15761,7 +15763,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15792,7 +15794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -15817,7 +15819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -15851,7 +15853,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15869,7 +15871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -15878,7 +15880,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15890,7 +15892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15902,7 +15904,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -15914,7 +15916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15926,7 +15928,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15938,7 +15940,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -15949,7 +15951,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -15960,7 +15962,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -15972,7 +15974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -15983,7 +15985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16005,7 +16007,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16017,7 +16019,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16031,7 +16033,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16048,7 +16050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16061,7 +16063,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16073,7 +16075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16091,7 +16093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -16107,7 +16109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16123,7 +16125,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16137,7 +16139,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16176,7 +16178,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -16216,7 +16218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -16227,7 +16229,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16262,7 +16264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16286,7 +16288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16319,7 +16321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-amqp"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16346,7 +16348,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16379,7 +16381,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16399,7 +16401,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16433,7 +16435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16469,7 +16471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16492,7 +16494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16516,7 +16518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16540,7 +16542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16575,7 +16577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16603,7 +16605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16628,7 +16630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.1",
|
||||
@@ -16647,7 +16649,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -16764,7 +16766,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -88,7 +88,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical).
|
||||
| EP9 Worker sandbox | nsjail / unshare / dind / rootless podman isolating user code | user code → host & cross-tenant filesystem/network | Worker host, isolation, downstream |
|
||||
| EP10 Worker code generation / wrappers | Entrypoint override, env-var names, workspace env interpolated into generated wrapper code | user-controlled identifier → executable code | Worker host, isolation |
|
||||
| EP11 OAuth / OIDC / SAML / MCP-OAuth / logout | Login callbacks, MCP OAuth client registration, logout `rd` redirect | untrusted IdP / redirect input → session | Session tokens, accounts |
|
||||
| EP12 Stored-content rendering | App builder HTML component, markdown, S3 download response headers | stored user content → admin browser (same origin) | Admin session, account takeover |
|
||||
| EP12 Stored-content rendering | App builder HTML component, markdown, S3 download response headers, script-controlled `wm_content_type`/`wm_headers` on `run_wait_result` and sync HTTP-route responses | stored user content → admin browser (same origin) | Admin session, account takeover |
|
||||
| EP13 Log/file reading & export endpoints | `service_logs`, `jobs_u/getupdate` log file read (symlinks), workspace/tarball export | authed/unauth request → arbitrary file or admin-only config | Arbitrary files, global settings |
|
||||
| EP14 Secret-value & resource-value caches | In-memory caches in `windmill-store` keyed (historically un-keyed) by path | cache lookup crossing identity/folder boundary | Secret variables, resource creds |
|
||||
| EP15 Deployment & runtime config | docker-compose defaults: dind, debugger (`REQUIRE_SIGNED_DEBUG_REQUESTS` now defaults to `true`; can still be overridden to `false`), CORS `Any`, default admin/`changeme`, exposed Postgres, `SUPERADMIN_SECRET`, `ENABLE_NSJAIL=false`, privileged containers | operator/infra default → full instance | All assets |
|
||||
@@ -106,7 +106,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical).
|
||||
| T8 | Unauthenticated RCE via the Debugger WebSocket: `/ws_debug/*` exposed by the gateway/ingress with the debugger service as the auth boundary; signature gate was bypassable via `program`-mode launches (read+exec an arbitrary server-side file path, never signed) even with signing on, and the WS handshake had no Origin check (CSWSH) | remote_unauth | EP15 | Worker host, all assets | critical | possible | partially_mitigated | `program`-mode launches now rejected when `REQUIRE_SIGNED_DEBUG_REQUESTS` is on (signing covers every launch, not just inline `code`); shipped `docker-compose` now defaults `REQUIRE_SIGNED_DEBUG_REQUESTS=true`; opt-in `DEBUG_ALLOWED_ORIGINS` allowlist rejects cross-origin handshakes. Residual: code default is secure but operators can still set `=false`; origin allowlist is opt-in | GHSA-725h-99vx-9xr4 |
|
||||
| T9 | Supply-chain compromise via cached hub scripts, GitHub workflow command injection, or vulnerable base-image deps | supply_chain | EP16 | Worker host, build integrity | critical | possible | partially_mitigated | hub-script re-pin to patched versions; HUB_BASE_URL override | GHSA-w2m9-q5f7-3gpq, edf340c4d4, GHSA-8rq7-w7g6-8wvr, GHSA-vch9-39v5-4wg7 (CVE-2024-37371) |
|
||||
| T10 | Unauthenticated disclosure of job results, args, logs, and admin config via missing-authz public endpoints | remote_unauth | EP2, EP13 | Job results/args/logs, global settings, scripts | high | likely | partially_mitigated | anonymous-job checks, log-endpoint authz hardening | GHSA-qfg7-x243-5hg4, GHSA-v448-fmm4-52fp, 108a88a180, bb90f4ce83 |
|
||||
| T11 | Stored XSS leading to admin/account takeover via app HTML component, markdown, or S3 download content-type | remote_auth | EP12 | Admin session, accounts | high | likely | partially_mitigated | DOMPurify markdown sanitization, `X-Content-Type-Options: nosniff` + CSP sandbox on downloads | GHSA-9c5c-hh3c-r9mc, GHSA-qxj7-hpx3-r892, GHSA-cf2x-rg8c-v63v, bb78b1c06d, 625b67dff0 |
|
||||
| T11 | Stored XSS leading to admin/account takeover via app HTML component, markdown, S3 download content-type, or a script-chosen `text/html` content type on `run_wait_result` / sync HTTP-route responses (GET-reachable with the `SameSite=Lax` session cookie) | remote_auth | EP12 | Admin session, accounts | high | likely | partially_mitigated | DOMPurify markdown sanitization, `X-Content-Type-Options: nosniff` + CSP sandbox on downloads and on every `result_to_response` composite result (inserted after `wm_headers`; hop-by-hop names such as `Connection` rejected so a proxy cannot strip them) | GHSA-9c5c-hh3c-r9mc, GHSA-qxj7-hpx3-r892, GHSA-cf2x-rg8c-v63v, bb78b1c06d, 625b67dff0, WIN-2471 |
|
||||
| T12 | Webhook authentication bypass / signature replay forges trigger invocations and approvals | remote_unauth | EP3 | Job execution integrity, approvals | high | likely | partially_mitigated | HMAC verification on some triggers; signing-oracle fix | GHSA-jw8c-h45c-xpjw, GHSA-hh9x-rcf8-xjr2, GHSA-q9g3-q6fj-hc2x, GHSA-8jc4-wj2p-2vmp, ab2a15b2a8 |
|
||||
| T13 | Path traversal / arbitrary file read via log-reading and MCP path endpoints (incl. symlink following) | remote_auth | EP13 | Arbitrary files on server, global settings | high | likely | partially_mitigated | traversal checks + no-symlink-follow added | GHSA-4hrf-mgvv-xp9x, bb90f4ce83, df451aa64f, ad5ec293b5, 5f2d3e6812 |
|
||||
| T14 | Privilege escalation via token rescope/refresh, script-issued JWTs, or operator-permission gaps | remote_auth | EP17, EP5 | Tokens, isolation, accounts | high | likely | partially_mitigated | monotonic-privilege enforcement on token lifecycle; SECURITY DEFINER triggers | GHSA-p62p-67xp-v775, GHSA-vv9w-wx3c-q3x2, 2ddf93de96, 865ab70c89, 33fb08cf3d |
|
||||
|
||||
@@ -1 +1 @@
|
||||
e11dbe96407f2831cfb71ecadad94e38113eb61f
|
||||
05512fd5ac1e4ff9226cec2dc84799a117ade2dc
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Drafts owned by a principal with no login account cannot exist under the constraint; drop them
|
||||
-- before restoring it.
|
||||
DELETE FROM draft
|
||||
WHERE email IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM password WHERE password.email = draft.email);
|
||||
|
||||
ALTER TABLE draft
|
||||
ADD CONSTRAINT draft_password_fkey
|
||||
FOREIGN KEY (email)
|
||||
REFERENCES password(email)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- The delete and rename this cascaded are now explicit, at the sites that remove or rename an
|
||||
-- account; `windmill_common::user_drafts::delete_drafts_of_email` carries the reasoning.
|
||||
ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_password_fkey;
|
||||
@@ -29,6 +29,10 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/spreadsheets"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/spreadsheets.readonly"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -38,6 +42,11 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/drive"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/drive.file",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/drive"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -47,6 +56,13 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/gmail.send"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.compose",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/gmail.labels"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -56,6 +72,12 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/calendar.events"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/calendar.events.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
"https://www.googleapis.com/auth/calendar"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -65,6 +87,12 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/forms"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/forms",
|
||||
"https://www.googleapis.com/auth/forms.body",
|
||||
"https://www.googleapis.com/auth/forms.body.readonly",
|
||||
"https://www.googleapis.com/auth/forms.responses.readonly"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -74,6 +102,10 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/cloud-platform"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/cloud-platform.read-only"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -88,6 +120,15 @@
|
||||
"https://www.googleapis.com/auth/admin.directory.user.security",
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit"
|
||||
],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/admin.directory.user",
|
||||
"https://www.googleapis.com/auth/admin.directory.user.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.group",
|
||||
"https://www.googleapis.com/auth/admin.directory.group.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit",
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.user.security"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
|
||||
+24
-24
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6274,7 +6274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6286,7 +6286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6295,7 +6295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6307,7 +6307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6319,7 +6319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6331,7 +6331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6343,7 +6343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6377,7 +6377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6400,7 +6400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6422,7 +6422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6434,7 +6434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6465,7 +6465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6478,7 +6478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6508,7 +6508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6524,7 +6524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6540,7 +6540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6586,7 +6586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.800.1"
|
||||
version = "1.803.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
+12
-9
@@ -53,9 +53,9 @@ use windmill_common::{
|
||||
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
|
||||
NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING,
|
||||
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING,
|
||||
PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING,
|
||||
PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING,
|
||||
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
|
||||
POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING,
|
||||
RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING,
|
||||
SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING,
|
||||
@@ -140,12 +140,12 @@ use crate::monitor::{
|
||||
reload_instance_events_webhook_setting, reload_job_default_timeout_setting,
|
||||
reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key,
|
||||
reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting,
|
||||
reload_nsjail_tmpfs_size_setting, reload_otel_tracing_proxy_setting,
|
||||
reload_pip_index_url_setting, reload_retention_period_setting,
|
||||
reload_sandbox_image_cache_max_setting, reload_sandbox_image_default_registry_setting,
|
||||
reload_sandbox_image_max_size_setting, reload_sandbox_image_pull_policy_setting,
|
||||
reload_sandbox_registry_auth_setting, reload_scim_token_setting,
|
||||
reload_service_log_retention_secs_setting, reload_smtp_config,
|
||||
reload_nsjail_tmpfs_size_setting, reload_otel_traces_retention_secs_setting,
|
||||
reload_otel_tracing_proxy_setting, reload_pip_index_url_setting,
|
||||
reload_retention_period_setting, reload_sandbox_image_cache_max_setting,
|
||||
reload_sandbox_image_default_registry_setting, reload_sandbox_image_max_size_setting,
|
||||
reload_sandbox_image_pull_policy_setting, reload_sandbox_registry_auth_setting,
|
||||
reload_scim_token_setting, reload_service_log_retention_secs_setting, reload_smtp_config,
|
||||
reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting,
|
||||
reload_uv_index_strategy_setting, reload_uv_python_install_mirror_setting,
|
||||
reload_worker_config, MonitorIteration,
|
||||
@@ -2013,6 +2013,9 @@ async fn process_notify_event(
|
||||
SERVICE_LOG_RETENTION_SECS_SETTING => {
|
||||
reload_service_log_retention_secs_setting(conn).await
|
||||
}
|
||||
OTEL_TRACES_RETENTION_SECS_SETTING => {
|
||||
reload_otel_traces_retention_secs_setting(conn).await
|
||||
}
|
||||
RETENTION_PERIOD_SECS_OVERRIDES_SETTING => {
|
||||
if let Err(e) = load_retention_period_overrides(db).await {
|
||||
tracing::error!("Error loading per-workspace retention overrides: {e:#}");
|
||||
|
||||
+131
-9
@@ -64,10 +64,11 @@ use windmill_common::{
|
||||
JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
|
||||
NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING,
|
||||
OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING,
|
||||
POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING,
|
||||
REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
|
||||
RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING,
|
||||
OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING,
|
||||
PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING,
|
||||
PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
|
||||
SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING,
|
||||
SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING,
|
||||
SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING,
|
||||
SERVICE_LOG_RETENTION_SECS_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING,
|
||||
@@ -97,10 +98,10 @@ use windmill_common::{
|
||||
KillpillSender, AUDIT_LOG_RETENTION_DAYS, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE,
|
||||
CRITICAL_ALERTS_ON_TOKEN_EXPIRY, CRITICAL_ALERT_MUTE_UI_ENABLED,
|
||||
CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART, CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL,
|
||||
DEFAULT_SERVICE_LOG_RETENTION_SECS, HUB_BASE_URL, JOB_RETENTION_SECS,
|
||||
JOB_RETENTION_SECS_OVERRIDES, JOB_RETENTION_SECS_OVERRIDES_LOADED, METRICS_DEBUG_ENABLED,
|
||||
METRICS_ENABLED, MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED,
|
||||
OTEL_TRACING_ENABLED, STORE_AUDIT_LOGS_S3,
|
||||
DEFAULT_OTEL_TRACES_RETENTION_SECS, DEFAULT_SERVICE_LOG_RETENTION_SECS, HUB_BASE_URL,
|
||||
JOB_RETENTION_SECS, JOB_RETENTION_SECS_OVERRIDES, JOB_RETENTION_SECS_OVERRIDES_LOADED,
|
||||
METRICS_DEBUG_ENABLED, METRICS_ENABLED, MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED,
|
||||
OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, STORE_AUDIT_LOGS_S3,
|
||||
};
|
||||
use windmill_common::{
|
||||
client::AuthedClient,
|
||||
@@ -514,6 +515,15 @@ pub async fn initial_load(
|
||||
Ordering::Relaxed,
|
||||
)
|
||||
});
|
||||
pass.setting(OTEL_TRACES_RETENTION_SECS_SETTING, true, |v| async move {
|
||||
windmill_common::set_otel_traces_retention_secs(parse_setting_value::<i64>(
|
||||
v,
|
||||
OTEL_TRACES_RETENTION_SECS_SETTING,
|
||||
"OTEL_TRACES_RETENTION_SECS",
|
||||
DEFAULT_OTEL_TRACES_RETENTION_SECS,
|
||||
|x| x,
|
||||
))
|
||||
});
|
||||
pass.setting(STORE_AUDIT_LOGS_S3_SETTING, true, |v| async move {
|
||||
STORE_AUDIT_LOGS_S3.store(
|
||||
parse_setting_value::<bool>(
|
||||
@@ -1682,6 +1692,57 @@ const SERVICE_LOG_DELETE_BATCH: i64 = 2_000;
|
||||
/// across ticks rather than inside one, the way the neighbouring sweeps already do.
|
||||
const SERVICE_LOG_DELETE_MAX_BATCHES: usize = 10;
|
||||
|
||||
/// One span per HTTP request made from a job script, so the table grows far faster than the
|
||||
/// job table it is keyed against; batched for the same reason the service log sweep is.
|
||||
const OTEL_TRACES_DELETE_BATCH: i64 = 10_000;
|
||||
const OTEL_TRACES_DELETE_MAX_BATCHES: usize = 10;
|
||||
|
||||
/// Delete HTTP request tracing spans older than `retention_secs`, returning how many went.
|
||||
///
|
||||
/// `retention_secs` is a parameter rather than a read of the process-wide setting so a test can
|
||||
/// pin a window without writing state the other tests in this binary run against concurrently.
|
||||
async fn delete_expired_otel_traces(db: &DB, retention_secs: i64) -> u64 {
|
||||
// `start_time_unix_nano` is the proto field stored verbatim, so the cutoff is built in that
|
||||
// unit rather than compared against `now()`. Truncating the epoch to whole seconds first
|
||||
// keeps the multiplication inside `bigint`.
|
||||
//
|
||||
// Batched on `ctid`, not on the `(trace_id, span_id)` primary key: with the key the planner
|
||||
// hashes the LIMITed subquery and Seq Scans the whole table to probe it, which at the size
|
||||
// this table reaches is the cost the batching exists to avoid. `ctid` plans as a Tid Scan, so
|
||||
// each batch touches only the rows it deletes. Safe because the subquery and the delete share
|
||||
// one snapshot, and spans are never updated after insert.
|
||||
let mut deleted = 0;
|
||||
for _ in 0..OTEL_TRACES_DELETE_MAX_BATCHES {
|
||||
let batch = sqlx::query!(
|
||||
"DELETE FROM otel_traces WHERE ctid IN (
|
||||
SELECT ctid FROM otel_traces
|
||||
WHERE start_time_unix_nano < EXTRACT(
|
||||
EPOCH FROM now() - ($1::bigint::text || ' s')::interval
|
||||
)::bigint * 1000000000
|
||||
LIMIT $2
|
||||
)",
|
||||
retention_secs,
|
||||
OTEL_TRACES_DELETE_BATCH,
|
||||
)
|
||||
.execute(db)
|
||||
.await;
|
||||
|
||||
match batch {
|
||||
Ok(res) => {
|
||||
deleted += res.rows_affected();
|
||||
if (res.rows_affected() as i64) < OTEL_TRACES_DELETE_BATCH {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error deleting expired otel trace spans: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
deleted
|
||||
}
|
||||
|
||||
pub async fn delete_expired_items(db: &DB) -> () {
|
||||
let expired_tokens_r = sqlx::query_as!(
|
||||
TokenRow,
|
||||
@@ -1808,6 +1869,12 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
}
|
||||
}
|
||||
|
||||
let deleted_spans =
|
||||
delete_expired_otel_traces(db, windmill_common::otel_traces_retention_secs()).await;
|
||||
if deleted_spans > 0 {
|
||||
tracing::info!("deleted {} expired otel trace spans", deleted_spans);
|
||||
}
|
||||
|
||||
let audit_retention_days = audit_log_retention_days().await;
|
||||
let audit_retention_secs: i64 = audit_retention_days * 60 * 60 * 24;
|
||||
|
||||
@@ -2927,6 +2994,21 @@ pub async fn reload_service_log_retention_secs_setting(conn: &Connection) {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reload_otel_traces_retention_secs_setting(conn: &Connection) {
|
||||
match load_setting_value::<i64>(
|
||||
conn,
|
||||
OTEL_TRACES_RETENTION_SECS_SETTING,
|
||||
"OTEL_TRACES_RETENTION_SECS",
|
||||
DEFAULT_OTEL_TRACES_RETENTION_SECS,
|
||||
|x| x,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(v) => windmill_common::set_otel_traces_retention_secs(v),
|
||||
Err(e) => tracing::error!("Error reloading otel traces retention period: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reload_audit_log_retention_days_setting(conn: &Connection) {
|
||||
match load_setting_value::<i64>(
|
||||
conn,
|
||||
@@ -4839,7 +4921,7 @@ async fn poll_git_fork_branches(
|
||||
}
|
||||
|
||||
async fn vacuuming_tables(db: &Pool<Postgres>) -> error::Result<()> {
|
||||
sqlx::query!("VACUUM v2_job, v2_job_completed, job_result_stream_v2, job_stats, job_logs, job_perms, concurrency_key, log_file, metrics")
|
||||
sqlx::query!("VACUUM v2_job, v2_job_completed, job_result_stream_v2, job_stats, job_logs, job_perms, concurrency_key, log_file, metrics, otel_traces")
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -6975,6 +7057,46 @@ mod zombie_worker_memory_pct_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod otel_traces_retention_tests {
|
||||
use super::{delete_expired_otel_traces, DB};
|
||||
|
||||
async fn insert_span(db: &DB, id: u8, age_secs: i64) {
|
||||
sqlx::query!(
|
||||
"INSERT INTO otel_traces (trace_id, span_id, name, kind, start_time_unix_nano, end_time_unix_nano)
|
||||
VALUES ($1, $2, 'GET /', 3, $3, $3)",
|
||||
&[id; 16][..],
|
||||
&[id; 8][..],
|
||||
(chrono::Utc::now() - chrono::Duration::seconds(age_secs))
|
||||
.timestamp_nanos_opt()
|
||||
.unwrap(),
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The cutoff crosses two units: a retention configured in seconds against a column holding
|
||||
/// nanoseconds. Getting that conversion wrong is silent in both directions — a window a
|
||||
/// billion times too wide never deletes anything, one a billion times too narrow deletes
|
||||
/// every span on the next tick — so pin it on either side of the boundary.
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn deletes_only_spans_past_the_window(db: DB) -> anyhow::Result<()> {
|
||||
let day = 60 * 60 * 24;
|
||||
insert_span(&db, 1, 60).await;
|
||||
insert_span(&db, 2, 6 * day).await;
|
||||
insert_span(&db, 3, 8 * day).await;
|
||||
|
||||
assert_eq!(delete_expired_otel_traces(&db, 7 * day).await, 1);
|
||||
|
||||
let kept = sqlx::query_scalar!("SELECT trace_id FROM otel_traces ORDER BY trace_id")
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
assert_eq!(kept, vec![vec![1u8; 16], vec![2u8; 16]]);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod log_file_listing_tests {
|
||||
use super::{rotated_log_files, sorted_log_files};
|
||||
|
||||
+11
-1
@@ -2,7 +2,9 @@
|
||||
-- Layered on top of `base` (which provides test-workspace and the non-operator
|
||||
-- `test-user-2`/SECRET_TOKEN_2). Adds an Operator member so we can assert that
|
||||
-- Operators cannot reach the arbitrary-code inline preview path
|
||||
-- (`POST /jobs/run_inline/preview`).
|
||||
-- (`POST /jobs/run_inline/preview`) with their own token, plus two deployed script
|
||||
-- jobs of the operator: one running, so we can assert that its WM_TOKEN can, and
|
||||
-- one queued but not yet pulled, so we can assert that "queued" is not enough.
|
||||
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES ('operator@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Operator User');
|
||||
@@ -12,3 +14,11 @@ INSERT INTO usr(workspace_id, email, username, is_admin, operator, role) VALUES
|
||||
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES
|
||||
(encode(sha256('OPERATOR_TOKEN'::bytea), 'hex'), 'OPERATOR_T', 'OPERATOR_TOKEN', 'operator@windmill.dev', 'operator token', false);
|
||||
|
||||
INSERT INTO v2_job(id, workspace_id, kind, runnable_path, created_by, permissioned_as, permissioned_as_email) VALUES
|
||||
('2aa0c0de-0000-4000-8000-000000000001', 'test-workspace', 'script', 'u/test-user/deployed', 'operator-user', 'u/operator-user', 'operator@windmill.dev'),
|
||||
('2aa0c0de-0000-4000-8000-000000000002', 'test-workspace', 'script', 'u/test-user/deployed', 'operator-user', 'u/operator-user', 'operator@windmill.dev');
|
||||
|
||||
INSERT INTO v2_job_queue(id, workspace_id, scheduled_for, running) VALUES
|
||||
('2aa0c0de-0000-4000-8000-000000000001', 'test-workspace', now(), true),
|
||||
('2aa0c0de-0000-4000-8000-000000000002', 'test-workspace', now(), false);
|
||||
|
||||
@@ -9,16 +9,33 @@
|
||||
//! was the incomplete-fix residual of CVE-2026-22683, whose v1.615.0 patch only
|
||||
//! covered the entity-CRUD endpoints and left this direct inline-exec sink open.
|
||||
//!
|
||||
//! The guard on both routes has one exemption: `wmill.datatable()` called from
|
||||
//! inside a job the operator is running. Operators can only run deployed code,
|
||||
//! so a request the job's WM_TOKEN authenticates comes from code a non-operator
|
||||
//! authored, and the exemption is limited to the request shape the helper sends
|
||||
//! (PostgreSQL against a `datatable://` database) so a leaked WM_TOKEN cannot
|
||||
//! be replayed to run anything else.
|
||||
//!
|
||||
//! This test pins down:
|
||||
//! - an Operator is rejected by the operator guard (the core fix; pre-fix this
|
||||
//! reached the inline executor instead of returning 401), and
|
||||
//! - an Operator's own token is rejected by the operator guard (the core fix;
|
||||
//! pre-fix this reached the inline executor instead of returning 401),
|
||||
//! - a regular non-operator passes the guard (the fix must not over-block the
|
||||
//! legitimate inline preview flow): in the test harness the worker inline
|
||||
//! utils are not registered, so a caller past the guard gets the distinct
|
||||
//! "worker inline functions" error rather than the operator rejection.
|
||||
//! "worker inline functions" error rather than the operator rejection,
|
||||
//! - an Operator's job token passes the guard for a datatable query while its
|
||||
//! job is running, on the inline route and on the `/jobs/run/preview`
|
||||
//! fallback the SDKs use when the worker has no internal server,
|
||||
//! - the same token is rejected for any other payload (in-process DuckDB, or a
|
||||
//! `-- database` directive redirecting the query, whether written literally or
|
||||
//! reached through a `WM_INTERNAL_DB` marker) and for a deferred run,
|
||||
//! - an Operator's job token for a job that is not running, whether finished or
|
||||
//! merely queued, is rejected.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::auth::create_jwt_token;
|
||||
use windmill_common::db::Authed;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
@@ -38,11 +55,65 @@ fn inline_preview_body() -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The request `wmill.datatable("main")` sends: PostgreSQL against `datatable://main`.
|
||||
fn datatable_query_body() -> serde_json::Value {
|
||||
json!({
|
||||
"language": "postgresql",
|
||||
"content": "SELECT 1 AS x;",
|
||||
"args": { "database": "datatable://main" }
|
||||
})
|
||||
}
|
||||
|
||||
/// Mint the WM_TOKEN a job hands its own code: an internally-signed job JWT
|
||||
/// (note the `job_id` claim) for the fixture's operator, exactly as the worker
|
||||
/// issues it when the operator runs a deployed script.
|
||||
async fn operator_job_token(job_id: uuid::Uuid) -> String {
|
||||
let authed = Authed {
|
||||
email: "operator@windmill.dev".to_string(),
|
||||
username: "operator-user".to_string(),
|
||||
is_admin: false,
|
||||
is_operator: true,
|
||||
groups: vec![],
|
||||
folders: vec![],
|
||||
scopes: None,
|
||||
token_prefix: None,
|
||||
};
|
||||
create_jwt_token(
|
||||
authed,
|
||||
"test-workspace",
|
||||
3600,
|
||||
Some(job_id),
|
||||
Some("ephemeral-script".to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("mint operator job token")
|
||||
}
|
||||
|
||||
const OPERATOR_GUARD_MSG: &str = "Operators cannot run preview jobs";
|
||||
|
||||
/// The fixture's deployed-script jobs of the operator: one running, one queued.
|
||||
const RUNNING_JOB_ID: &str = "2aa0c0de-0000-4000-8000-000000000001";
|
||||
const QUEUED_JOB_ID: &str = "2aa0c0de-0000-4000-8000-000000000002";
|
||||
|
||||
async fn post(url: &str, token: &str, body: &serde_json::Value) -> (u16, String) {
|
||||
let resp = authed(client().post(url), token)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.expect("request");
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.expect("body");
|
||||
(status, body)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "inline_preview_auth"))]
|
||||
async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
// The server decodes WM_TOKENs with the same in-process JWT secret, so
|
||||
// setting it once lets us mint valid ones below.
|
||||
set_jwt_secret().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
@@ -51,12 +122,7 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
|
||||
// 1. CORE REGRESSION: an Operator must be rejected by the operator guard.
|
||||
// Pre-fix this fell through to the inline executor (arbitrary code
|
||||
// execution); post-fix it returns 401 with the operator guard message.
|
||||
let resp = authed(client().post(&url), "OPERATOR_TOKEN")
|
||||
.json(&inline_preview_body())
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
let (status, body) = post(&url, "OPERATOR_TOKEN", &inline_preview_body()).await;
|
||||
assert_eq!(
|
||||
status, 401,
|
||||
"Operator must be rejected from inline preview (got {status}): {body}"
|
||||
@@ -71,12 +137,7 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
|
||||
// the worker inline utils, so the request proceeds past the guard and
|
||||
// fails later with the distinct "worker inline functions" error — proving
|
||||
// the operator guard did not reject it.
|
||||
let resp = authed(client().post(&url), "SECRET_TOKEN_2")
|
||||
.json(&inline_preview_body())
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
let (status, body) = post(&url, "SECRET_TOKEN_2", &inline_preview_body()).await;
|
||||
assert_ne!(
|
||||
status, 401,
|
||||
"non-operator must not be blocked by the operator guard (got {status}): {body}"
|
||||
@@ -86,5 +147,113 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
|
||||
"non-operator must not hit the operator guard, got: {body}"
|
||||
);
|
||||
|
||||
// 3. The WM_TOKEN of a deployed-script job the Operator is running passes the
|
||||
// guard for a datatable query: this is `wmill.datatable()` called from
|
||||
// inside that job. As in 2, the harness then fails with the "worker inline
|
||||
// functions" error.
|
||||
let running_job_token =
|
||||
operator_job_token(uuid::Uuid::parse_str(RUNNING_JOB_ID).unwrap()).await;
|
||||
let (status, body) = post(&url, &running_job_token, &datatable_query_body()).await;
|
||||
assert_ne!(
|
||||
status, 401,
|
||||
"operator job token of a running job must pass the guard for a datatable query (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains(OPERATOR_GUARD_MSG),
|
||||
"operator job token of a running job must not hit the operator guard, got: {body}"
|
||||
);
|
||||
|
||||
// 4. The same token is rejected for any other payload: the exemption covers
|
||||
// the datatable request shape only, never in-process DuckDB, and never a
|
||||
// `-- database` directive, which the executor honors over `args.database`.
|
||||
let mut redirected = datatable_query_body();
|
||||
redirected["content"] = json!("-- database u/test-user/other_db\nSELECT 1 AS x;");
|
||||
let mut to_s3 = datatable_query_body();
|
||||
to_s3["content"] = json!("-- s3\nSELECT 1 AS x;");
|
||||
let mut resource_db = datatable_query_body();
|
||||
resource_db["args"]["database"] = json!("$res:u/test-user/other_db");
|
||||
// A marker is a single line the directive regexes cannot match; the directive only
|
||||
// appears once the executor expands it, so the guard must check the expansion.
|
||||
let mut marker = datatable_query_body();
|
||||
marker["content"] = json!(concat!(
|
||||
r#"-- WM_INTERNAL_DB_SELECT {"table":"t","columnDefs":[{"field":"id","datatype":"int4"}],"#,
|
||||
r#""whereClause":"true\n-- database u/test-user/other_db\n AND true"}"#
|
||||
));
|
||||
for (label, payload) in [
|
||||
("DuckDB", inline_preview_body()),
|
||||
("database directive", redirected),
|
||||
("s3 directive", to_s3),
|
||||
("resource database", resource_db),
|
||||
("marker-expanded database directive", marker),
|
||||
] {
|
||||
let (status, body) = post(&url, &running_job_token, &payload).await;
|
||||
assert_eq!(
|
||||
status, 401,
|
||||
"operator job token must be rejected for a {label} payload (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(OPERATOR_GUARD_MSG),
|
||||
"rejection for a {label} payload must be the operator guard, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
// 5. An Operator's job token whose job is not running is rejected like the
|
||||
// operator's own token, whether the job is over (no queue row) or merely
|
||||
// queued: a WM_TOKEN that leaked through logs cannot be replayed once the
|
||||
// job is over.
|
||||
for (label, job_id) in [
|
||||
("finished", uuid::Uuid::new_v4()),
|
||||
("queued", uuid::Uuid::parse_str(QUEUED_JOB_ID).unwrap()),
|
||||
] {
|
||||
let token = operator_job_token(job_id).await;
|
||||
let (status, body) = post(&url, &token, &datatable_query_body()).await;
|
||||
assert_eq!(
|
||||
status, 401,
|
||||
"operator job token of a {label} job must be rejected (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(OPERATOR_GUARD_MSG),
|
||||
"rejection for a {label} job must be the operator guard, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
// 6. The SDKs fall back to `/jobs/run/preview` when the worker has no internal
|
||||
// server (agent workers). The same exemption applies there: the running
|
||||
// job's token queues the datatable query (201 with the job id), the
|
||||
// operator's own token is still refused.
|
||||
let fallback_url = format!("http://localhost:{port}/api/w/test-workspace/jobs/run/preview");
|
||||
let (status, body) = post(&fallback_url, &running_job_token, &datatable_query_body()).await;
|
||||
assert_eq!(
|
||||
status, 201,
|
||||
"operator job token of a running job must queue a datatable preview (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = post(&fallback_url, "OPERATOR_TOKEN", &datatable_query_body()).await;
|
||||
assert_eq!(
|
||||
status, 401,
|
||||
"Operator must be rejected from the preview fallback (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(OPERATOR_GUARD_MSG),
|
||||
"rejection must be the operator guard, got: {body}"
|
||||
);
|
||||
|
||||
// 7. A deferred run on the fallback would outlive the running job the
|
||||
// exemption keys off, so the running job's token cannot schedule one.
|
||||
for deferral in [
|
||||
"scheduled_in_secs=86400",
|
||||
"scheduled_for=2099-01-01T00:00:00Z",
|
||||
] {
|
||||
let deferred_url = format!("{fallback_url}?{deferral}");
|
||||
let (status, body) = post(&deferred_url, &running_job_token, &datatable_query_body()).await;
|
||||
assert_eq!(
|
||||
status, 401,
|
||||
"operator job token must not schedule a deferred preview with {deferral} (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(OPERATOR_GUARD_MSG),
|
||||
"rejection for {deferral} must be the operator guard, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! `POST /api/settings/test_object_storage_config` runs the probe on the API server and reflects the
|
||||
//! upstream response, so every non-super-admin must be rejected for private/loopback endpoints and
|
||||
//! the Filesystem backend on every deployment (`CLOUD_HOSTED` is unset here), while a super admin's
|
||||
//! Filesystem probe still round-trips. Requires the `parquet` feature, like the route.
|
||||
#![cfg(feature = "parquet")]
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const SUPER_ADMIN_TOKEN: &str = "SECRET_TOKEN";
|
||||
const USER_TOKEN: &str = "SECRET_TOKEN_2";
|
||||
|
||||
async fn test_object_storage(
|
||||
url: &str,
|
||||
token: &str,
|
||||
body: serde_json::Value,
|
||||
) -> anyhow::Result<(u16, String)> {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
Ok((resp.status().as_u16(), resp.text().await?))
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn object_storage_test_is_restricted_for_non_super_admins_off_cloud(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let url = format!(
|
||||
"http://localhost:{}/api/settings/test_object_storage_config",
|
||||
server.addr.port()
|
||||
);
|
||||
|
||||
// A loopback "S3 endpoint" standing in for an internal service: the probe must be rejected
|
||||
// before the server opens a connection to it.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let internal_port = listener.local_addr()?.port();
|
||||
let connected = Arc::new(AtomicBool::new(false));
|
||||
tokio::spawn({
|
||||
let connected = connected.clone();
|
||||
async move {
|
||||
while listener.accept().await.is_ok() {
|
||||
connected.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
});
|
||||
let internal_s3 = json!({
|
||||
"type": "S3",
|
||||
"bucket": "bucket",
|
||||
"region": "us-east-1",
|
||||
"access_key": "key",
|
||||
"secret_key": "secret",
|
||||
"endpoint": format!("http://127.0.0.1:{internal_port}"),
|
||||
"allow_http": true,
|
||||
"path_style": true,
|
||||
});
|
||||
let (status, body) = test_object_storage(&url, USER_TOKEN, internal_s3).await?;
|
||||
assert_eq!(
|
||||
status, 401,
|
||||
"non-super-admin must be rejected for a loopback endpoint (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("requires a super admin"),
|
||||
"unexpected rejection: {body}"
|
||||
);
|
||||
assert!(
|
||||
!connected.load(Ordering::SeqCst),
|
||||
"the server must not connect to the rejected endpoint"
|
||||
);
|
||||
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let filesystem = json!({ "type": "Filesystem", "root_path": tmp.path().to_str().unwrap() });
|
||||
let (status, body) = test_object_storage(&url, USER_TOKEN, filesystem.clone()).await?;
|
||||
assert_eq!(
|
||||
status, 401,
|
||||
"non-super-admin must be rejected for a Filesystem backend (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("requires a super admin"),
|
||||
"unexpected rejection: {body}"
|
||||
);
|
||||
|
||||
// Super admins keep the unrestricted path.
|
||||
let (status, body) = test_object_storage(&url, SUPER_ADMIN_TOKEN, filesystem).await?;
|
||||
assert_eq!(
|
||||
status, 200,
|
||||
"super admin must be able to test a Filesystem backend (got {status}): {body}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use tokio_stream::StreamExt;
|
||||
use windmill_api_client::types::NewScript;
|
||||
use windmill_common::scripts::{deploy_relocked_version, fetch_script_for_update};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const W: &str = "test-workspace";
|
||||
|
||||
const A: &str = r#"export async function main() { return "a" }"#;
|
||||
const A_COMMENTED: &str = r#"// same dependencies, different content
|
||||
export async function main() { return "a" }"#;
|
||||
const A_WITH_LODASH: &str = r#"import _ from "lodash@4.17.21";
|
||||
export async function main() { return _.trim(" a ") }"#;
|
||||
const B: &str = r#"import { main as a } from "/f/rel/a.ts";
|
||||
export async function main() { return "b" + (await a()) }"#;
|
||||
const C: &str = r#"import { main as b } from "/f/rel/b.ts";
|
||||
export async function main() { return "c" + (await b()) }"#;
|
||||
|
||||
fn bun_script(path: &str, content: &str, parent_hash: Option<String>) -> NewScript {
|
||||
NewScript {
|
||||
draft_only: None,
|
||||
content: content.into(),
|
||||
language: windmill_api_client::types::ScriptLang::Bun,
|
||||
lock: None,
|
||||
parent_hash,
|
||||
path: path.into(),
|
||||
concurrent_limit: None,
|
||||
concurrency_time_window_s: None,
|
||||
cache_ttl: None,
|
||||
dedicated_worker: None,
|
||||
description: "".to_string(),
|
||||
envs: vec![],
|
||||
is_template: None,
|
||||
kind: None,
|
||||
summary: "".to_string(),
|
||||
tag: None,
|
||||
schema: std::collections::HashMap::new(),
|
||||
ws_error_handler_muted: Some(false),
|
||||
priority: None,
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
restart_unless_cancelled: None,
|
||||
deployment_message: None,
|
||||
concurrency_key: None,
|
||||
visible_to_runner_only: None,
|
||||
auto_kind: None,
|
||||
codebase: None,
|
||||
has_preprocessor: None,
|
||||
on_behalf_of_email: None,
|
||||
assets: vec![],
|
||||
modules: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug)]
|
||||
struct Version {
|
||||
hash: i64,
|
||||
archived: bool,
|
||||
lock: Option<String>,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Every version of `path`, oldest first.
|
||||
async fn versions(db: &Pool<Postgres>, path: &str) -> Vec<Version> {
|
||||
sqlx::query_as(
|
||||
"SELECT hash, archived, lock, created_at FROM script
|
||||
WHERE workspace_id = $1 AND path = $2 ORDER BY created_at",
|
||||
)
|
||||
.bind(W)
|
||||
.bind(path)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn live(versions: &[Version]) -> &Version {
|
||||
versions.iter().rev().find(|v| !v.archived).unwrap()
|
||||
}
|
||||
|
||||
/// `(path, status, logs)` of every dependency job created after `since`, in completion order.
|
||||
async fn dependency_jobs_since(
|
||||
db: &Pool<Postgres>,
|
||||
since: chrono::DateTime<chrono::Utc>,
|
||||
) -> Vec<(String, String, String)> {
|
||||
sqlx::query_as(
|
||||
"SELECT j.runnable_path, c.status::text, COALESCE(l.logs, '') FROM v2_job_completed c
|
||||
JOIN v2_job j ON j.id = c.id
|
||||
LEFT JOIN job_logs l ON l.job_id = c.id
|
||||
WHERE j.kind = 'dependencies' AND j.created_at > $1
|
||||
ORDER BY c.started_at",
|
||||
)
|
||||
.bind(since)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn wait_for_jobs(
|
||||
completed: &mut (impl futures::Stream<Item = uuid::Uuid> + Unpin),
|
||||
count: usize,
|
||||
) {
|
||||
for _ in 0..count {
|
||||
completed.next().await;
|
||||
}
|
||||
// Then let anything else that was queued run out, so a job the assertions say must not
|
||||
// exist would have shown up here.
|
||||
while let Ok(Some(_)) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), completed.next()).await
|
||||
{}
|
||||
}
|
||||
|
||||
/// A redeploy of an imported script whose dependencies did not move relocks its importer,
|
||||
/// and that relock must deploy nothing: no new version, and no dependency job for the
|
||||
/// importer's own importers. A redeploy that does change the dependencies still walks the
|
||||
/// whole chain with a new version at each step.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn relative_import_relock_deploys_only_when_the_lock_changed(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
|
||||
let (client, port, _s) = init_client(db.clone()).await;
|
||||
let mut completed = listen_for_completed_jobs(&db).await;
|
||||
|
||||
in_test_worker(
|
||||
&db,
|
||||
async {
|
||||
// One at a time: each deploy's dependency job records the importer's edges, and an
|
||||
// importer whose edges are recorded is what a later relock of it can skip on.
|
||||
for (path, content) in [("f/rel/a", A), ("f/rel/b", B), ("f/rel/c", C)] {
|
||||
client
|
||||
.create_script(W, &bun_script(path, content, None))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 1).await;
|
||||
}
|
||||
let b_before = versions(&db, "f/rel/b").await;
|
||||
let c_before = versions(&db, "f/rel/c").await;
|
||||
assert_eq!(b_before.len(), 1);
|
||||
assert_eq!(c_before.len(), 1);
|
||||
|
||||
// Content-only change on the leaf.
|
||||
let since = chrono::Utc::now();
|
||||
let a_hash = live(&versions(&db, "f/rel/a").await).hash;
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
&bun_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 2).await;
|
||||
|
||||
let jobs = dependency_jobs_since(&db, since).await;
|
||||
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
assert_eq!(
|
||||
paths,
|
||||
["f/rel/a", "f/rel/b"],
|
||||
"the leaf's own job and one no-op relock of its importer, and nothing for c"
|
||||
);
|
||||
assert!(
|
||||
jobs[1]
|
||||
.2
|
||||
.contains("Lock unchanged: no new version deployed"),
|
||||
"b's relock should have found its lock unchanged: {}",
|
||||
jobs[1].2
|
||||
);
|
||||
let b_after = versions(&db, "f/rel/b").await;
|
||||
let c_after = versions(&db, "f/rel/c").await;
|
||||
assert_eq!(
|
||||
b_after.len(),
|
||||
1,
|
||||
"an unchanged relock must not mint a version"
|
||||
);
|
||||
assert_eq!(live(&b_after).hash, live(&b_before).hash);
|
||||
assert_eq!(c_after.len(), 1);
|
||||
assert_eq!(live(&c_after).hash, live(&c_before).hash);
|
||||
|
||||
// A dependency change on the leaf.
|
||||
let since = chrono::Utc::now();
|
||||
let a_hash = live(&versions(&db, "f/rel/a").await).hash;
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
&bun_script("f/rel/a", A_WITH_LODASH, Some(format!("{a_hash:016x}"))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 3).await;
|
||||
|
||||
let jobs = dependency_jobs_since(&db, since).await;
|
||||
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
assert_eq!(paths, ["f/rel/a", "f/rel/b", "f/rel/c"]);
|
||||
for path in ["f/rel/b", "f/rel/c"] {
|
||||
let vs = versions(&db, path).await;
|
||||
assert_eq!(
|
||||
vs.len(),
|
||||
2,
|
||||
"{path}: a changed relock deploys a new version"
|
||||
);
|
||||
assert!(
|
||||
vs[0].archived && !vs[1].archived,
|
||||
"{path}: parent archived, child live"
|
||||
);
|
||||
assert!(vs[0].created_at < vs[1].created_at, "{path}: lineage order");
|
||||
assert!(
|
||||
vs[1].lock.as_deref().unwrap_or("").contains("lodash"),
|
||||
"{path}: the new version carries the new lock: {:?}",
|
||||
vs[1].lock
|
||||
);
|
||||
}
|
||||
},
|
||||
port,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A relock that has to wait on its head's row lock, because a deploy of the same path holds
|
||||
/// it, must find the version that deploy left and requeue itself for it rather than fail. The
|
||||
/// blocked statement re-checks only the row it selected, which the deploy archived, and comes
|
||||
/// back empty; the successor is only visible to a fresh read.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
|
||||
let (client, port, _s) = init_client(db.clone()).await;
|
||||
let mut completed = listen_for_completed_jobs(&db).await;
|
||||
|
||||
in_test_worker(
|
||||
&db,
|
||||
async {
|
||||
for (path, content) in [("f/rel/a", A), ("f/rel/b", B)] {
|
||||
client
|
||||
.create_script(W, &bun_script(path, content, None))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 1).await;
|
||||
}
|
||||
|
||||
// A deploy of b that holds its head's row lock for as long as this transaction lives.
|
||||
let mut deploy = db.begin().await.unwrap();
|
||||
let head = fetch_script_for_update("f/rel/b", W, &mut *deploy)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let since = chrono::Utc::now();
|
||||
let a_hash = live(&versions(&db, "f/rel/a").await).hash;
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
&bun_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// b's relock skips generation and reaches its commit, where it waits on the lock.
|
||||
let mut waiting = false;
|
||||
for _ in 0..300 {
|
||||
waiting = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_stat_activity
|
||||
WHERE datname = current_database() AND wait_event_type = 'Lock'
|
||||
AND query LIKE '%FROM script WHERE path = $1%FOR UPDATE%')",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
if waiting {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
assert!(waiting, "b's relock never reached the row lock");
|
||||
|
||||
// The deploy lands: the head is archived and a successor with its own lock takes
|
||||
// its place, while the relock is still waiting.
|
||||
let lock = head.lock.clone().unwrap();
|
||||
let successor =
|
||||
deploy_relocked_version(&mut deploy, head, None, Some(&lock), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
deploy.commit().await.unwrap();
|
||||
|
||||
// a's own job, the relock that waited, and the relock it queued for the successor.
|
||||
wait_for_jobs(&mut completed, 3).await;
|
||||
|
||||
let jobs = dependency_jobs_since(&db, since).await;
|
||||
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
assert_eq!(paths, ["f/rel/a", "f/rel/b", "f/rel/b"], "{jobs:?}");
|
||||
assert!(
|
||||
jobs.iter().all(|(_, status, _)| status == "success"),
|
||||
"no relock may fail on the wait: {jobs:?}"
|
||||
);
|
||||
assert!(
|
||||
jobs[1]
|
||||
.2
|
||||
.contains("was deployed while this lock was generated"),
|
||||
"the waiting relock should have seen the successor: {}",
|
||||
jobs[1].2
|
||||
);
|
||||
assert!(
|
||||
jobs[2]
|
||||
.2
|
||||
.contains("Lock unchanged: no new version deployed"),
|
||||
"the requeued relock should find the successor's lock current: {}",
|
||||
jobs[2].2
|
||||
);
|
||||
let vs = versions(&db, "f/rel/b").await;
|
||||
assert_eq!(
|
||||
vs.len(),
|
||||
2,
|
||||
"the deploy's successor and nothing else: {vs:?}"
|
||||
);
|
||||
assert_eq!(live(&vs).hash, successor);
|
||||
},
|
||||
port,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A multi-file importer: on a skipped relock each module gets its own last lock back, not the
|
||||
/// parent script's, so an import's content-only redeploy leaves the importer alone as well.
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
|
||||
let (client, port, _s) = init_client(db.clone()).await;
|
||||
let mut completed = listen_for_completed_jobs(&db).await;
|
||||
|
||||
let py = |path: &str, content: &str, parent_hash: Option<String>, with_module: bool| {
|
||||
let mut ns = bun_script(path, content, parent_hash);
|
||||
ns.language = windmill_api_client::types::ScriptLang::Python3;
|
||||
if with_module {
|
||||
ns.modules = Some(std::collections::HashMap::from([(
|
||||
"helper.py".to_string(),
|
||||
serde_json::json!({
|
||||
"content": "def greet(x):\n return 'hi ' + x\n",
|
||||
"language": "python3"
|
||||
}),
|
||||
)]));
|
||||
}
|
||||
ns
|
||||
};
|
||||
async fn module_lock(db: &Pool<Postgres>) -> Option<String> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT modules->'helper.py'->>'lock' FROM script
|
||||
WHERE workspace_id = $1 AND path = 'f/rel/pb' AND archived = false",
|
||||
)
|
||||
.bind(W)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
in_test_worker(
|
||||
&db,
|
||||
async {
|
||||
client
|
||||
.create_script(W, &py("f/rel/pa", "def main():\n return 'a'\n", None, false))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 1).await;
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
&py(
|
||||
"f/rel/pb",
|
||||
"from f.rel.pa import main as a\nfrom .helper import greet\n\ndef main():\n return greet(a())\n",
|
||||
None,
|
||||
true,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 1).await;
|
||||
let lock_before = module_lock(&db).await;
|
||||
assert!(lock_before.is_some(), "the module got a lock of its own on deploy");
|
||||
|
||||
let since = chrono::Utc::now();
|
||||
let pa_hash = live(&versions(&db, "f/rel/pa").await).hash;
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
&py(
|
||||
"f/rel/pa",
|
||||
"# same dependencies\ndef main():\n return 'a'\n",
|
||||
Some(format!("{pa_hash:016x}")),
|
||||
false,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 2).await;
|
||||
|
||||
let jobs = dependency_jobs_since(&db, since).await;
|
||||
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
assert_eq!(paths, ["f/rel/pa", "f/rel/pb"], "{jobs:?}");
|
||||
assert!(
|
||||
jobs[1].2.contains("Lock unchanged: no new version deployed"),
|
||||
"the multi-file importer's relock should be a no-op: {}",
|
||||
jobs[1].2
|
||||
);
|
||||
assert_eq!(versions(&db, "f/rel/pb").await.len(), 1);
|
||||
assert_eq!(module_lock(&db).await, lock_before, "the module keeps its own lock");
|
||||
},
|
||||
port,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -266,7 +266,10 @@ def main():
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
in_test_worker(&db, wait_for_jobs_ge(&mut completed, 10), port).await;
|
||||
// Empty content leaves every importer's lock as it was, so only the five direct
|
||||
// importers of the default deps run a job: an unchanged script relock deploys no
|
||||
// version and so queues nothing for its own importers.
|
||||
in_test_worker(&db, wait_for_jobs_ge(&mut completed, 5), port).await;
|
||||
|
||||
// Note: within a cascade, the same script may be triggered multiple times.
|
||||
// After the first trigger relocks and stores the hash, subsequent triggers skip.
|
||||
@@ -295,7 +298,7 @@ def main():
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
in_test_worker(&db, wait_for_jobs_ge(&mut completed, 10), port).await;
|
||||
in_test_worker(&db, wait_for_jobs_ge(&mut completed, 5), port).await;
|
||||
|
||||
let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await;
|
||||
assert!(
|
||||
|
||||
@@ -157,7 +157,8 @@ async fn list_flows(
|
||||
FROM draft d \
|
||||
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
|
||||
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
|
||||
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow') as draft_users",
|
||||
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow' \
|
||||
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)) as draft_users",
|
||||
"folder_labels(o.workspace_id, o.path) as inherited_labels"
|
||||
])
|
||||
.left()
|
||||
|
||||
@@ -183,9 +183,9 @@ async fn add_granular_acl(
|
||||
|
||||
if kind == "folder" {
|
||||
let change_type = if write.unwrap_or(false) {
|
||||
"grant_read"
|
||||
} else {
|
||||
"grant_write"
|
||||
} else {
|
||||
"grant_read"
|
||||
};
|
||||
crate::folders::log_folder_permission_change(
|
||||
&mut *tx,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Request headers reaching a runnable's preprocessor over MCP.
|
||||
//!
|
||||
//! The property this pins is structural rather than a filter: the model writes
|
||||
//! the tool's arguments, which become `event.body`, while the server writes
|
||||
//! `event.headers`. A model that guesses a header's name can only ever land in
|
||||
//! `body`, so an identity read from `headers` is one prompt injection cannot
|
||||
//! forge. Nothing else in the suite exercises MCP argument shaping end to end.
|
||||
//!
|
||||
//! Requires: bun runtime, live database (migrations applied by sqlx::test).
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const SCRIPT_PATH: &str = "u/test-user/mcp_hdr_probe";
|
||||
|
||||
/// Echoes the two halves of the event separately, so the assertions can tell
|
||||
/// which one a value arrived in.
|
||||
const PREPROCESSOR_SCRIPT: &str = r#"
|
||||
export async function preprocessor(event: any) {
|
||||
return {
|
||||
kind: event.kind,
|
||||
from_headers: event.headers?.["x-user-id"] ?? "",
|
||||
from_body: event.body?.x_user_id ?? "",
|
||||
header_names: Object.keys(event.headers ?? {}).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function main(kind: string, from_headers: string, from_body: string, header_names: string[]) {
|
||||
return { kind, from_headers, from_body, header_names };
|
||||
}
|
||||
"#;
|
||||
|
||||
async fn insert_mcp_token(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
|
||||
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST one JSON-RPC message. The endpoint answers either `application/json` or
|
||||
/// a single-event SSE stream, so strip the `data: ` framing before parsing.
|
||||
async fn mcp_post(port: u16, headers: &[(&str, &str)], body: Value) -> anyhow::Result<Value> {
|
||||
let mut req = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/mcp/w/test-workspace/mcp"
|
||||
))
|
||||
.header("Authorization", "Bearer MCP_TOKEN")
|
||||
.header("Accept", "application/json, text/event-stream")
|
||||
.json(&body);
|
||||
for (k, v) in headers {
|
||||
req = req.header(*k, *v);
|
||||
}
|
||||
let text = req.send().await?.text().await?;
|
||||
let payload = text
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("data: "))
|
||||
.unwrap_or(text.trim());
|
||||
serde_json::from_str(payload).map_err(|e| anyhow::anyhow!("unparseable MCP body {text:?}: {e}"))
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_mcp_preprocessor_receives_the_callers_headers(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
set_jwt_secret().await;
|
||||
insert_mcp_token(&db).await?;
|
||||
let server = ApiServer::start_mcp(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": SCRIPT_PATH,
|
||||
"summary": "mcp header probe",
|
||||
"description": "",
|
||||
"content": PREPROCESSOR_SCRIPT,
|
||||
"language": "bun",
|
||||
"lock": "",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": { "x_user_id": { "type": "string" } },
|
||||
"required": []
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"create script: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
|
||||
// A script counts as deployed once it has a lock, which normally arrives from
|
||||
// a dependency job. Planting an empty one keeps the test to the path under
|
||||
// test instead of a bun resolution whose timing it does not control.
|
||||
sqlx::query("UPDATE script SET lock = '' WHERE path = $1 AND workspace_id = 'test-workspace'")
|
||||
.bind(SCRIPT_PATH)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let tools = mcp_post(
|
||||
port,
|
||||
&[],
|
||||
json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}),
|
||||
)
|
||||
.await?;
|
||||
let tool_name = tools["result"]["tools"]
|
||||
.as_array()
|
||||
.and_then(|list| {
|
||||
list.iter()
|
||||
.filter_map(|t| t["name"].as_str())
|
||||
.find(|n| n.contains("mcp__hdr__probe"))
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("the deployed script was not listed as a tool: {tools}"))?
|
||||
.to_string();
|
||||
|
||||
let result = in_test_worker(
|
||||
db.clone(),
|
||||
async {
|
||||
mcp_post(
|
||||
port,
|
||||
// Every name the withheld list covers has to be on the wire, or
|
||||
// asserting its absence proves nothing. `Authorization` is already
|
||||
// set by `mcp_post`, and `extract_token` reads it before the
|
||||
// cookie, so sending one does not disturb auth.
|
||||
&[
|
||||
("X-User-Id", "alice@corp.example"),
|
||||
("Cookie", "session=secret"),
|
||||
("Proxy-Authorization", "Basic Zm9v"),
|
||||
],
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
||||
// The model names the header it wants to spoof. Its value is an
|
||||
// argument, so it can only ever reach `event.body`.
|
||||
"params": { "name": tool_name, "arguments": { "x_user_id": "attacker@evil.test" } }
|
||||
}),
|
||||
)
|
||||
.await
|
||||
},
|
||||
port,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let text = result["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("tool call returned no text content: {result}"))?;
|
||||
let out: Value = serde_json::from_str(text)?;
|
||||
|
||||
assert_eq!(out["kind"], "mcp", "preprocessor event kind: {out}");
|
||||
assert_eq!(
|
||||
out["from_headers"], "alice@corp.example",
|
||||
"the caller's header must reach event.headers: {out}"
|
||||
);
|
||||
assert_eq!(
|
||||
out["from_body"], "attacker@evil.test",
|
||||
"the model's argument must land in event.body, not overwrite the header: {out}"
|
||||
);
|
||||
|
||||
let names: Vec<&str> = out["header_names"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
names.contains(&"x-user-id"),
|
||||
"event.headers must carry the request's own headers: {names:?}"
|
||||
);
|
||||
for withheld in ["authorization", "cookie", "proxy-authorization"] {
|
||||
assert!(
|
||||
!names.contains(&withheld),
|
||||
"{withheld} is withheld from a preprocessor: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -38,6 +38,85 @@ fn new_script(path: &str, summary: &str, content: &str) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// A supplied lock queues no dependency job, so if the create does not record its hash nothing
|
||||
/// ever will, and every importer of this script relocks on each of its deploys forever after.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_create_script_persists_supplied_lock_hash(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let path = "u/test-user/supplied_lock";
|
||||
let lock = r#"{"version":"4","remote":{}}"#;
|
||||
let mut script = new_script(
|
||||
path,
|
||||
"Supplied lock",
|
||||
"export async function main() { return 42; }",
|
||||
);
|
||||
script["lock"] = json!(lock);
|
||||
|
||||
let resp = authed(client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
)))
|
||||
.json(&script)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "create: {}", resp.text().await?);
|
||||
|
||||
let stored_hash = sqlx::query_scalar!(
|
||||
"SELECT lockfile_hash FROM lock_hash WHERE workspace_id = $1 AND path = $2",
|
||||
"test-workspace",
|
||||
path,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(stored_hash, windmill_common::scripts::hash_script(lock));
|
||||
|
||||
// A script deployed before the create recorded hashes has no row, and pushing it unchanged
|
||||
// creates no version to hang one off. Without the write on that path it would keep its
|
||||
// importers relocking until someone edited it.
|
||||
sqlx::query!(
|
||||
"DELETE FROM lock_hash WHERE workspace_id = $1 AND path = $2",
|
||||
"test-workspace",
|
||||
path,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// The no-op comparison covers every field, so the push has to carry what the first deploy
|
||||
// filled in by itself; `auto_parent` both resolves the parent and keeps the hash distinct.
|
||||
script["auto_parent"] = json!(true);
|
||||
script["ws_error_handler_muted"] = json!(false);
|
||||
script["assets"] = json!([]);
|
||||
let resp = authed(client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create?skip_if_noop=true"
|
||||
)))
|
||||
.json(&script)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "no-op push: {}", resp.text().await?);
|
||||
|
||||
let versions: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM script WHERE workspace_id = $1 AND path = $2",
|
||||
"test-workspace",
|
||||
path,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
assert_eq!(versions, 1, "no-op push must not create a version");
|
||||
|
||||
let repaired_hash = sqlx::query_scalar!(
|
||||
"SELECT lockfile_hash FROM lock_hash WHERE workspace_id = $1 AND path = $2",
|
||||
"test-workspace",
|
||||
path,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(repaired_hash, windmill_common::scripts::hash_script(lock));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
@@ -797,10 +876,12 @@ async fn test_update_script_reports_losing_to_a_concurrent_deploy(
|
||||
|
||||
// What a deploy leaves behind: the old head archived, a new one live at the path.
|
||||
// Copied through a temp table so this does not have to restate every column.
|
||||
sqlx::query("CREATE TEMP TABLE superseding ON COMMIT DROP AS SELECT * FROM script WHERE hash = $1")
|
||||
.bind(head)
|
||||
.execute(&mut *winner)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE TEMP TABLE superseding ON COMMIT DROP AS SELECT * FROM script WHERE hash = $1",
|
||||
)
|
||||
.bind(head)
|
||||
.execute(&mut *winner)
|
||||
.await?;
|
||||
sqlx::query("UPDATE superseding SET hash = $1, archived = false, parent_hashes = ARRAY[$2]")
|
||||
.bind(head + 1)
|
||||
.bind(head)
|
||||
@@ -818,7 +899,10 @@ async fn test_update_script_reports_losing_to_a_concurrent_deploy(
|
||||
let resp = tokio::time::timeout(std::time::Duration::from_secs(20), update).await??;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(status, 400, "losing the race should not read as success: {body}");
|
||||
assert_eq!(
|
||||
status, 400,
|
||||
"losing the race should not read as success: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("deployed to concurrently"),
|
||||
"the loser must say it was superseded, not that the script is missing: {body}"
|
||||
|
||||
@@ -917,3 +917,79 @@ async fn test_change_user_email_leaves_group_identities(db: Pool<Postgres>) -> a
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An address with no `password` row can own a draft, and the account paths carry the delete and
|
||||
/// rename that no foreign key does any more.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_drafts_follow_their_owner_without_a_fkey(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let global_base = format!("http://localhost:{port}/api/users");
|
||||
|
||||
// The destination of the rename below already holds a draft of the same item — it belongs to
|
||||
// an accountless principal, so `change_email`'s "address is free" check does not see it.
|
||||
sqlx::query!(
|
||||
"INSERT INTO draft(workspace_id, path, typ, value, email) VALUES
|
||||
('test-workspace', 'u/ext/s', 'script', '{}'::json, 'ext-jwt@windmill.dev'),
|
||||
('test-workspace', 'u/two/s', 'script', '{\"summary\": \"moving\"}'::json, 'test2@windmill.dev'),
|
||||
('test-workspace', 'u/two/s', 'script', '{\"summary\": \"displaced\"}'::json, 'renamed@windmill.dev'),
|
||||
('test-workspace', 'u/three/s', 'script', '{}'::json, 'test3@windmill.dev')"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// A null username is how the legacy workspace-level row is encoded, so an owner nobody can
|
||||
// name must be absent from the owner circles rather than pose as one.
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/list?all_users=true"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let listed = resp.json::<serde_json::Value>().await?;
|
||||
let ext = listed
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|d| d["path"] == "u/ext/s")
|
||||
.expect("the accountless owner's draft is listed");
|
||||
assert_eq!(ext.get("draft_users"), None);
|
||||
|
||||
let resp = authed(client().post(format!("{global_base}/change_email/test2@windmill.dev")))
|
||||
.json(&json!({ "new_email": "renamed@windmill.dev" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "change_email: {}", resp.text().await?);
|
||||
let moved = sqlx::query!(
|
||||
"SELECT email, value->>'summary' AS summary FROM draft WHERE path = 'u/two/s'"
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
moved
|
||||
.iter()
|
||||
.map(|r| (r.email.as_deref(), r.summary.as_deref()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(Some("renamed@windmill.dev"), Some("moving"))],
|
||||
"the moving account's draft wins the unique index it now collides on"
|
||||
);
|
||||
|
||||
let resp = authed(client().delete(format!("{global_base}/delete/test3@windmill.dev")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "delete_user: {}", resp.text().await?);
|
||||
let remaining = sqlx::query_scalar!("SELECT path FROM draft ORDER BY path")
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
remaining,
|
||||
vec!["u/ext/s".to_string(), "u/two/s".to_string()],
|
||||
"the deleted account's draft goes, the accountless owner's stays"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -889,6 +889,53 @@ async fn test_get_copilot_info_ignores_empty_instance_ai_row(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A workspace with no provider of its own is served the instance config, but the
|
||||
/// `copilot_disabled` flag must still come from the workspace's own row.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_get_copilot_info_keeps_workspace_copilot_disabled_over_instance_fallback(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
|
||||
|
||||
sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2")
|
||||
.bind(json!({ "copilot_disabled": true }))
|
||||
.bind("test-workspace")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
|
||||
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
|
||||
)
|
||||
.bind("ai_config")
|
||||
.bind(json!({
|
||||
"providers": {
|
||||
"openai": {
|
||||
"resource_path": "u/test-user/openai_instance",
|
||||
"models": ["gpt-4o-mini"]
|
||||
}
|
||||
}
|
||||
}))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_copilot_info")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let settings = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(
|
||||
settings["providers"]["openai"]["models"][0], "gpt-4o-mini",
|
||||
"instance providers are still served"
|
||||
);
|
||||
assert_eq!(settings["copilot_disabled"], true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_error_handler_instance_alerts_fallback(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
@@ -941,7 +988,12 @@ async fn test_error_handler_instance_alerts_fallback(db: Pool<Postgres>) -> anyh
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "disable on fork: {}", resp.text().await?);
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"disable on fork: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
assert!(!stored().await?);
|
||||
|
||||
Ok(())
|
||||
@@ -1044,9 +1096,11 @@ async fn test_create_service_account_drops_orphaned_group_memberships(
|
||||
.await?;
|
||||
|
||||
// Same username, different workspace, and very much alive — must not be touched.
|
||||
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other', 'svc_acct')")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other', 'svc_acct')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO group_ (workspace_id, name, summary) VALUES
|
||||
('other-workspace', 'all', 'All users'),
|
||||
|
||||
@@ -416,11 +416,31 @@ pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
// A reverse proxy consumes hop-by-hop headers instead of forwarding them and
|
||||
// drops every header named by `Connection`, so a script could use one to strip
|
||||
// the sandbox headers this function adds before they reach the browser.
|
||||
const HOP_BY_HOP_HEADERS: [&str; 9] = [
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
];
|
||||
|
||||
if let Some(windmill_headers) = windmill_headers {
|
||||
for (k, v) in windmill_headers {
|
||||
let k = HeaderName::from_str(k.as_str()).map_err(|err| {
|
||||
Error::internal_err(format!("Invalid header name {k}: {err}"))
|
||||
})?;
|
||||
if HOP_BY_HOP_HEADERS.contains(&k.as_str()) {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"windmill_headers cannot set the hop-by-hop header \"{k}\""
|
||||
)));
|
||||
}
|
||||
let v = HeaderValue::from_str(v.as_str()).map_err(|err| {
|
||||
Error::internal_err(format!("Invalid header value {v}: {err}"))
|
||||
})?;
|
||||
@@ -428,6 +448,22 @@ pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result
|
||||
}
|
||||
}
|
||||
|
||||
// The script controls the content type and body, and run_wait_result and sync
|
||||
// HTTP routes are reachable by top-level GET navigation with the session cookie:
|
||||
// sandbox the document into an opaque origin so HTML can never run with the
|
||||
// viewer's session. Inserted after `wm_headers` so a script cannot override it.
|
||||
headers.insert(
|
||||
http::header::X_CONTENT_TYPE_OPTIONS,
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert(
|
||||
http::header::CONTENT_SECURITY_POLICY,
|
||||
HeaderValue::from_static(
|
||||
"sandbox allow-scripts allow-forms allow-popups \
|
||||
allow-popups-to-escape-sandbox allow-downloads allow-modals",
|
||||
),
|
||||
);
|
||||
|
||||
if let Some(content_type) = windmill_content_type {
|
||||
let serialized_json_result = result_value
|
||||
.map(|val| val.get().to_owned())
|
||||
@@ -1104,6 +1140,56 @@ mod result_to_response_tests {
|
||||
resp.headers().get(http::header::CONTENT_TYPE).unwrap(),
|
||||
"text/html"
|
||||
);
|
||||
assert_sandboxed(resp.headers());
|
||||
assert_eq!(body_bytes(resp).await, b"<h1>hi</h1>");
|
||||
}
|
||||
|
||||
fn assert_sandboxed(headers: &HeaderMap) {
|
||||
assert_eq!(
|
||||
headers.get(http::header::X_CONTENT_TYPE_OPTIONS).unwrap(),
|
||||
"nosniff"
|
||||
);
|
||||
let csp = headers
|
||||
.get(http::header::CONTENT_SECURITY_POLICY)
|
||||
.expect("content-security-policy")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(csp.starts_with("sandbox "), "csp: {csp}");
|
||||
assert!(!csp.contains("allow-same-origin"), "csp: {csp}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_headers_cannot_override_sandbox() {
|
||||
// wm_headers is script-controlled: a content-type set there replaces the JSON
|
||||
// one even without wm_content_type, and the sandbox headers must survive an
|
||||
// attempt to override them.
|
||||
let resp = result_to_response(
|
||||
raw(
|
||||
r#"{"wm_headers":{"content-type":"text/html","content-security-policy":"default-src *","x-content-type-options":"none"},"result":"<h1>hi</h1>"}"#,
|
||||
),
|
||||
true,
|
||||
)
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(
|
||||
resp.headers().get(http::header::CONTENT_TYPE).unwrap(),
|
||||
"text/html"
|
||||
);
|
||||
assert_sandboxed(resp.headers());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hop_by_hop_custom_headers_are_rejected() {
|
||||
// A proxy drops every header named by `Connection`, which would strip the
|
||||
// sandbox headers on the way to the browser.
|
||||
for name in ["connection", "Connection", "transfer-encoding", "upgrade"] {
|
||||
let res = result_to_response(
|
||||
raw(&format!(
|
||||
r#"{{"wm_content_type":"text/html","wm_headers":{{"{name}":"content-security-policy, x-content-type-options"}},"result":"<h1>hi</h1>"}}"#
|
||||
)),
|
||||
true,
|
||||
);
|
||||
assert!(res.is_err(), "hop-by-hop header must be rejected: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ use sqlx::{FromRow, Postgres, Transaction};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_dep_map::process_relative_imports;
|
||||
use windmill_dep_map::{lock_hash::record_lock_hashes, process_relative_imports};
|
||||
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
|
||||
|
||||
use windmill_common::{
|
||||
@@ -216,12 +216,15 @@ async fn list_scripts(
|
||||
// a member of has no `usr` row, so fall back to their instance-derived username
|
||||
// (`password.username`), or their email when derivation is disabled — this keeps the
|
||||
// raw email out of the payload whenever a derived username exists. The genuine
|
||||
// NULL-email legacy row stays None (no `usr`/`password` match, `d.email` is NULL).
|
||||
// NULL-email legacy row stays None (no `usr`/`password` match, `d.email` is NULL),
|
||||
// which is why an owner that resolves to no name at all — an external JWT's subject
|
||||
// has neither row — is dropped: None is read as "legacy" downstream.
|
||||
"(SELECT json_agg(json_build_object('username', COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END)) ORDER BY COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) NULLS LAST) \
|
||||
FROM draft d \
|
||||
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
|
||||
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
|
||||
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'script') as draft_users",
|
||||
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'script' \
|
||||
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)) as draft_users",
|
||||
"folder_labels(o.workspace_id, o.path) as inherited_labels"
|
||||
])
|
||||
.left()
|
||||
@@ -1070,6 +1073,14 @@ fn modules_eq(
|
||||
}
|
||||
}
|
||||
|
||||
/// Recorded for the empty lock a codebase or a language with no lock generation carries as well as
|
||||
/// for a real one: the worker writes `hash_script("")` in the same situation, and a path going from
|
||||
/// a real lock to an empty one has to stop matching what its importers recorded, or they wrongly
|
||||
/// skip rather than merely relock too often.
|
||||
fn lock_hash_entry(path: &str, lock: &str) -> [(String, i64); 1] {
|
||||
[(path.to_string(), hash_script(lock))]
|
||||
}
|
||||
|
||||
async fn create_script_internal<'c>(
|
||||
mut ns: NewScript,
|
||||
w_id: String,
|
||||
@@ -1337,6 +1348,12 @@ async fn create_script_internal<'c>(
|
||||
parent_hash = %p_hash.0,
|
||||
"Skipping no-op script deploy (identical to parent)"
|
||||
);
|
||||
// The version is unchanged, but the row recording its lock's hash may never have
|
||||
// been written — nothing else writes it for a supplied lock, and a path only ever
|
||||
// pushed unchanged would otherwise keep its importers relocking forever.
|
||||
if let Some(lock) = ps.lock.as_deref() {
|
||||
record_lock_hashes(&mut tx, &w_id, &lock_hash_entry(&ns.path, lock)).await?;
|
||||
}
|
||||
return Ok((p_hash.clone(), tx, None, Vec::new()));
|
||||
}
|
||||
|
||||
@@ -1884,6 +1901,13 @@ async fn create_script_internal<'c>(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// A lock that is not left to a dependency job queues none, so this is the only place its hash
|
||||
// can be recorded. `try_skip_relock` treats a missing hash for an imported script as changed,
|
||||
// so leaving the row out makes every importer of this path relock on every deploy of it.
|
||||
if let Some(lock) = lock.as_deref() {
|
||||
record_lock_hashes(&mut tx, &w_id, &lock_hash_entry(&ns.path, lock)).await?;
|
||||
}
|
||||
|
||||
// Update ci_test_reference table for test scripts
|
||||
// Delete by both new and old path to handle renames
|
||||
let old_path = parent_hashes_and_perms.as_ref().map(|x| x.p_path.as_str());
|
||||
|
||||
@@ -284,15 +284,28 @@ pub async fn test_s3_bucket(
|
||||
use bytes::Bytes;
|
||||
use futures::StreamExt;
|
||||
|
||||
// The probe executes on the API server itself. On multi-tenant Cloud that is a shared control
|
||||
// plane, so we constrain untrusted callers to remove the SSRF / credential-exfiltration /
|
||||
// local-filesystem surface (see validate_object_storage_test). On self-hosted instances the
|
||||
// object store usually lives on the local/private network and all authenticated users are
|
||||
// trusted, so testing there stays unrestricted. Super admins keep the unrestricted path too.
|
||||
// The probe executes on the API server itself and reflects the upstream response into the
|
||||
// error, so any authenticated caller could otherwise use it as an SSRF / port-scan primitive
|
||||
// against the server's network, exfiltrate its ambient credentials, or write to its local
|
||||
// disk (see validate_object_storage_test). That holds on self-hosted instances as much as on
|
||||
// Cloud, so only super admins get the unrestricted path.
|
||||
let is_super_admin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?;
|
||||
let restrict = !is_super_admin && *CLOUD_HOSTED;
|
||||
let restrict = !is_super_admin;
|
||||
if restrict {
|
||||
validate_object_storage_test(&test_s3_bucket).await?;
|
||||
validate_object_storage_test(&test_s3_bucket)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
// A job token never counts as a super admin (it is capped at workspace admin), so
|
||||
// a super admin calling this route from a script is told why rather than that
|
||||
// they lack a privilege they hold.
|
||||
error::Error::NotAuthorized(msg) if authed.job_id.is_some() => {
|
||||
error::Error::NotAuthorized(format!(
|
||||
"{msg} A job token ($WM_TOKEN) is never treated as a super admin; call \
|
||||
this route with a user token instead."
|
||||
))
|
||||
}
|
||||
e => e,
|
||||
})?;
|
||||
}
|
||||
|
||||
let client = build_object_store_from_settings(test_s3_bucket, Some(&db))
|
||||
@@ -355,8 +368,8 @@ pub async fn test_s3_bucket(
|
||||
}
|
||||
}
|
||||
|
||||
// Hardening for the object-storage connectivity test by an untrusted (non-super-admin) caller on
|
||||
// Cloud. The probe runs on the shared API server, so without these constraints an authenticated
|
||||
// Hardening for the object-storage connectivity test by an untrusted (non-super-admin) caller.
|
||||
// The probe runs on the API server, so without these constraints an authenticated
|
||||
// user could coerce the server into connecting to arbitrary internal endpoints (SSRF), signing
|
||||
// requests with the instance role (credential exfiltration), or reading/writing the server's local
|
||||
// disk (filesystem object store).
|
||||
@@ -366,6 +379,11 @@ async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Resul
|
||||
opt.as_ref().is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
// Every refusal names the way out: the resource usually works in jobs (workers reach the
|
||||
// endpoint directly), so without it the refusal reads as a broken resource.
|
||||
const ALTERNATIVE: &str =
|
||||
"Ask a super admin to run it, or test the resource from a script, which runs on a worker.";
|
||||
|
||||
// Reject backends that rely on the server's identity or local filesystem, require explicit
|
||||
// credentials for the rest (so the server never falls back to its own ambient credentials), and
|
||||
// resolve the host the client will actually connect to. We derive the *effective* endpoint here
|
||||
@@ -376,20 +394,25 @@ async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Resul
|
||||
let effective_endpoint: Option<String> = match settings {
|
||||
ObjectSettings::Filesystem(_) => {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing a local filesystem object store requires a super admin".to_string(),
|
||||
"Testing a local filesystem object store requires a super admin: it runs on the \
|
||||
Windmill server and reads and writes the server's local disk. Ask a super admin \
|
||||
to run it."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
ObjectSettings::AwsOidc(_) => {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing OIDC-based object storage requires a super admin".to_string(),
|
||||
));
|
||||
return Err(error::Error::NotAuthorized(format!(
|
||||
"Testing OIDC-based object storage requires a super admin: it runs on the \
|
||||
Windmill server with the server's own identity. {ALTERNATIVE}"
|
||||
)));
|
||||
}
|
||||
ObjectSettings::S3(s3) => {
|
||||
if !(non_empty(&s3.access_key) && non_empty(&s3.secret_key)) {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing S3 storage without explicit credentials requires a super admin"
|
||||
.to_string(),
|
||||
));
|
||||
return Err(error::Error::NotAuthorized(format!(
|
||||
"Testing S3 storage without an explicit access key and secret key requires a \
|
||||
super admin: it runs on the Windmill server, which would use its own ambient \
|
||||
credentials. {ALTERNATIVE}"
|
||||
)));
|
||||
}
|
||||
let region = s3
|
||||
.region
|
||||
@@ -413,10 +436,11 @@ async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Resul
|
||||
}
|
||||
ObjectSettings::Azure(azure) => {
|
||||
if !non_empty(&azure.access_key) {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing Azure storage without an explicit access key requires a super admin"
|
||||
.to_string(),
|
||||
));
|
||||
return Err(error::Error::NotAuthorized(format!(
|
||||
"Testing Azure storage without an explicit access key requires a super admin: \
|
||||
it runs on the Windmill server, which would use its own ambient credentials. \
|
||||
{ALTERNATIVE}"
|
||||
)));
|
||||
}
|
||||
Some(
|
||||
azure
|
||||
@@ -432,10 +456,11 @@ async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Resul
|
||||
// otherwise an untrusted caller could probe with the server's identity (the very
|
||||
// SSRF/credential-exfil this function guards against).
|
||||
if windmill_object_store::gcs_service_account_key_is_blank(&gcs.service_account_key) {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing GCS storage without a service account key requires a super admin"
|
||||
.to_string(),
|
||||
));
|
||||
return Err(error::Error::NotAuthorized(format!(
|
||||
"Testing GCS storage without a service account key requires a super admin: \
|
||||
it runs on the Windmill server, which would use its own ambient credentials. \
|
||||
{ALTERNATIVE}"
|
||||
)));
|
||||
}
|
||||
// The service-account-key JSON can override the data-plane URL (`gcs_base_url`) and the
|
||||
// OAuth token endpoint (`token_uri`); the GCS client connects to whatever they point at.
|
||||
@@ -492,10 +517,15 @@ async fn validate_public_endpoint(endpoint: &str) -> error::Result<()> {
|
||||
// attempts (a name resolving to both a public and a private address).
|
||||
for addr in addrs {
|
||||
if is_forbidden_ip(addr.ip()) {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing object storage at a private, loopback, or link-local endpoint requires a super admin"
|
||||
.to_string(),
|
||||
));
|
||||
// The resolved address stays out of the message: it is the server's resolver's
|
||||
// answer, and this message is only ever shown to the caller being constrained.
|
||||
return Err(error::Error::NotAuthorized(format!(
|
||||
"Testing object storage at '{host}', which resolves to a private, loopback, or \
|
||||
link-local address, requires a super admin: this test runs on the Windmill \
|
||||
server, which is not allowed to probe internal addresses for non-super-admins. \
|
||||
Ask a super admin to run it, or test the resource from a script, which runs on \
|
||||
a worker."
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -2008,7 +2038,10 @@ struct CachedResourceType {
|
||||
/// decodes the on-disk cache, where an absent key means "written before the
|
||||
/// column, leave the stored extension alone" and an explicit null means the hub
|
||||
/// dropped it. Plain serde folds both into `None`.
|
||||
#[serde(default, deserialize_with = "windmill_common::more_serde::double_option")]
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "windmill_common::more_serde::double_option"
|
||||
)]
|
||||
format_extension: Option<Option<String>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1239,6 +1239,7 @@ async fn leave_instance(Extension(db): Extension<DB>, authed: ApiAuthed) -> Resu
|
||||
sqlx::query!("DELETE FROM password WHERE email = $1", &authed.email)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &authed.email).await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -1661,6 +1662,7 @@ async fn delete_user(
|
||||
sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &email_to_delete).await?;
|
||||
|
||||
let usernames = sqlx::query_scalar!(
|
||||
"DELETE FROM usr WHERE email = $1 RETURNING username",
|
||||
@@ -1869,7 +1871,7 @@ async fn change_user_email(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// ---- account ---- (draft.email follows through its ON UPDATE CASCADE fkey)
|
||||
// ---- account ----
|
||||
sqlx::query!(
|
||||
"UPDATE password SET email = $1 WHERE email = $2",
|
||||
&new_email,
|
||||
@@ -1883,6 +1885,7 @@ async fn change_user_email(
|
||||
}
|
||||
_ => e.into(),
|
||||
})?;
|
||||
windmill_common::user_drafts::rename_drafts_of_email(&mut *tx, &old_email, &new_email).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE usr SET email = $1 WHERE email = $2",
|
||||
@@ -3539,6 +3542,9 @@ async fn overwrite_global_users(
|
||||
require_super_admin(&db, &authed).await?;
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
// Replaces the account table, so — unlike the paths that remove one account — it deliberately
|
||||
// does not call `delete_drafts_of_email`: the addresses are about to be reinstated, and
|
||||
// dropping every draft on the instance to restore accounts would be pure collateral.
|
||||
sqlx::query!("DELETE FROM password")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -12,6 +12,7 @@ use windmill_api_auth::{
|
||||
};
|
||||
use windmill_api_users::users::WorkspaceInvite;
|
||||
use windmill_common::email_oss::send_email_if_possible;
|
||||
use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace;
|
||||
use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME};
|
||||
use windmill_common::webhook::WebhookShared;
|
||||
use windmill_common::{BASE_URL, DB};
|
||||
@@ -5799,9 +5800,8 @@ async fn clone_workspace_data(
|
||||
// Clone the forker's own per-user drafts (plus the legacy NULL-email
|
||||
// workspace draft, if any) so they keep their pending edits in the
|
||||
// fork. Other users' drafts are intentionally NOT cloned — they don't
|
||||
// own a `usr` row in the fork (see `clone_workspace_full`) so their
|
||||
// drafts would dangle and the home-page `draft_users` aggregate would
|
||||
// surface them as duplicate legacy entries.
|
||||
// own a `usr` row in the fork (see `clone_workspace_full`), so those
|
||||
// drafts would belong to someone the fork holds no membership for.
|
||||
clone_drafts(tx, source_workspace_id, target_workspace_id, &authed.email).await?;
|
||||
|
||||
// Clone workspace runnable dependencies and dependency map
|
||||
@@ -7140,7 +7140,16 @@ async fn clone_workspace_runnable_dependencies(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// Clone dependency_map to preserve import relationships
|
||||
// Recorded so the clone's own relocks have something to match; with no row they record NULL
|
||||
// and nothing in it ever skips. Hashed from the locks the clone holds rather than copied from
|
||||
// the source's rows, which are only as current as the last write to them: one left stale by a
|
||||
// supplied lock deployed before this was recorded names a lock the clone no longer has, and an
|
||||
// importer that resolved against the real one would then skip a relock it needed.
|
||||
record_lock_hashes_for_workspace(tx, target_workspace_id).await?;
|
||||
|
||||
// Deliberately without `imported_lockfile_hash`: it records what an importer resolved against
|
||||
// when it was last locked, which nothing here can establish for the version the clone got.
|
||||
// Left NULL, every importer relocks once and re-anchors both sides to what the clone holds.
|
||||
sqlx::query!(
|
||||
"INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id)
|
||||
SELECT $1, importer_path, importer_kind, imported_path, importer_node_id
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.800.1
|
||||
version: 1.803.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -10456,7 +10456,7 @@ paths:
|
||||
summary: run script by path
|
||||
operationId: runScriptByPath
|
||||
x-mcp-tool: true
|
||||
x-mcp-instructions: "You should first use getScriptByPath to retrieve the script's schema and understand what arguments are expected."
|
||||
x-mcp-instructions: "You should first use getScriptByPath to retrieve the script's schema and understand what arguments are expected. A resource argument (schema format `resource-<type>`) is the bare string `$res:<path>` as that whole argument value -- not a wrapper object, not a plain path. A variable is `$var:<path>`."
|
||||
x-mcp-tool-include-query-params: []
|
||||
tags:
|
||||
- job
|
||||
@@ -13814,7 +13814,7 @@ paths:
|
||||
summary: run flow by path
|
||||
operationId: runFlowByPath
|
||||
x-mcp-tool: true
|
||||
x-mcp-instructions: "You should first use getFlowByPath to retrieve the flow's schema and understand what arguments are expected."
|
||||
x-mcp-instructions: "You should first use getFlowByPath to retrieve the flow's schema and understand what arguments are expected. A resource argument (schema format `resource-<type>`) is the bare string `$res:<path>` as that whole argument value -- not a wrapper object, not a plain path. A variable is `$var:<path>`."
|
||||
x-mcp-tool-include-query-params: []
|
||||
tags:
|
||||
- job
|
||||
@@ -27135,6 +27135,13 @@ components:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/ModelPriceOverride"
|
||||
copilot_disabled:
|
||||
type: boolean
|
||||
description: >-
|
||||
Hides the Windmill AI assistant (chat, sessions, code generation, completion,
|
||||
fixes) from the workspace UI. Read from the workspace's own settings even when
|
||||
the providers served fall back to the instance config. AI agent steps and the
|
||||
AI sandbox in flows are unaffected.
|
||||
|
||||
FreeTierInfo:
|
||||
type: object
|
||||
@@ -28987,6 +28994,8 @@ components:
|
||||
- "igroup.delete"
|
||||
- "igroup.adduser"
|
||||
- "igroup.removeuser"
|
||||
- "instance_groups.jit_adduser"
|
||||
- "instance_groups.jit_removeuser"
|
||||
- "variables.decrypt_secret"
|
||||
- "workspaces.read_encryption_key"
|
||||
- "workspaces.edit_command_script"
|
||||
|
||||
@@ -445,6 +445,12 @@ pub struct AIConfig {
|
||||
/// Only models whose rates differ from the built-in table are stored.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_pricing: Option<HashMap<String, ModelPriceOverride>>,
|
||||
/// Hides the Windmill AI assistant (chat, sessions, generation, completion, fixes) from
|
||||
/// the workspace UI. Only the workspace's own row is consulted: the flag holds even when
|
||||
/// the providers served come from the instance config or the free tier. AI agent steps
|
||||
/// and the AI sandbox are unaffected, so the providers stay in force.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub copilot_disabled: bool,
|
||||
}
|
||||
|
||||
/// Negotiated rates in USD per million tokens. An unset cache rate is read as the
|
||||
|
||||
@@ -491,7 +491,8 @@ async fn list_apps(
|
||||
FROM draft d \
|
||||
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
|
||||
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
|
||||
WHERE d.workspace_id = app.workspace_id AND d.path = app.path AND d.typ IN ('app', 'raw_app')) as draft_users",
|
||||
WHERE d.workspace_id = app.workspace_id AND d.path = app.path AND d.typ IN ('app', 'raw_app') \
|
||||
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)) as draft_users",
|
||||
"folder_labels(app.workspace_id, app.path) as inherited_labels",
|
||||
])
|
||||
.left()
|
||||
|
||||
@@ -214,6 +214,9 @@ fn list_drafts_query(all_users: bool) -> String {
|
||||
// row: fall back to their instance-derived username (`password.username`), or
|
||||
// their email when derivation is disabled (`password.username` is NULL). This
|
||||
// keeps the raw email out of the payload whenever a derived username exists.
|
||||
// A null username means the legacy row downstream, so an owner that resolves to
|
||||
// no name at all — an external JWT's subject has neither row — is dropped rather
|
||||
// than surfaced as a second legacy entry.
|
||||
let draft_users = r#"CASE WHEN d.typ::text IN ('script', 'flow', 'app', 'raw_app') THEN (
|
||||
SELECT json_agg(json_build_object('username', COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN du.email END))
|
||||
ORDER BY COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN du.email END) NULLS LAST)
|
||||
@@ -221,6 +224,7 @@ fn list_drafts_query(all_users: bool) -> String {
|
||||
LEFT JOIN usr u ON u.workspace_id = du.workspace_id AND u.email = du.email
|
||||
LEFT JOIN password p ON p.email = du.email AND p.super_admin = true
|
||||
WHERE du.workspace_id = d.workspace_id AND du.path = d.path AND du.typ = d.typ
|
||||
AND (du.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)
|
||||
) ELSE NULL END"#;
|
||||
// Default lists the user's own drafts AND the legacy NULL-email rows; with
|
||||
// `all_users` the filter is dropped to list every workspace draft.
|
||||
|
||||
@@ -18,6 +18,7 @@ use quick_cache::sync::Cache;
|
||||
use serde_json::value::RawValue;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -108,6 +109,7 @@ use windmill_common::{
|
||||
flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue},
|
||||
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
|
||||
oauth2::HmacSha256,
|
||||
query_builders,
|
||||
scripts::{ScriptHash, ScriptLang},
|
||||
users::username_to_permissioned_as,
|
||||
utils::{not_found_if_none, now_from_db, paginate, require_admin, Pagination, StripPath},
|
||||
@@ -8131,6 +8133,79 @@ pub async fn run_wait_result_flow_by_version(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Whether request-supplied SQL from an operator may run. Operators can only run deployed
|
||||
/// code, so a request their job token (`WM_TOKEN`) authenticates comes from code a
|
||||
/// non-operator authored. The job must still be running, and the request must have the
|
||||
/// shape `wmill.datatable()` sends (PostgreSQL against a `datatable://` database), so a
|
||||
/// WM_TOKEN that leaked into job logs cannot be replayed to reach another target while the
|
||||
/// job lives, in particular DuckDB, which runs in-process in the worker.
|
||||
///
|
||||
/// What it does permit is any statement against the workspace's data tables, writes and DDL
|
||||
/// included: the helper's body is an unrestricted SQL template and data tables carry no
|
||||
/// per-user ACL. Narrowing that is a separate decision from this exemption.
|
||||
///
|
||||
/// The database argument is only half the target: the executor honors a `-- database`
|
||||
/// directive in the SQL over it, and `-- s3` redirects the result set, so both are refused.
|
||||
/// Check them against the code the executor runs rather than the request's `content`, which
|
||||
/// is not the same string once a `WM_INTERNAL_DB` marker expands.
|
||||
async fn operator_may_run_datatable_query(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
job_id: Option<Uuid>,
|
||||
language: Option<&ScriptLang>,
|
||||
content: &str,
|
||||
args: Option<&HashMap<String, Box<JsonRawValue>>>,
|
||||
) -> error::Result<bool> {
|
||||
let Some(job_id) = job_id else {
|
||||
return Ok(false);
|
||||
};
|
||||
if language != Some(&ScriptLang::Postgresql) {
|
||||
return Ok(false);
|
||||
}
|
||||
// Parse the directives out of the code the executor actually runs: it expands a
|
||||
// `WM_INTERNAL_DB` marker first, and a directive can be embedded in the expansion.
|
||||
// An expansion that overrides the language would run something other than the SQL the
|
||||
// language check above cleared, so it is refused along with a malformed marker.
|
||||
let executed =
|
||||
match query_builders::try_expand_internal_db_query(content, &ScriptLang::Postgresql) {
|
||||
Some(Ok(expanded)) if expanded.language_override.is_none() => Cow::Owned(expanded.code),
|
||||
Some(_) => return Ok(false),
|
||||
None => Cow::Borrowed(content),
|
||||
};
|
||||
if windmill_parser_sql::parse_db_resource(&executed).is_some()
|
||||
|| !matches!(windmill_parser_sql::parse_s3_mode(&executed), Ok(None))
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let targets_datatable = args
|
||||
.and_then(|args| args.get("database"))
|
||||
.and_then(|database| serde_json::from_str::<String>(database.get()).ok())
|
||||
.is_some_and(|database| database.starts_with("datatable://"));
|
||||
if !targets_datatable {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(sqlx::query_scalar!(
|
||||
"SELECT running AS \"running!\" FROM v2_job_queue WHERE id = $1 AND workspace_id = $2",
|
||||
job_id,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// The refusal an operator gets from a preview route. Inside a job the caller never ran a
|
||||
/// preview themselves, so name the one thing the job's token may do.
|
||||
fn operator_preview_refusal(job_id: Option<Uuid>) -> error::Error {
|
||||
let reason = if job_id.is_some() {
|
||||
"Operators cannot run preview jobs for security reasons: from inside a job, an \
|
||||
operator may only run a wmill.datatable() query while that job is running"
|
||||
} else {
|
||||
"Operators cannot run preview jobs for security reasons"
|
||||
};
|
||||
error::Error::NotAuthorized(reason.to_string())
|
||||
}
|
||||
|
||||
async fn run_preview_script(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -8142,9 +8217,20 @@ async fn run_preview_script(
|
||||
#[cfg(feature = "enterprise")]
|
||||
check_license_key_valid().await?;
|
||||
if authed.is_operator {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Operators cannot run preview jobs for security reasons".to_string(),
|
||||
));
|
||||
// A deferred run would outlive the running job the exemption keys off.
|
||||
if run_query.get_scheduled_for(&db).await?.is_some()
|
||||
|| !operator_may_run_datatable_query(
|
||||
&db,
|
||||
&w_id,
|
||||
authed.job_id,
|
||||
preview.language.as_ref(),
|
||||
preview.content.as_deref().unwrap_or_default(),
|
||||
preview.args.as_ref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(operator_preview_refusal(authed.job_id));
|
||||
}
|
||||
}
|
||||
// Preview runs arbitrary, request-supplied code. require_path_read_access_for_preview
|
||||
// only checks folder/namespace *read* access (and is a no-op when path is null), so a
|
||||
@@ -8239,13 +8325,21 @@ async fn run_inline_preview_script(
|
||||
Path(w_id): Path<String>,
|
||||
Json(preview): Json<PreviewInline>,
|
||||
) -> error::Result<Response> {
|
||||
// Same arbitrary-code class as run_preview_script: operators are blocked from
|
||||
// running request-supplied code, and a narrowly-scoped token must not escape
|
||||
// its scope through inline preview.
|
||||
if authed.is_operator {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Operators cannot run preview jobs for security reasons".to_string(),
|
||||
));
|
||||
// Same arbitrary-code class as run_preview_script, and every worker and standalone
|
||||
// server exposes this route, so an operator is refused on the same terms. A
|
||||
// narrowly-scoped token must not escape its scope through inline preview either.
|
||||
if authed.is_operator
|
||||
&& !operator_may_run_datatable_query(
|
||||
&db,
|
||||
&w_id,
|
||||
job_id,
|
||||
Some(&preview.language),
|
||||
&preview.content,
|
||||
preview.args.as_ref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(operator_preview_refusal(job_id));
|
||||
}
|
||||
check_scopes(&authed, || format!("jobs:run"))?;
|
||||
if let Some(job_id) = job_id {
|
||||
|
||||
@@ -452,15 +452,15 @@ pub async fn run_server(
|
||||
// unless they are allowed — hence a separate layer rather than widening the
|
||||
// one every other route shares. (`Mcp-Param-*` is only sent for tool inputs
|
||||
// annotated with `x-mcp-header`, which no tool here declares.)
|
||||
//
|
||||
// The request's own header list is mirrored rather than enumerated: a browser
|
||||
// MCP client may send any custom name for a preprocessor to read, and no fixed
|
||||
// list could cover them. Nothing is granted by echoing it: the origin is
|
||||
// `Any`, so browsers never attach credentials, and the endpoint authenticates
|
||||
// each request on its own.
|
||||
let mcp_cors = CorsLayer::new()
|
||||
.allow_methods([http::Method::GET, http::Method::POST, http::Method::DELETE])
|
||||
.allow_headers([
|
||||
http::header::CONTENT_TYPE,
|
||||
http::header::AUTHORIZATION,
|
||||
http::HeaderName::from_static("mcp-protocol-version"),
|
||||
http::HeaderName::from_static("mcp-method"),
|
||||
http::HeaderName::from_static("mcp-name"),
|
||||
])
|
||||
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
|
||||
// The 401 challenge is how a client discovers where to authorize (RFC 9728),
|
||||
// and it is not a safelisted response header, so without this a browser
|
||||
// client sees an empty one and has no way to begin the OAuth flow.
|
||||
|
||||
@@ -878,7 +878,7 @@ is, a different one moves it there and archives the old path"),
|
||||
EndpointTool {
|
||||
name: Cow::Borrowed("runScriptByPath"),
|
||||
description: Cow::Borrowed("run script by path"),
|
||||
instructions: Cow::Borrowed("You should first use getScriptByPath to retrieve the script's schema and understand what arguments are expected."),
|
||||
instructions: Cow::Borrowed("You should first use getScriptByPath to retrieve the script's schema and understand what arguments are expected. A resource argument (schema format `resource-<type>`) is the bare string `$res:<path>` as that whole argument value -- not a wrapper object, not a plain path. A variable is `$var:<path>`."),
|
||||
path: Cow::Borrowed("/w/{workspace}/jobs/run/p/{path}"),
|
||||
method: Cow::Borrowed("POST"),
|
||||
path_params_schema: Some(serde_json::json!({
|
||||
@@ -1419,7 +1419,7 @@ is, a different one moves it there and archives the old path"),
|
||||
EndpointTool {
|
||||
name: Cow::Borrowed("runFlowByPath"),
|
||||
description: Cow::Borrowed("run flow by path"),
|
||||
instructions: Cow::Borrowed("You should first use getFlowByPath to retrieve the flow's schema and understand what arguments are expected."),
|
||||
instructions: Cow::Borrowed("You should first use getFlowByPath to retrieve the flow's schema and understand what arguments are expected. A resource argument (schema format `resource-<type>`) is the bare string `$res:<path>` as that whole argument value -- not a wrapper object, not a plain path. A variable is `$var:<path>`."),
|
||||
path: Cow::Borrowed("/w/{workspace}/jobs/run/f/{path}"),
|
||||
method: Cow::Borrowed("POST"),
|
||||
path_params_schema: Some(serde_json::json!({
|
||||
|
||||
@@ -12,7 +12,9 @@ use windmill_mcp::common::transform::transform_property_keys;
|
||||
use windmill_mcp::common::types::{
|
||||
FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, WorkspaceInfo,
|
||||
};
|
||||
use windmill_mcp::server::{BackendResult, EndpointTool, ErrorData, McpBackend, PathFilter};
|
||||
use windmill_mcp::server::{
|
||||
BackendResult, EndpointTool, ErrorData, McpBackend, McpRequest, PathFilter,
|
||||
};
|
||||
|
||||
use crate::auth::AuthCache;
|
||||
use crate::db::ApiAuthed;
|
||||
@@ -214,8 +216,11 @@ impl McpBackend for WindmillBackend {
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
args: Value,
|
||||
request: &McpRequest<'_>,
|
||||
) -> BackendResult<Value> {
|
||||
let push_args = prepare_push_args(args);
|
||||
let push_args = prepare_push_args(&self.db, workspace_id, path, false, args, request)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
|
||||
let result = run_wait_result_script_by_path_internal(
|
||||
self.db.clone(),
|
||||
@@ -238,8 +243,11 @@ impl McpBackend for WindmillBackend {
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
args: Value,
|
||||
request: &McpRequest<'_>,
|
||||
) -> BackendResult<Value> {
|
||||
let push_args = prepare_push_args(args);
|
||||
let push_args = prepare_push_args(&self.db, workspace_id, path, true, args, request)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
|
||||
let result = run_wait_result_flow_by_path_internal(
|
||||
self.db.clone(),
|
||||
|
||||
@@ -11,15 +11,19 @@ use serde_json::Value;
|
||||
use sql_builder::prelude::*;
|
||||
use windmill_common::auth::create_jwt_token;
|
||||
use windmill_common::db::{Authed, UserDB};
|
||||
use windmill_common::error::Error;
|
||||
use windmill_common::scripts::{get_full_hub_script_by_path, Schema};
|
||||
use windmill_common::triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind};
|
||||
use windmill_common::utils::{query_elems_from_hub, StripPath};
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_common::{DB, HUB_BASE_URL};
|
||||
use windmill_mcp::server::{
|
||||
non_empty_body_fields, BackendResult, EndpointTool, ErrorData, PathFilter,
|
||||
non_empty_body_fields, BackendResult, EndpointTool, ErrorData, McpRequest, PathFilter,
|
||||
};
|
||||
use windmill_mcp::{HubResponse, HubScriptInfo, ItemSchema, ResourceInfo, ResourceType};
|
||||
use windmill_trigger::trigger_helpers::{get_runnable_format, RunnableId};
|
||||
|
||||
use crate::args::build_headers;
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::HTTP_CLIENT;
|
||||
|
||||
@@ -641,7 +645,7 @@ fn selects_endpoint_tool(caller_scopes: &[String], tool: &str) -> bool {
|
||||
.is_ok_and(|config| config.endpoints.iter().any(|e| e == tool))
|
||||
}
|
||||
|
||||
/// Create HTTP request with authentication
|
||||
/// Create HTTP request with authentication.
|
||||
pub async fn create_http_request(
|
||||
method: &str,
|
||||
url: &str,
|
||||
@@ -702,17 +706,113 @@ pub async fn create_http_request(
|
||||
.map_err(|e| ErrorData::internal_error(format!("Failed to execute request: {}", e), None))
|
||||
}
|
||||
|
||||
/// Convert a JSON Value into PushArgsOwned for job execution
|
||||
pub fn prepare_push_args(args: Value) -> windmill_queue::PushArgsOwned {
|
||||
/// The `kind` an MCP-invoked runnable sees on its preprocessor event, alongside
|
||||
/// `webhook`, `http` and the trigger kinds.
|
||||
const MCP_TRIGGER_KEY: &str = "mcp";
|
||||
|
||||
/// A preprocessor's view of the MCP request that ran it. Mirrors the HTTP
|
||||
/// trigger event: `body` is what the model sent, everything else describes the
|
||||
/// call itself.
|
||||
#[derive(serde::Serialize)]
|
||||
struct McpPreprocessorEvent<'a> {
|
||||
kind: &'a str,
|
||||
body: Box<serde_json::value::RawValue>,
|
||||
headers: HashMap<String, Box<serde_json::value::RawValue>>,
|
||||
tool_name: &'a str,
|
||||
}
|
||||
|
||||
/// Headers withheld from a preprocessor because they authenticate the connection.
|
||||
///
|
||||
/// Not a security boundary: a webhook preprocessor receives all three. Withheld
|
||||
/// because nothing needs them yet, and releasing one later is additive while
|
||||
/// withdrawing one after runnables read it is not.
|
||||
const WITHHELD_FROM_PREPROCESSOR: &[&str] = &["authorization", "cookie", "proxy-authorization"];
|
||||
|
||||
/// Every header a preprocessor may see.
|
||||
fn preprocessor_headers(
|
||||
headers: &http::HeaderMap,
|
||||
) -> HashMap<String, Box<serde_json::value::RawValue>> {
|
||||
let mut selected = build_headers(headers, None, true);
|
||||
selected.retain(|name, _| {
|
||||
!WITHHELD_FROM_PREPROCESSOR
|
||||
.iter()
|
||||
.any(|withheld| withheld.eq_ignore_ascii_case(name))
|
||||
});
|
||||
selected
|
||||
}
|
||||
|
||||
/// Build the job arguments for a script or flow run as an MCP tool.
|
||||
///
|
||||
/// Shaped by the runnable's own format: a preprocessor receives the request as
|
||||
/// an event, and a runnable without one receives only what the model sent.
|
||||
pub async fn prepare_push_args(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
is_flow: bool,
|
||||
args: Value,
|
||||
request: &McpRequest<'_>,
|
||||
) -> Result<windmill_queue::PushArgsOwned, Error> {
|
||||
let mut main_args = HashMap::new();
|
||||
if let Value::Object(map) = args {
|
||||
let mut args_hash = HashMap::new();
|
||||
for (k, v) in map {
|
||||
args_hash.insert(k, to_raw_value(&v));
|
||||
main_args.insert(k, to_raw_value(&v));
|
||||
}
|
||||
windmill_queue::PushArgsOwned { extra: None, args: args_hash }
|
||||
} else {
|
||||
windmill_queue::PushArgsOwned::default()
|
||||
}
|
||||
|
||||
let runnable_id = if is_flow {
|
||||
RunnableId::from_flow_path(path)
|
||||
} else {
|
||||
// Resolves a `hub/<version_id>` path to the hub script on its own.
|
||||
RunnableId::from_script_path(path)
|
||||
};
|
||||
|
||||
// MCP is not one of the `TRIGGER_KIND` enum values and does not need to be:
|
||||
// the per-kind arms of the no-preprocessor heuristic are payload-shape
|
||||
// special cases for message triggers, and `Webhook` reaches the same generic
|
||||
// arm MCP wants while sharing that kind's format cache.
|
||||
let runnable_format = get_runnable_format(runnable_id, w_id, db, &TriggerKind::Webhook).await?;
|
||||
|
||||
Ok(match runnable_format {
|
||||
// Without a preprocessor there is nowhere for a header to go that the
|
||||
// model does not also write: its arguments *are* the runnable's
|
||||
// parameters, so a header bound to one of them would be a value the model
|
||||
// could set. The request is reachable through a preprocessor, where it
|
||||
// arrives in a key of the event the model never fills.
|
||||
RunnableFormat { has_preprocessor: false, .. } => {
|
||||
windmill_queue::PushArgsOwned { args: main_args, extra: None }
|
||||
}
|
||||
RunnableFormat { has_preprocessor: true, version } => {
|
||||
let headers = preprocessor_headers(request.headers);
|
||||
match version {
|
||||
RunnableFormatVersion::V2 => {
|
||||
let event = McpPreprocessorEvent {
|
||||
kind: MCP_TRIGGER_KEY,
|
||||
body: to_raw_value(&main_args),
|
||||
headers,
|
||||
tool_name: request.tool_name,
|
||||
};
|
||||
windmill_queue::PushArgsOwned {
|
||||
args: HashMap::from([("event".to_string(), to_raw_value(&event))]),
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
RunnableFormatVersion::V1 => windmill_queue::PushArgsOwned {
|
||||
args: main_args,
|
||||
extra: Some(HashMap::from([(
|
||||
"wm_trigger".to_string(),
|
||||
to_raw_value(&serde_json::json!({
|
||||
"kind": MCP_TRIGGER_KEY,
|
||||
MCP_TRIGGER_KEY: {
|
||||
"headers": headers,
|
||||
"tool_name": request.tool_name,
|
||||
}
|
||||
})),
|
||||
)])),
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse an HTTP response body into a JSON Value
|
||||
|
||||
@@ -618,6 +618,7 @@ pub(crate) async fn offboard_global_user(
|
||||
sqlx::query!("DELETE FROM password WHERE email = $1", &email)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &email).await?;
|
||||
sqlx::query!("DELETE FROM workspace_invite WHERE email = $1", &email)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -261,7 +261,8 @@ fn branch_sqls() -> Branches {
|
||||
FROM draft d \
|
||||
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
|
||||
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
|
||||
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND {typ_pred}) as draft_users"
|
||||
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND {typ_pred} \
|
||||
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)) as draft_users"
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ async fn edit_copilot_config(
|
||||
.await?;
|
||||
|
||||
let workspace_has_config = ai_config.has_providers();
|
||||
let copilot_disabled = ai_config.copilot_disabled;
|
||||
let instance_ai_config =
|
||||
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
|
||||
.fetch_optional(&db)
|
||||
@@ -158,7 +159,7 @@ async fn edit_copilot_config(
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
|
||||
.filter(|c| c.has_providers());
|
||||
let effective_ai_config = if workspace_has_config {
|
||||
let mut effective_ai_config = if workspace_has_config {
|
||||
ai_config
|
||||
} else if let Some(instance_config) = instance_config_with_providers {
|
||||
instance_config
|
||||
@@ -172,6 +173,7 @@ async fn edit_copilot_config(
|
||||
} else {
|
||||
AIConfig::default()
|
||||
};
|
||||
effective_ai_config.copilot_disabled = copilot_disabled;
|
||||
|
||||
Ok(Json(EditCopilotConfigResponse {
|
||||
effective_ai_config,
|
||||
@@ -207,6 +209,9 @@ async fn get_copilot_info(
|
||||
))
|
||||
})?;
|
||||
|
||||
let copilot_disabled = workspace_ai_config
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.0.copilot_disabled);
|
||||
let instance_config =
|
||||
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
|
||||
.fetch_optional(&db)
|
||||
@@ -215,20 +220,23 @@ async fn get_copilot_info(
|
||||
// A provider-less instance config (e.g. `{}`) is unconfigured; don't let it shadow the
|
||||
// free-tier fallback, matching the proxy and edit_copilot_config paths.
|
||||
.filter(|c| c.has_providers());
|
||||
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
|
||||
Ok(Json(workspace_ai_config.0))
|
||||
} else if let Some(instance_config) = instance_config {
|
||||
Ok(Json(instance_config))
|
||||
} else if let Some(free_config) =
|
||||
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
|
||||
{
|
||||
// Nothing configured: fall back to Windmill's free tier (EE-only). The config
|
||||
// carries a `free_tier` marker even once the user's grant is spent — with no
|
||||
// providers, but telling the client *why* AI is off.
|
||||
Ok(Json(free_config))
|
||||
} else {
|
||||
Ok(Json(AIConfig::default()))
|
||||
}
|
||||
let mut effective =
|
||||
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
|
||||
workspace_ai_config.0
|
||||
} else if let Some(instance_config) = instance_config {
|
||||
instance_config
|
||||
} else if let Some(free_config) =
|
||||
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
|
||||
{
|
||||
// Nothing configured: fall back to Windmill's free tier (EE-only). The config
|
||||
// carries a `free_tier` marker even once the user's grant is spent — with no
|
||||
// providers, but telling the client *why* AI is off.
|
||||
free_config
|
||||
} else {
|
||||
AIConfig::default()
|
||||
};
|
||||
effective.copilot_disabled = copilot_disabled;
|
||||
Ok(Json(effective))
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
|
||||
@@ -94,6 +94,9 @@ pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation
|
||||
pub const DISABLE_WORKSPACE_INVITE_EMAILS_SETTING: &str = "disable_workspace_invite_emails";
|
||||
pub const DISABLE_PASSWORD_LOGIN_SETTING: &str = "disable_password_login";
|
||||
pub const AUTO_LOGIN_PROVIDER_SETTING: &str = "auto_login_provider";
|
||||
/// Name of the SAML attribute or OIDC userinfo claim carrying the user's IdP groups. Unset or
|
||||
/// empty leaves instance-group membership entirely to SCIM.
|
||||
pub const SSO_GROUPS_CLAIM_SETTING: &str = "sso_groups_claim";
|
||||
pub const HUB_BASE_URL_SETTING: &str = "hub_base_url";
|
||||
pub const HUB_ACCESSIBLE_URL_SETTING: &str = "hub_accessible_url";
|
||||
pub const DISABLE_HUB_SETTING: &str = "disable_hub";
|
||||
@@ -108,6 +111,7 @@ pub const JWT_SECRET_SETTING: &str = "jwt_secret";
|
||||
pub const EMAIL_DOMAIN_SETTING: &str = "email_domain";
|
||||
pub const OTEL_SETTING: &str = "otel";
|
||||
pub const OTEL_TRACING_PROXY_SETTING: &str = "otel_tracing_proxy";
|
||||
pub const OTEL_TRACES_RETENTION_SECS_SETTING: &str = "otel_traces_retention_secs";
|
||||
pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route";
|
||||
pub const HTTP_ROUTE_WORKSPACED_ROUTE_SETTING: &str = "http_route_workspaced_route";
|
||||
pub const SECRET_BACKEND_SETTING: &str = "secret_backend";
|
||||
|
||||
@@ -148,43 +148,76 @@ pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5;
|
||||
pub const DEFAULT_HUB_BASE_URL: &str = "https://hub.windmill.dev";
|
||||
pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000;
|
||||
pub const DEFAULT_SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs
|
||||
pub const DEFAULT_OTEL_TRACES_RETENTION_SECS: i64 = 60 * 60 * 24 * 7; // 1 week retention period for HTTP request spans
|
||||
pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers";
|
||||
|
||||
/// A century. Every consumer has to survive `now - retention`, and the ceilings are much lower
|
||||
/// than an `i64`: `DateTime` subtraction panics past year 262143, and the `(<n> s)::interval`
|
||||
/// the cleanup queries build overflows Postgres' microsecond field.
|
||||
const MAX_SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 365 * 100;
|
||||
const MAX_RETENTION_SECS: i64 = 60 * 60 * 24 * 365 * 100;
|
||||
|
||||
/// Apply a configured service log retention, in seconds.
|
||||
/// Clamp a configured retention window, in seconds, to one a cutoff can be built from.
|
||||
///
|
||||
/// The only way into [`SERVICE_LOG_RETENTION_SECS`], so an unusable value can never reach a
|
||||
/// cutoff. The two unusable directions are not the same mistake and must not share a landing
|
||||
/// point: too large still says "keep these for a very long time", so it is capped and the
|
||||
/// intent survives, whereas falling back would delete logs the operator meant to keep. A
|
||||
/// non-positive value has no such reading — every cutoff is `now - retention`, so it lands at
|
||||
/// or after `now` and the next sweep expires the entire history, rows and object-storage files
|
||||
/// alike. Unlike job retention there is no "keep forever" spelling here, so `0` — what an
|
||||
/// operator types by analogy with it, and what the settings UI writes into a field that was
|
||||
/// merely focused — falls back to the default.
|
||||
pub fn set_service_log_retention_secs(configured: i64) {
|
||||
let effective = if configured > MAX_SERVICE_LOG_RETENTION_SECS {
|
||||
/// Shared by the retention windows that have no "keep forever" spelling, so that an unusable
|
||||
/// value can never reach a cutoff. The two unusable directions are not the same mistake and must
|
||||
/// not share a landing point: too large still says "keep these for a very long time", so it is
|
||||
/// capped and the intent survives, whereas falling back would delete data the operator meant to
|
||||
/// keep. A non-positive value has no such reading — every cutoff is `now - retention`, so it
|
||||
/// lands at or after `now` and the next sweep expires the entire history. `0` is both what an
|
||||
/// operator types by analogy with job retention, where it does mean keep forever, and what the
|
||||
/// settings UI writes into a field that was merely focused, so it falls back to the default.
|
||||
fn clamp_retention_secs(configured: i64, default: i64, what: &str) -> i64 {
|
||||
if configured > MAX_RETENTION_SECS {
|
||||
tracing::warn!(
|
||||
"service log retention of {configured}s exceeds the maximum of \
|
||||
{MAX_SERVICE_LOG_RETENTION_SECS}s, capping it there"
|
||||
"{what} retention of {configured}s exceeds the maximum of {MAX_RETENTION_SECS}s, \
|
||||
capping it there"
|
||||
);
|
||||
MAX_SERVICE_LOG_RETENTION_SECS
|
||||
MAX_RETENTION_SECS
|
||||
} else if configured >= 1 {
|
||||
configured
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"service log retention of {configured}s would expire every service log, \
|
||||
falling back to the default of {DEFAULT_SERVICE_LOG_RETENTION_SECS}s"
|
||||
"{what} retention of {configured}s would expire the entire history, \
|
||||
falling back to the default of {default}s"
|
||||
);
|
||||
DEFAULT_SERVICE_LOG_RETENTION_SECS
|
||||
};
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a configured service log retention, in seconds.
|
||||
///
|
||||
/// The only way into [`SERVICE_LOG_RETENTION_SECS`]. Expiry reaches every copy of a log line:
|
||||
/// the row, the file on disk, and the object-storage object.
|
||||
pub fn set_service_log_retention_secs(configured: i64) {
|
||||
let effective = clamp_retention_secs(
|
||||
configured,
|
||||
DEFAULT_SERVICE_LOG_RETENTION_SECS,
|
||||
"service log",
|
||||
);
|
||||
SERVICE_LOG_RETENTION_SECS.store(effective, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Apply a configured OTEL trace retention, in seconds.
|
||||
///
|
||||
/// The only way into [`OTEL_TRACES_RETENTION_SECS`].
|
||||
pub fn set_otel_traces_retention_secs(configured: i64) {
|
||||
let effective = clamp_retention_secs(
|
||||
configured,
|
||||
DEFAULT_OTEL_TRACES_RETENTION_SECS,
|
||||
"otel traces",
|
||||
);
|
||||
OTEL_TRACES_RETENTION_SECS.store(effective, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// How long an HTTP request tracing span stays in `otel_traces`, in seconds.
|
||||
///
|
||||
/// Spans are keyed by the job they were captured for and read back by the job detail view, so
|
||||
/// this is the outer bound on how far back that view can show a job's HTTP requests. It is
|
||||
/// independent of job retention: a span can outlive its job, or be swept while the job remains.
|
||||
pub fn otel_traces_retention_secs() -> i64 {
|
||||
OTEL_TRACES_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// How long a service log line stays retrievable, in seconds.
|
||||
///
|
||||
/// The outer bound on everything service-log: the `log_file` rows, the raw files in object
|
||||
@@ -423,6 +456,10 @@ lazy_static::lazy_static! {
|
||||
/// would expire every service log cannot reach a cutoff. Read it with
|
||||
/// [`service_log_retention_secs`].
|
||||
static ref SERVICE_LOG_RETENTION_SECS: AtomicI64 = AtomicI64::new(DEFAULT_SERVICE_LOG_RETENTION_SECS);
|
||||
/// Private on purpose, same as [`SERVICE_LOG_RETENTION_SECS`]:
|
||||
/// [`set_otel_traces_retention_secs`] is the only writer, [`otel_traces_retention_secs`] the
|
||||
/// only reader.
|
||||
static ref OTEL_TRACES_RETENTION_SECS: AtomicI64 = AtomicI64::new(DEFAULT_OTEL_TRACES_RETENTION_SECS);
|
||||
|
||||
pub static ref MONITOR_LOGS_ON_OBJECT_STORE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
|
||||
@@ -358,31 +358,38 @@ pub async fn fetch_script_for_update<'a>(
|
||||
.map_err(crate::error::Error::from)
|
||||
}
|
||||
|
||||
pub struct ClonedScript {
|
||||
pub old_script: NewScript,
|
||||
pub new_hash: i64,
|
||||
}
|
||||
// TODO: What if dependency job fails, there is script with NULL in the lock
|
||||
pub async fn clone_script<'c>(
|
||||
path: &str,
|
||||
w_id: &str,
|
||||
/// Deploys the outcome of a relative-import relock as a new version of `head`, the path's live
|
||||
/// version that the caller holds `FOR UPDATE`, and archives `head`. A `lock` of `None` records
|
||||
/// a failed generation: the version carries `lock_error_logs` instead and runs keep resolving
|
||||
/// to the last version that has a lock. A `modules` of `None` keeps the head's module locks.
|
||||
///
|
||||
/// Writes whatever `head` names and checks nothing: callers are responsible for having
|
||||
/// established access to its workspace and path, as a dependency job's push already has.
|
||||
///
|
||||
/// `created_at` is stamped when the insert runs, not at transaction start. The row lock on
|
||||
/// `head` is what orders one relock after another, and with `now()` a transaction that began
|
||||
/// first but locked second commits a live child older than its archived parent, which every
|
||||
/// "latest version" read then mis-orders.
|
||||
pub async fn deploy_relocked_version(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
head: Script<ScriptRunnableSettingsHandle>,
|
||||
deployment_message: Option<String>,
|
||||
db: &DB,
|
||||
) -> crate::error::Result<ClonedScript> {
|
||||
let mut tx = db.begin().await?;
|
||||
let s = if let Some(s) = fetch_script_for_update(path, w_id, &mut *tx).await? {
|
||||
s
|
||||
} else {
|
||||
return Err(crate::error::Error::NotFound(format!(
|
||||
"Non-archived script with path '{}' not found",
|
||||
path
|
||||
)));
|
||||
};
|
||||
lock: Option<&str>,
|
||||
modules: Option<&std::collections::HashMap<String, ScriptModule>>,
|
||||
lock_error_logs: Option<&str>,
|
||||
) -> crate::error::Result<i64> {
|
||||
let s = head;
|
||||
let w_id = s.workspace_id.as_str();
|
||||
|
||||
let rs = runnable_settings::from_handle(s.runnable_settings.runnable_settings_handle, &mut *tx)
|
||||
.await?;
|
||||
let rs =
|
||||
runnable_settings::from_handle(s.runnable_settings.runnable_settings_handle, &mut **tx)
|
||||
.await?;
|
||||
let (debouncing_settings, concurrency_settings) =
|
||||
runnable_settings::prefetch_cached_tx(&rs, &mut tx).await?;
|
||||
runnable_settings::prefetch_cached_tx(&rs, &mut *tx).await?;
|
||||
|
||||
// What the row stores is what the hash covers: the new module locks when there are any.
|
||||
let modules = modules.cloned().or(s.modules);
|
||||
let modules_json = modules.as_ref().map(serde_json::to_value).transpose()?;
|
||||
|
||||
let ns = NewScript {
|
||||
path: s.path.clone(),
|
||||
@@ -392,7 +399,7 @@ pub async fn clone_script<'c>(
|
||||
content: s.content,
|
||||
schema: s.schema,
|
||||
is_template: s.is_template,
|
||||
lock: None,
|
||||
lock: lock.map(str::to_string),
|
||||
language: s.language,
|
||||
kind: Some(s.kind),
|
||||
tag: s.tag,
|
||||
@@ -424,7 +431,7 @@ pub async fn clone_script<'c>(
|
||||
on_behalf_of: s.on_behalf_of,
|
||||
preserve_on_behalf_of: None,
|
||||
assets: s.assets,
|
||||
modules: s.modules,
|
||||
modules,
|
||||
auto_parent: None,
|
||||
labels: s.labels,
|
||||
skip_draft_deletion: None,
|
||||
@@ -433,7 +440,7 @@ pub async fn clone_script<'c>(
|
||||
let new_hash = hash_script(&ns);
|
||||
|
||||
tracing::debug!(
|
||||
"cloning script at path {} from '{}' to '{}'",
|
||||
"deploying relocked version of script at path {} from '{}' to '{}'",
|
||||
s.path,
|
||||
*s.hash,
|
||||
new_hash
|
||||
@@ -446,17 +453,19 @@ pub async fn clone_script<'c>(
|
||||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \
|
||||
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
|
||||
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \
|
||||
codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)
|
||||
codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels, \
|
||||
lock_error_logs, created_at)
|
||||
|
||||
SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \
|
||||
content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, \
|
||||
content, created_by, schema, is_template, extra_perms, $4::text, language, kind, tag, \
|
||||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \
|
||||
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
|
||||
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \
|
||||
codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels
|
||||
codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, COALESCE($5::jsonb, modules), labels, \
|
||||
$6::text, clock_timestamp()
|
||||
|
||||
FROM script WHERE hash = $2 AND workspace_id = $3;
|
||||
", new_hash, s.hash.0, w_id).execute(&mut *tx).await?;
|
||||
", new_hash, s.hash.0, w_id, lock, modules_json, lock_error_logs).execute(&mut **tx).await?;
|
||||
|
||||
// Archive base.
|
||||
sqlx::query!(
|
||||
@@ -464,9 +473,8 @@ pub async fn clone_script<'c>(
|
||||
*s.hash,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(ClonedScript { old_script: ns, new_hash })
|
||||
Ok(new_hash)
|
||||
}
|
||||
|
||||
@@ -245,7 +245,9 @@ async fn fetch_other_drafts_users(
|
||||
// row: fall back to their instance-derived username (`password.username`), or
|
||||
// their email when derivation is disabled. Else a real teammate's draft renders
|
||||
// as a phantom "Legacy draft". The genuine NULL-email legacy row keeps
|
||||
// `username = None` (no `usr`/`password` match and `d.email` is NULL).
|
||||
// `username = None` (no `usr`/`password` match and `d.email` is NULL), which is
|
||||
// why an owner that resolves to no name at all — an external JWT's subject has
|
||||
// neither row — is dropped instead: `None` is taken to mean "legacy" downstream.
|
||||
let rows = sqlx::query_as!(
|
||||
OtherDraftUser,
|
||||
r#"SELECT COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) as "username?",
|
||||
@@ -261,6 +263,7 @@ async fn fetch_other_drafts_users(
|
||||
AND d.path = $2
|
||||
AND d.typ = $3
|
||||
AND (d.email IS NULL OR d.email <> $4)
|
||||
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)
|
||||
ORDER BY d.email NULLS LAST"#,
|
||||
w_id,
|
||||
path,
|
||||
@@ -426,6 +429,67 @@ pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the drafts an address owns, across every workspace.
|
||||
///
|
||||
/// `draft.email` carries no foreign key to `password`: a draft's owner is any principal the
|
||||
/// instance authenticates, and an external JWT's subject never has a `password` row. Deleting an
|
||||
/// account is therefore what has to delete its drafts — a delete path that skips this leaves them
|
||||
/// behind forever, addressed to someone who no longer exists. Call it in the same transaction as
|
||||
/// the account removal.
|
||||
///
|
||||
/// No authorization of its own: it acts instance-wide on whatever address it is handed, so the
|
||||
/// caller must already have authorized removing that account (superadmin, the account's own
|
||||
/// holder, or SCIM).
|
||||
pub async fn delete_drafts_of_email<'c>(
|
||||
executor: impl sqlx::PgExecutor<'c>,
|
||||
email: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query!("DELETE FROM draft WHERE email = $1", email)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move the drafts an address owns onto its new address, for the same reason
|
||||
/// [`delete_drafts_of_email`] exists: no foreign key follows the rename, so drafts left behind are
|
||||
/// stranded on an address that no longer authenticates. Same authorization contract, for a rename.
|
||||
///
|
||||
/// The two addresses may each already hold a draft of the same item, since the destination can
|
||||
/// belong to a principal with no account and so is not covered by the caller's "address is free"
|
||||
/// check. `draft_pkey_with_user` admits only one, so the moving account's wins — which is also why
|
||||
/// a rename onto the same address returns early: every row would collide with itself and be
|
||||
/// cleared. Callers need not compare first (an IdP re-sending an unchanged `userName` does not).
|
||||
pub async fn rename_drafts_of_email(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
old_email: &str,
|
||||
new_email: &str,
|
||||
) -> Result<()> {
|
||||
if old_email == new_email {
|
||||
return Ok(());
|
||||
}
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft dest
|
||||
WHERE dest.email = $1
|
||||
AND EXISTS (SELECT 1 FROM draft src
|
||||
WHERE src.email = $2
|
||||
AND src.workspace_id = dest.workspace_id
|
||||
AND src.path = dest.path
|
||||
AND src.typ = dest.typ)",
|
||||
new_email,
|
||||
old_email
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE draft SET email = $1 WHERE email = $2",
|
||||
new_email,
|
||||
old_email
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete EVERY user's draft (and the legacy NULL-email row) at a path+kind.
|
||||
/// Use when the item is DELETED outright: it's gone for everyone, so leaving
|
||||
/// teammates' drafts behind would orphan them forever. Discarding one's OWN
|
||||
|
||||
@@ -175,7 +175,7 @@ pub enum ObjectType {
|
||||
DatatableMigration,
|
||||
}
|
||||
|
||||
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28911/sync-script-to-git-repo-windmill";
|
||||
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28931/sync-script-to-git-repo-windmill";
|
||||
|
||||
/// Hub script that applies a repository's state back into a workspace
|
||||
/// (the repo → Windmill / "pull" direction). Same script the UI runs from
|
||||
@@ -183,7 +183,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28911/sync-script-to-git-repo
|
||||
/// ignores the slug, so the slug is kept free of characters that would be
|
||||
/// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened
|
||||
/// reverse proxies reject as double-encoding when the client re-encodes it).
|
||||
pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28910/git-sync-init-repository-windmill";
|
||||
pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28930/git-sync-init-repository-windmill";
|
||||
|
||||
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
|
||||
/// fork of another workspace.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::user_drafts::rename_drafts_of_email;
|
||||
|
||||
/// A rename onto the same address has to be a no-op: the helper clears a draft the destination
|
||||
/// already holds at the same item, and every row would be its own destination. SCIM PATCH sends
|
||||
/// `userName` unconditionally, so an IdP re-sending an unchanged one reaches this.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn renaming_onto_the_same_address_keeps_the_drafts(db: Pool<Postgres>) {
|
||||
sqlx::query(
|
||||
"INSERT INTO draft(workspace_id, path, typ, value, email) \
|
||||
VALUES ('test-workspace', 'u/test-user/s', 'script', '{}'::json, 'test@windmill.dev')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("failed to seed draft");
|
||||
|
||||
let mut conn = db.acquire().await.unwrap();
|
||||
rename_drafts_of_email(&mut conn, "test@windmill.dev", "test@windmill.dev")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let kept: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM draft WHERE email = 'test@windmill.dev'")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(kept, 1);
|
||||
}
|
||||
@@ -26,4 +26,5 @@ tracing.workspace = true
|
||||
lazy_static.workspace = true
|
||||
chrono.workspace = true
|
||||
itertools.workspace = true
|
||||
futures.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod ci_tests;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod ci_tests_ee;
|
||||
pub mod lock_hash;
|
||||
pub mod scoped_dependency_map;
|
||||
pub mod trigger_dependents;
|
||||
pub mod workspace_dependencies;
|
||||
@@ -127,6 +128,40 @@ pub fn extract_referenced_paths(
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-records which paths `script_path` imports and what each one's lock hashes to right now.
|
||||
/// That snapshot is what a later relock-skip check of this importer compares against, so it
|
||||
/// has to move whenever the imports may have, whether or not the importer's own lock did.
|
||||
///
|
||||
/// Writes for any path in `w_id` and checks nothing: callers are responsible for having
|
||||
/// established access to that workspace and script, as a dependency job's push already has.
|
||||
pub async fn refresh_dependency_map(
|
||||
db: &sqlx::Pool<sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
script_path: &str,
|
||||
parent_path: &Option<String>,
|
||||
code: &str,
|
||||
script_lang: &Option<ScriptLang>,
|
||||
) -> error::Result<()> {
|
||||
use scoped_dependency_map::ScopedDependencyMap;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let mut dependency_map =
|
||||
ScopedDependencyMap::fetch_maybe_rearranged(w_id, script_path, "script", parent_path, db)
|
||||
.await?;
|
||||
|
||||
tx = dependency_map
|
||||
.patch(
|
||||
extract_referenced_paths(code, script_path, *script_lang),
|
||||
// Ideally should be None, but due to current implementation will use empty string to represent None.
|
||||
"".into(),
|
||||
tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
dependency_map.dissolve(tx).await.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn process_relative_imports(
|
||||
db: &sqlx::Pool<sqlx::Postgres>,
|
||||
_job_id: Option<Uuid>,
|
||||
@@ -144,29 +179,7 @@ pub async fn process_relative_imports(
|
||||
use scoped_dependency_map::ScopedDependencyMap;
|
||||
use trigger_dependents::trigger_dependents_to_recompute_dependencies;
|
||||
|
||||
// TODO: Should be moved into handle_dependency_job body to be more consistent with how flows and apps are handled
|
||||
{
|
||||
let mut tx = db.begin().await?;
|
||||
let mut dependency_map = ScopedDependencyMap::fetch_maybe_rearranged(
|
||||
&w_id,
|
||||
script_path,
|
||||
"script",
|
||||
&parent_path,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx = dependency_map
|
||||
.patch(
|
||||
extract_referenced_paths(&code, script_path, *script_lang),
|
||||
// Ideally should be None, but due to current implementation will use empty string to represent None.
|
||||
"".into(),
|
||||
tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
dependency_map.dissolve(tx).await.commit().await?;
|
||||
}
|
||||
refresh_dependency_map(db, w_id, script_path, &parent_path, code, script_lang).await?;
|
||||
|
||||
{
|
||||
let mut already_visited = args
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use futures::TryStreamExt;
|
||||
use sqlx::{Postgres, Transaction};
|
||||
use windmill_common::error::Result;
|
||||
use windmill_common::scripts::hash_script;
|
||||
|
||||
/// Records what the lock now at each path hashes to, which is one half of the comparison a relock
|
||||
/// skip makes against what each importer resolved against.
|
||||
///
|
||||
/// Writes any path in `w_id` and checks nothing: callers are responsible for having established
|
||||
/// the caller's access to that workspace. A path repeated in `entries` keeps its last hash.
|
||||
///
|
||||
/// Callers that write the lock itself in the same statement fold the upsert into that statement
|
||||
/// instead; this is for the ones with nothing to fold it into.
|
||||
pub async fn record_lock_hashes(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
w_id: &str,
|
||||
entries: &[(String, i64)],
|
||||
) -> Result<()> {
|
||||
// Postgres rejects a whole statement that resolves a conflict on one key twice, so a path
|
||||
// given more than once keeps its last hash, as it would if the two were written in order.
|
||||
let mut deduped: HashMap<&str, i64> = HashMap::with_capacity(entries.len());
|
||||
for (path, hash) in entries {
|
||||
deduped.insert(path.as_str(), *hash);
|
||||
}
|
||||
if deduped.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (paths, hashes): (Vec<String>, Vec<i64>) = deduped
|
||||
.into_iter()
|
||||
.map(|(path, hash)| (path.to_string(), hash))
|
||||
.unzip();
|
||||
// Recording a hash a path already has would still cut a row version, and the no-op push this
|
||||
// is reached from is the mode a git-sync of an unchanged workspace runs in.
|
||||
sqlx::query!(
|
||||
"INSERT INTO lock_hash (workspace_id, path, lockfile_hash)
|
||||
SELECT $1, * FROM UNNEST($2::text[], $3::bigint[])
|
||||
ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = EXCLUDED.lockfile_hash
|
||||
WHERE lock_hash.lockfile_hash IS DISTINCT FROM EXCLUDED.lockfile_hash",
|
||||
w_id,
|
||||
&paths[..],
|
||||
&hashes[..]
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Records the hash of every live lock in `w_id`, for a workspace whose scripts arrived without
|
||||
/// going through a deploy — a clone, which copies their locks verbatim and so would otherwise hold
|
||||
/// none of the hashes describing them.
|
||||
///
|
||||
/// Carries the same caller obligation as [`record_lock_hashes`].
|
||||
///
|
||||
/// `script.lock` is unbounded and a workspace holds one per script, so the rows are streamed and
|
||||
/// each lock is hashed and dropped before the next arrives; only the hashes accumulate.
|
||||
pub async fn record_lock_hashes_for_workspace(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
w_id: &str,
|
||||
) -> Result<()> {
|
||||
let mut entries: Vec<(String, i64)> = Vec::new();
|
||||
{
|
||||
let mut rows = sqlx::query!(
|
||||
"SELECT DISTINCT ON (path) path, lock FROM script
|
||||
WHERE workspace_id = $1 AND NOT archived AND NOT deleted AND lock IS NOT NULL
|
||||
ORDER BY path, created_at DESC",
|
||||
w_id
|
||||
)
|
||||
.fetch(&mut **tx);
|
||||
|
||||
while let Some(row) = rows.try_next().await? {
|
||||
if let Some(lock) = row.lock {
|
||||
entries.push((row.path, hash_script(&lock)));
|
||||
}
|
||||
}
|
||||
}
|
||||
record_lock_hashes(tx, w_id, &entries).await
|
||||
}
|
||||
@@ -101,7 +101,7 @@ fn apply_resource_enrichment(
|
||||
let resources_count = resource_cache.len();
|
||||
let description = match resource_type {
|
||||
Some(rt) => format!(
|
||||
"This is a resource named `{}` with the following description: `{}`.\\nThe path of the resource should be used to specify the resource.\\n{}",
|
||||
"This is a resource named `{}` with the following description: `{}`.\nPass it as the bare string `$res:<path>` — the whole value of this argument, never an object wrapper like {{\"$res\": \"<path>\"}} and never a plain path.\n{}",
|
||||
rt.name,
|
||||
rt.description.as_deref().unwrap_or("No description"),
|
||||
if resources_count == 0 {
|
||||
@@ -138,7 +138,7 @@ fn apply_resource_enrichment(
|
||||
)
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("\\n");
|
||||
.join("\n");
|
||||
let prior_description = prop_map
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
@@ -147,7 +147,7 @@ fn apply_resource_enrichment(
|
||||
prop_map.insert(
|
||||
"description".to_string(),
|
||||
Value::String(format!(
|
||||
"{}\\nHere are the available resources, in the format title:path. Title can be empty. Path should be used to specify the resource:\\n{}",
|
||||
"{}\nHere are the available resources, one per line as `title: $res:path`. The title is only a label; pass the `$res:path` part verbatim as this argument's value:\n{}",
|
||||
prior_description, resources_description
|
||||
)),
|
||||
);
|
||||
@@ -804,6 +804,10 @@ mod tests {
|
||||
let desc = node["description"].as_str().unwrap();
|
||||
assert!(desc.contains("c_aws_account"));
|
||||
assert!(desc.contains("$res:f/platform/aws_dev"));
|
||||
// MCP clients render this description verbatim, so the separators must be
|
||||
// real newlines rather than the two-character escape.
|
||||
assert!(desc.contains('\n'));
|
||||
assert!(!desc.contains("\\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,6 +16,14 @@ use crate::server::endpoints::EndpointTool;
|
||||
/// Result type for backend operations using rmcp's ErrorData directly
|
||||
pub type BackendResult<T> = Result<T, ErrorData>;
|
||||
|
||||
/// What the backend needs about the HTTP request a tool call arrived on, in order
|
||||
/// to hand a runnable the headers of the call that triggered it.
|
||||
pub struct McpRequest<'a> {
|
||||
pub headers: &'a http::HeaderMap,
|
||||
/// The MCP tool name the caller invoked, reported to preprocessors.
|
||||
pub tool_name: &'a str,
|
||||
}
|
||||
|
||||
/// How a script/flow listing is narrowed by path at the SQL layer, *before* the
|
||||
/// `ITEMS_FETCH_MAX_LIMIT` cap applies.
|
||||
///
|
||||
@@ -157,6 +165,7 @@ pub trait McpBackend: Send + Sync + Clone + 'static {
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
args: Value,
|
||||
request: &McpRequest<'_>,
|
||||
) -> BackendResult<Value>;
|
||||
|
||||
/// Run a flow and wait for result
|
||||
@@ -166,6 +175,7 @@ pub trait McpBackend: Send + Sync + Clone + 'static {
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
args: Value,
|
||||
request: &McpRequest<'_>,
|
||||
) -> BackendResult<Value>;
|
||||
|
||||
/// Call an endpoint tool (generated API endpoint)
|
||||
|
||||
@@ -12,7 +12,7 @@ pub mod tools;
|
||||
|
||||
// Re-export main types
|
||||
pub use crate::common::types::{McpToken, MultiWorkspaceMcp, WorkspaceInfo};
|
||||
pub use backend::{BackendResult, McpAuth, McpBackend, PathFilter};
|
||||
pub use backend::{BackendResult, McpAuth, McpBackend, McpRequest, PathFilter};
|
||||
pub use endpoints::{
|
||||
endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, is_endpoint_read_only,
|
||||
list_workspaces_tool, non_empty_body_fields, EndpointTool,
|
||||
|
||||
@@ -9,8 +9,10 @@ use crate::common::transform::{
|
||||
extract_hub_version_id_from_hashed, extract_path_prefix_from_hashed, parse_tool_prefix,
|
||||
reverse_transform, reverse_transform_key,
|
||||
};
|
||||
use crate::common::types::{McpToken, MultiWorkspaceMcp, ResourceInfo, ToolableItem, WorkspaceId};
|
||||
use crate::server::backend::{McpAuth, McpBackend, PathFilter};
|
||||
use crate::common::types::{
|
||||
McpToken, MultiWorkspaceMcp, ResourceInfo, SchemaType, ToolableItem, WorkspaceId,
|
||||
};
|
||||
use crate::server::backend::{McpAuth, McpBackend, McpRequest, PathFilter};
|
||||
use crate::server::endpoints::{
|
||||
endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, list_workspaces_tool, EndpointTool,
|
||||
};
|
||||
@@ -101,16 +103,24 @@ enum McpMode {
|
||||
Multi(String),
|
||||
}
|
||||
|
||||
/// Everything a request carries besides its MCP payload.
|
||||
struct McpContext<A> {
|
||||
auth: A,
|
||||
mode: McpMode,
|
||||
headers: http::HeaderMap,
|
||||
}
|
||||
|
||||
impl<B: McpBackend> Runner<B> {
|
||||
/// Create a new Runner with the given backend
|
||||
pub fn new(backend: B) -> Self {
|
||||
Self { backend: Arc::new(backend) }
|
||||
}
|
||||
|
||||
/// Extract authentication and the workspace mode from request context
|
||||
/// Extract authentication, the workspace mode and the HTTP request itself
|
||||
/// from the request context
|
||||
fn extract_context(
|
||||
context: &RequestContext<RoleServer>,
|
||||
) -> Result<(B::Auth, McpMode), ErrorData> {
|
||||
) -> Result<McpContext<B::Auth>, ErrorData> {
|
||||
let http_parts = context.extensions.get::<HttpParts>().ok_or_else(|| {
|
||||
tracing::error!("http::request::Parts not found");
|
||||
ErrorData::internal_error("http::request::Parts not found", None)
|
||||
@@ -148,7 +158,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
McpMode::Single(workspace_id)
|
||||
};
|
||||
|
||||
Ok((auth.clone(), mode))
|
||||
Ok(McpContext { auth: auth.clone(), mode, headers: http_parts.headers.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +401,18 @@ fn authorize_endpoint_call(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map the model's argument keys back to the runnable's original parameter names.
|
||||
fn transform_call_args(args: Value, item_schema: &Option<SchemaType>) -> Value {
|
||||
let Value::Object(map) = args else {
|
||||
return args;
|
||||
};
|
||||
let mut args_hash = HashMap::new();
|
||||
for (k, v) in map {
|
||||
args_hash.insert(reverse_transform_key(&k, item_schema), v);
|
||||
}
|
||||
Value::Object(args_hash.into_iter().collect())
|
||||
}
|
||||
|
||||
fn find_matching_path<T: ToolableItem>(candidates: Vec<T>, request_name: &str) -> Option<String> {
|
||||
candidates
|
||||
.into_iter()
|
||||
@@ -427,7 +449,7 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, ErrorData> {
|
||||
let (auth, mode) = Self::extract_context(&context)?;
|
||||
let McpContext { auth, mode, .. } = Self::extract_context(&context)?;
|
||||
|
||||
// Parse MCP scopes to determine what to expose
|
||||
let scopes = auth.scopes().unwrap_or(&[]);
|
||||
@@ -455,7 +477,7 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResponse, ErrorData> {
|
||||
let (auth, mode) = Self::extract_context(&context)?;
|
||||
let McpContext { auth, mode, headers } = Self::extract_context(&context)?;
|
||||
|
||||
// Parse MCP scopes for authorization
|
||||
let scopes = auth.scopes().unwrap_or(&[]);
|
||||
@@ -464,6 +486,7 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
let read_only = auth.read_only();
|
||||
|
||||
let args = request.arguments.map(Value::Object).unwrap_or(Value::Null);
|
||||
let mcp_request = McpRequest { headers: &headers, tool_name: request.name.as_ref() };
|
||||
|
||||
// Every tool here runs to completion in one round trip: none of them ask the
|
||||
// client for input, so the MRTR variants of `CallToolResponse` are never built.
|
||||
@@ -474,14 +497,22 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
&workspace_id,
|
||||
&scope_config,
|
||||
read_only,
|
||||
request.name,
|
||||
request.name.clone(),
|
||||
args,
|
||||
&mcp_request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
McpMode::Multi(token) => {
|
||||
self.call_tool_multi(&auth, &token, &scope_config, read_only, request.name, args)
|
||||
.await
|
||||
self.call_tool_multi(
|
||||
&auth,
|
||||
&token,
|
||||
&scope_config,
|
||||
read_only,
|
||||
request.name.clone(),
|
||||
args,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}?;
|
||||
Ok(result.into())
|
||||
@@ -665,6 +696,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
read_only: bool,
|
||||
name: std::borrow::Cow<'static, str>,
|
||||
args: Value,
|
||||
request: &McpRequest<'_>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
// Check if this is an endpoint tool
|
||||
let endpoint_tools = self.backend.all_endpoint_tools();
|
||||
@@ -777,17 +809,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?
|
||||
};
|
||||
|
||||
// Transform arguments back to original key names
|
||||
let transformed_args = if let Value::Object(map) = args {
|
||||
let mut args_hash = HashMap::new();
|
||||
for (k, v) in map {
|
||||
let original_key = reverse_transform_key(&k, &item_schema);
|
||||
args_hash.insert(original_key, v);
|
||||
}
|
||||
Value::Object(args_hash.into_iter().collect())
|
||||
} else {
|
||||
args
|
||||
};
|
||||
let transformed_args = transform_call_args(args, &item_schema);
|
||||
|
||||
let script_or_flow_path = if is_hub {
|
||||
format!("hub/{}", path)
|
||||
@@ -798,11 +820,23 @@ impl<B: McpBackend> Runner<B> {
|
||||
// Execute script or flow
|
||||
let result = if tool_type == "script" {
|
||||
self.backend
|
||||
.run_script(auth, workspace_id, &script_or_flow_path, transformed_args)
|
||||
.run_script(
|
||||
auth,
|
||||
workspace_id,
|
||||
&script_or_flow_path,
|
||||
transformed_args,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
self.backend
|
||||
.run_flow(auth, workspace_id, &script_or_flow_path, transformed_args)
|
||||
.run_flow(
|
||||
auth,
|
||||
workspace_id,
|
||||
&script_or_flow_path,
|
||||
transformed_args,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ pub struct OAuthConfig {
|
||||
#[serde(default = "empty_string")]
|
||||
pub token_url: String,
|
||||
pub userinfo_url: Option<String>,
|
||||
/// The registry JSON may also carry `scope_options`, a frontend-only pick
|
||||
/// list for the connect dialog; it is deliberately not modelled here.
|
||||
pub scopes: Option<Vec<String>>,
|
||||
/// Default scopes for the client-credentials (2-legged) flow. These differ
|
||||
/// from the authorization-code `scopes` for most providers (member/consent
|
||||
|
||||
@@ -4007,33 +4007,10 @@ async fn clone_runnable(j: &mut PulledJob, db: &DB) -> error::Result<()> {
|
||||
|
||||
{
|
||||
let maybe_new_id = match j.kind {
|
||||
JobKind::Dependencies => {
|
||||
let deployment_message = j
|
||||
.args
|
||||
.clone()
|
||||
.map(|hashmap| {
|
||||
hashmap
|
||||
.get("deployment_message")
|
||||
.map(|map_value| serde_json::from_str::<String>(map_value.get()).ok())
|
||||
.flatten()
|
||||
})
|
||||
.flatten();
|
||||
|
||||
// This way we tell downstream which script we should archive when the resolution is finished.
|
||||
// (not used at the moment)
|
||||
j.args
|
||||
.as_mut()
|
||||
.map(|args| args.insert("base_hash".to_owned(), to_raw_value(&*base_hash)));
|
||||
|
||||
windmill_common::scripts::clone_script(
|
||||
j.runnable_path(),
|
||||
&j.workspace_id,
|
||||
deployment_message,
|
||||
db,
|
||||
)
|
||||
.await?
|
||||
.new_hash
|
||||
}
|
||||
// A script gets its new version from the worker, once the generated lock is known
|
||||
// to differ from the live version's: minting one here would deploy, and walk the
|
||||
// importers of, a version whose lock turns out byte-identical to its parent's.
|
||||
JobKind::Dependencies => *base_hash,
|
||||
JobKind::FlowDependencies => {
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO flow_version
|
||||
|
||||
@@ -38,6 +38,25 @@ pub fn is_default<T: Default + std::cmp::PartialEq>(t: &T) -> bool {
|
||||
&T::default() == t
|
||||
}
|
||||
|
||||
pub fn maybe_number<'de, T, D>(deserializer: D) -> Result<T, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: FromStr + serde::Deserialize<'de>,
|
||||
<T as FromStr>::Err: Display,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum NumericOrString<T> {
|
||||
String(String),
|
||||
RawT(T),
|
||||
}
|
||||
|
||||
match NumericOrString::<T>::deserialize(deserializer)? {
|
||||
NumericOrString::String(s) => T::from_str(&s).map_err(serde::de::Error::custom),
|
||||
NumericOrString::RawT(i) => Ok(i),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn maybe_number_opt<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
@@ -85,3 +104,41 @@ where
|
||||
{
|
||||
serde::Deserialize::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WithMaybeNumber {
|
||||
#[serde(deserialize_with = "super::maybe_number")]
|
||||
n: i64,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_number_accepts_number() {
|
||||
let v: WithMaybeNumber = serde_json::from_value(serde_json::json!({ "n": 12345 })).unwrap();
|
||||
assert_eq!(v.n, 12345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_number_accepts_string() {
|
||||
let v: WithMaybeNumber =
|
||||
serde_json::from_value(serde_json::json!({ "n": "12345" })).unwrap();
|
||||
assert_eq!(v.n, 12345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_number_rejects_non_numeric_string() {
|
||||
assert!(
|
||||
serde_json::from_value::<WithMaybeNumber>(serde_json::json!({ "n": "abc" })).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_number_rejects_null() {
|
||||
assert!(
|
||||
serde_json::from_value::<WithMaybeNumber>(serde_json::json!({ "n": null })).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
runnable_settings::{ConcurrencySettings, DebouncingSettings},
|
||||
};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Hash)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq)]
|
||||
pub struct ScriptModule {
|
||||
pub content: String,
|
||||
pub language: ScriptLang,
|
||||
|
||||
@@ -1099,7 +1099,7 @@ pub async fn run_agent(
|
||||
// For non-Anthropic providers, response_format is handled by the query builder
|
||||
}
|
||||
|
||||
let user_wants_streaming = args.streaming.unwrap_or(false);
|
||||
let user_wants_streaming = streaming_requested(args.streaming);
|
||||
*has_stream = user_wants_streaming && is_text_output;
|
||||
|
||||
let mut final_events_str = String::new();
|
||||
@@ -1701,6 +1701,17 @@ pub async fn run_agent(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether the step asked for its answer as it is generated. Absence means on, matching the
|
||||
/// schema's own default: a step that never wrote the key never had an opinion, and an answer
|
||||
/// arriving as it is written is what people expect. Only an explicit `false` holds it back.
|
||||
///
|
||||
/// The chat surfaces decide whether to open a stream from their own reading of the same config,
|
||||
/// and a surface that opens one for an answer sent in a single piece re-runs the flow when the
|
||||
/// connection times out. So this default is half of a contract, not a local preference.
|
||||
fn streaming_requested(streaming: Option<bool>) -> bool {
|
||||
streaming.unwrap_or(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1713,6 +1724,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unwritten_streaming_field_streams() {
|
||||
assert!(streaming_requested(None));
|
||||
assert!(streaming_requested(Some(true)));
|
||||
assert!(!streaming_requested(Some(false)));
|
||||
}
|
||||
|
||||
/// Over 64 characters OpenAI rejects the key outright, which costs a wasted round
|
||||
/// trip per run and silently leaves that step with no prompt caching at all.
|
||||
#[test]
|
||||
|
||||
@@ -19,14 +19,17 @@ use windmill_common::error::Result;
|
||||
use windmill_common::flows::{FlowModule, FlowModuleValue, FlowNodeId};
|
||||
use windmill_common::jobs::JobKind;
|
||||
use windmill_common::min_version::MIN_VERSION_SUPPORTS_DEBOUNCING_V2;
|
||||
use windmill_common::scripts::ScriptHash;
|
||||
use windmill_common::scripts::{
|
||||
deploy_relocked_version, fetch_script_for_update, hash_script, ScriptHash, ScriptModule,
|
||||
};
|
||||
#[cfg(feature = "python")]
|
||||
use windmill_common::worker::PythonAnnotations;
|
||||
use windmill_common::worker::{to_raw_value, to_raw_value_owned, write_file, Connection};
|
||||
use windmill_common::workspace_dependencies::{
|
||||
RawWorkspaceDependencies, WorkspaceDependenciesPrefetched,
|
||||
};
|
||||
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
|
||||
use windmill_dep_map::scoped_dependency_map::{DependencyDependent, ScopedDependencyMap};
|
||||
use windmill_dep_map::trigger_dependents::trigger_dependents_to_recompute_dependencies;
|
||||
#[cfg(feature = "python")]
|
||||
use windmill_parser_yaml::AnsibleRequirements;
|
||||
|
||||
@@ -38,8 +41,10 @@ use windmill_common::{
|
||||
scripts::ScriptLang,
|
||||
DB,
|
||||
};
|
||||
use windmill_dep_map::lock_hash::record_lock_hashes;
|
||||
pub use windmill_dep_map::{
|
||||
extract_referenced_paths, extract_relative_imports, process_relative_imports,
|
||||
refresh_dependency_map,
|
||||
};
|
||||
use windmill_git_sync::{
|
||||
handle_deployment_metadata, tally_deployed_object_changes, DeployedObject,
|
||||
@@ -86,10 +91,13 @@ use crate::{
|
||||
/// has the toolchain and, since the cache key is per OS/arch, the platform the runtime
|
||||
/// workers use. Deploys that supply their own lock never reach a dependency job at all and
|
||||
/// queue theirs from `create_script_internal` instead.
|
||||
async fn maybe_queue_binary_prebuild(db: &DB, job: &MiniPulledJob, lock: &str) -> Result<()> {
|
||||
let (Some(hash), Some(path), Some(lang)) =
|
||||
(job.runnable_id, job.runnable_path.clone(), job.script_lang)
|
||||
else {
|
||||
async fn maybe_queue_binary_prebuild(
|
||||
db: &DB,
|
||||
job: &MiniPulledJob,
|
||||
hash: ScriptHash,
|
||||
lock: &str,
|
||||
) -> Result<()> {
|
||||
let (Some(path), Some(lang)) = (job.runnable_path.clone(), job.script_lang) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(prebuild) =
|
||||
@@ -283,6 +291,13 @@ pub async fn handle_dependency_job(
|
||||
job.runnable_path()
|
||||
);
|
||||
let script_path = job.runnable_path();
|
||||
let w_id = &job.workspace_id;
|
||||
|
||||
let triggered_by_relative_import = job
|
||||
.args
|
||||
.as_ref()
|
||||
.map(|x| x.get("triggered_by_relative_import").is_some())
|
||||
.unwrap_or_default();
|
||||
|
||||
// A build pass reads the same script data but writes none of the deploy state below,
|
||||
// including the `lock_error_logs` stamp on a fetch failure: the version it builds is
|
||||
@@ -296,14 +311,48 @@ pub async fn handle_dependency_job(
|
||||
*deployment_tallied = true;
|
||||
}
|
||||
|
||||
// A relative-import relock deploys nothing until `commit_relock` says so, while the
|
||||
// caller's fallback tally assumes a failed dependency job left a deployed version behind.
|
||||
// Claim the tally here; the failure path hands it back once it has minted the version
|
||||
// that carries the error.
|
||||
if triggered_by_relative_import {
|
||||
*deployment_tallied = true;
|
||||
}
|
||||
|
||||
// A relative-import relock locks the path's live version as of now, not the hash captured
|
||||
// when the job was pushed: a deploy can land during the debounce delay, after which that
|
||||
// hash names an archived version. What it generates is committed against the live version
|
||||
// re-read under a row lock, so a deploy landing mid-generation is caught there too.
|
||||
let target_hash = if triggered_by_relative_import {
|
||||
Some(ScriptHash(live_head_hash(db, w_id, script_path).await?))
|
||||
} else {
|
||||
job.runnable_id
|
||||
};
|
||||
|
||||
// `JobKind::Dependencies` job store either:
|
||||
// - A saved script `hash` in the `script_hash` column.
|
||||
// - Preview raw lock and code in the `queue` or `job` table.
|
||||
let script_data = &match job.runnable_id {
|
||||
let script_data = &match target_hash {
|
||||
// Read straight from the database: the cache pins a version's data under its hash for
|
||||
// as long as this worker lives, and the live version may still be waiting on its own
|
||||
// dependency job's lock, which lands in place. A run resolving to it on this worker
|
||||
// would then get no lock from the cache at all.
|
||||
Some(hash) if triggered_by_relative_import => {
|
||||
let raw = cache::script::fetch_script_from_db(db, hash, std::panic::Location::caller())
|
||||
.await?;
|
||||
Cow::Owned(std::sync::Arc::new(cache::ScriptData {
|
||||
lock: raw.lock,
|
||||
code: raw.content,
|
||||
modules: raw.modules,
|
||||
}))
|
||||
}
|
||||
Some(hash) => match cache::script::fetch(&Connection::from(db.clone()), hash).await {
|
||||
Ok(d) => Cow::Owned(d.0),
|
||||
Err(e) => {
|
||||
if !is_build_job {
|
||||
// The live version of a relative-import relock is what runs resolve to, and
|
||||
// `lock_error_logs` on it takes it out of resolution; the job carries the
|
||||
// error instead, since it deployed nothing.
|
||||
if !is_build_job && !triggered_by_relative_import {
|
||||
let logs2 = sqlx::query_scalar!(
|
||||
"SELECT logs FROM job_logs WHERE job_id = $1 AND workspace_id = $2",
|
||||
&job.id,
|
||||
@@ -348,12 +397,6 @@ pub async fn handle_dependency_job(
|
||||
.await;
|
||||
}
|
||||
|
||||
let triggered_by_relative_import = job
|
||||
.args
|
||||
.as_ref()
|
||||
.map(|x| x.get("triggered_by_relative_import").is_some())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Extract temp_script_refs from job args (path -> hash mapping for temp storage)
|
||||
let temp_script_refs: Option<HashMap<String, String>> = job
|
||||
.args
|
||||
@@ -391,20 +434,17 @@ pub async fn handle_dependency_job(
|
||||
)
|
||||
.await;
|
||||
|
||||
let (deployment_message, parent_path) =
|
||||
get_deployment_msg_and_parent_path_from_args(job.args.clone());
|
||||
|
||||
match content {
|
||||
Ok(content) => {
|
||||
if job.runnable_id.is_none() {
|
||||
let Some(current_hash) = target_hash else {
|
||||
// it a one-off raw script dependency job, no need to update the db
|
||||
return Ok(to_raw_value_owned(
|
||||
json!({ "status": "Successful lock file generation", "lock": content }),
|
||||
));
|
||||
}
|
||||
|
||||
let current_hash = job.runnable_id.unwrap_or(ScriptHash(0));
|
||||
let w_id = &job.workspace_id;
|
||||
|
||||
let (deployment_message, parent_path) =
|
||||
get_deployment_msg_and_parent_path_from_args(job.args.clone());
|
||||
};
|
||||
|
||||
// Generate lockfiles for module files (if any).
|
||||
//
|
||||
@@ -441,7 +481,9 @@ pub async fn handle_dependency_job(
|
||||
occupancy_metrics,
|
||||
&raw_workspace_dependencies_o,
|
||||
module.lock.as_deref(),
|
||||
triggered_by_relative_import,
|
||||
// A module that was never locked has nothing a skip could hand
|
||||
// back; the path's lock is the parent script's, not its own.
|
||||
triggered_by_relative_import && module.lock.is_some(),
|
||||
script_path,
|
||||
None,
|
||||
"script",
|
||||
@@ -464,34 +506,83 @@ pub async fn handle_dependency_job(
|
||||
None
|
||||
};
|
||||
|
||||
// We do not create new row for this update
|
||||
// That means we can keep current hash and just update lock
|
||||
// Also store lockfile hash for dependency change detection
|
||||
let lockfile_hash = windmill_common::scripts::hash_script(&content);
|
||||
let updated_modules_json = updated_modules
|
||||
.as_ref()
|
||||
.and_then(|m| serde_json::to_value(m).ok());
|
||||
sqlx::query!(
|
||||
"WITH update_lock AS (
|
||||
UPDATE script SET lock = $1, modules = COALESCE($6, modules) WHERE hash = $2 AND workspace_id = $3
|
||||
let deployed_hash = if triggered_by_relative_import {
|
||||
match commit_relock(
|
||||
db,
|
||||
w_id,
|
||||
script_path,
|
||||
current_hash,
|
||||
Some(&content),
|
||||
updated_modules.as_ref(),
|
||||
None,
|
||||
deployment_message.clone(),
|
||||
)
|
||||
INSERT INTO lock_hash (workspace_id, path, lockfile_hash)
|
||||
VALUES ($3, $4, $5)
|
||||
ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = $5",
|
||||
&content,
|
||||
¤t_hash.0,
|
||||
w_id,
|
||||
script_path,
|
||||
&lockfile_hash,
|
||||
updated_modules_json
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
.await?
|
||||
{
|
||||
RelockOutcome::Deployed(hash) => hash,
|
||||
RelockOutcome::Unchanged => {
|
||||
let log_msg = "\nLock unchanged: no new version deployed";
|
||||
tracing::info!(workspace_id = %w_id, job_id = %job.id, "{log_msg}");
|
||||
append_logs(&job.id, w_id, log_msg, &db.into()).await;
|
||||
// The imports may have moved even though the result did not, and the
|
||||
// map is what this importer's next skip check reads.
|
||||
refresh_dependency_map(
|
||||
db,
|
||||
w_id,
|
||||
script_path,
|
||||
&parent_path,
|
||||
&script_data.code,
|
||||
&job.script_lang,
|
||||
)
|
||||
.await?;
|
||||
return Ok(to_raw_value_owned(
|
||||
json!({ "status": "Lock unchanged, no new version deployed", "lock": content }),
|
||||
));
|
||||
}
|
||||
RelockOutcome::Superseded(head) => {
|
||||
let log_msg = format!(
|
||||
"\nVersion {head} was deployed while this lock was generated; discarding it and queueing a relock of that version"
|
||||
);
|
||||
tracing::info!(workspace_id = %w_id, job_id = %job.id, "{log_msg}");
|
||||
append_logs(&job.id, w_id, log_msg, &db.into()).await;
|
||||
requeue_relock(db, job, script_path, deployment_message, parent_path)
|
||||
.await?;
|
||||
return Ok(to_raw_value_owned(
|
||||
json!({ "status": "Lock generation superseded by a newer version", "lock": content }),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// We do not create new row for this update
|
||||
// That means we can keep current hash and just update lock
|
||||
// Also store lockfile hash for dependency change detection
|
||||
let lockfile_hash = windmill_common::scripts::hash_script(&content);
|
||||
let updated_modules_json = updated_modules
|
||||
.as_ref()
|
||||
.and_then(|m| serde_json::to_value(m).ok());
|
||||
sqlx::query!(
|
||||
"WITH update_lock AS (
|
||||
UPDATE script SET lock = $1, modules = COALESCE($6, modules) WHERE hash = $2 AND workspace_id = $3
|
||||
)
|
||||
INSERT INTO lock_hash (workspace_id, path, lockfile_hash)
|
||||
VALUES ($3, $4, $5)
|
||||
ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = $5",
|
||||
&content,
|
||||
¤t_hash.0,
|
||||
w_id,
|
||||
script_path,
|
||||
&lockfile_hash,
|
||||
updated_modules_json
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
// `lock` has been updated; invalidate the cache.
|
||||
// Since only worker that ran this Dependency Job has the cache
|
||||
// we do not need to think about invalidating cache for other workers.
|
||||
cache::script::invalidate(current_hash);
|
||||
// `lock` has been updated; invalidate the cache.
|
||||
// Since only worker that ran this Dependency Job has the cache
|
||||
// we do not need to think about invalidating cache for other workers.
|
||||
cache::script::invalidate(current_hash);
|
||||
current_hash
|
||||
};
|
||||
// The version only became runnable now, so this process still resolves the path to
|
||||
// the one before it. Only the runnable-hash cache: the import-side caches ignore the
|
||||
// lock, so evicting this process' half of that pair here would key a bundle by a
|
||||
@@ -504,7 +595,7 @@ pub async fn handle_dependency_job(
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::Script {
|
||||
hash: current_hash,
|
||||
hash: deployed_hash,
|
||||
path: script_path.to_string(),
|
||||
parent_path: parent_path.clone(),
|
||||
},
|
||||
@@ -565,7 +656,7 @@ pub async fn handle_dependency_job(
|
||||
});
|
||||
}
|
||||
|
||||
if let Err(e) = maybe_queue_binary_prebuild(db, job, &content).await {
|
||||
if let Err(e) = maybe_queue_binary_prebuild(db, job, deployed_hash, &content).await {
|
||||
tracing::error!(%e, "error queueing the auto-build binary job for {script_path}");
|
||||
}
|
||||
|
||||
@@ -583,14 +674,49 @@ pub async fn handle_dependency_job(
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "no logs".to_string());
|
||||
sqlx::query!(
|
||||
"UPDATE script SET lock_error_logs = $1 WHERE hash = $2 AND workspace_id = $3",
|
||||
&format!("{logs2}\n{error}"),
|
||||
&job.runnable_id.unwrap_or(ScriptHash(0)).0,
|
||||
&job.workspace_id
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
let error_logs = format!("{logs2}\n{error}");
|
||||
if let (true, Some(hash)) = (triggered_by_relative_import, target_hash) {
|
||||
// The same shape a failed deploy leaves: a version without a lock that carries
|
||||
// the error, so it shows on the script while runs keep resolving to the last
|
||||
// version that has one. Only that version is the caller's fallback to tally;
|
||||
// one that landed meanwhile owns its own lock, and a commit that failed left
|
||||
// nothing.
|
||||
match commit_relock(
|
||||
db,
|
||||
w_id,
|
||||
script_path,
|
||||
hash,
|
||||
None,
|
||||
None,
|
||||
Some(&error_logs),
|
||||
deployment_message.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(RelockOutcome::Deployed(_)) => *deployment_tallied = false,
|
||||
Ok(RelockOutcome::Superseded(_)) => {
|
||||
if let Err(e) =
|
||||
requeue_relock(db, job, script_path, deployment_message, parent_path)
|
||||
.await
|
||||
{
|
||||
tracing::error!(%e, "error queueing a relock of {script_path}")
|
||||
}
|
||||
}
|
||||
Ok(RelockOutcome::Unchanged) => {}
|
||||
Err(e) => {
|
||||
tracing::error!(%e, "error recording the failed relock of {script_path}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"UPDATE script SET lock_error_logs = $1 WHERE hash = $2 AND workspace_id = $3",
|
||||
&error_logs,
|
||||
&job.runnable_id.unwrap_or(ScriptHash(0)).0,
|
||||
&job.workspace_id
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
}
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"Error locking file: {error}\n\nlogs:\n{}",
|
||||
remove_ansi_codes(&logs2)
|
||||
@@ -598,6 +724,136 @@ pub async fn handle_dependency_job(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The version of `script_path` that runs resolve to, which is what a relative-import relock
|
||||
/// locks. `NotFound` when the path holds none, which a job pushed for a path since archived or
|
||||
/// deleted reports as its own failure.
|
||||
async fn live_head_hash(db: &DB, w_id: &str, script_path: &str) -> error::Result<i64> {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false AND archived = false ORDER BY created_at DESC LIMIT 1",
|
||||
script_path,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
"Non-archived script with path '{script_path}' not found"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
enum RelockOutcome {
|
||||
/// A new version carrying the result is the live one.
|
||||
Deployed(ScriptHash),
|
||||
/// The live version already holds this lock and these module locks; nothing was written.
|
||||
Unchanged,
|
||||
/// The live version is no longer the one the lock was generated for; nothing was written.
|
||||
Superseded(ScriptHash),
|
||||
}
|
||||
|
||||
/// Commits what a relative-import relock produced against the path's live version, read under
|
||||
/// a row lock so relocks of one path serialize on it.
|
||||
///
|
||||
/// A result equal to the live version's lock and module locks writes nothing: the importer's
|
||||
/// dependencies did not move, and a new version would deploy byte-identical content and then
|
||||
/// walk its own importers for nothing. A `lock` of `None` is a failed generation and always
|
||||
/// deploys, as the version that carries the error.
|
||||
async fn commit_relock(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
script_path: &str,
|
||||
generated_for: ScriptHash,
|
||||
lock: Option<&str>,
|
||||
modules: Option<&HashMap<String, ScriptModule>>,
|
||||
lock_error_logs: Option<&str>,
|
||||
deployment_message: Option<String>,
|
||||
) -> error::Result<RelockOutcome> {
|
||||
let mut tx = db.begin().await?;
|
||||
let mut head = None;
|
||||
for _ in 0..4 {
|
||||
head = fetch_script_for_update(script_path, w_id, &mut *tx).await?;
|
||||
if head.is_some() {
|
||||
break;
|
||||
}
|
||||
// Having waited on the live version's row lock, the statement re-checked that row
|
||||
// once the holder committed, found it archived, and returned nothing: the successor
|
||||
// the holder inserted is not in the statement's snapshot. A fresh statement sees it,
|
||||
// unless yet another writer got there first, so this goes around a few times before
|
||||
// concluding the path holds no live version.
|
||||
}
|
||||
let Some(head) = head else {
|
||||
return Err(Error::NotFound(format!(
|
||||
"Non-archived script with path '{script_path}' not found"
|
||||
)));
|
||||
};
|
||||
if head.hash != generated_for {
|
||||
// A deploy landed while the lock was generated. It carried its own lock or queued its
|
||||
// own dependency job, and this lock describes content that is no longer live.
|
||||
return Ok(RelockOutcome::Superseded(head.hash));
|
||||
}
|
||||
let lock_hash_entry = lock.map(|lock| (script_path.to_string(), hash_script(lock)));
|
||||
if let Some(lock) = lock {
|
||||
let modules_unchanged = modules.map_or(true, |m| head.modules.as_ref() == Some(m));
|
||||
if head.lock.as_deref() == Some(lock) && modules_unchanged {
|
||||
// The hash row is still written, and under the same row lock: a version deployed
|
||||
// before lock hashes were recorded has none, so its importers cannot skip until
|
||||
// it does, and a deploy that takes the lock next must not have the hash it records
|
||||
// overwritten by this one.
|
||||
record_lock_hashes(&mut tx, w_id, lock_hash_entry.as_slice()).await?;
|
||||
tx.commit().await?;
|
||||
return Ok(RelockOutcome::Unchanged);
|
||||
}
|
||||
}
|
||||
let new_hash = deploy_relocked_version(
|
||||
&mut tx,
|
||||
head,
|
||||
deployment_message,
|
||||
lock,
|
||||
modules,
|
||||
lock_error_logs,
|
||||
)
|
||||
.await?;
|
||||
record_lock_hashes(&mut tx, w_id, lock_hash_entry.as_slice()).await?;
|
||||
tx.commit().await?;
|
||||
Ok(RelockOutcome::Deployed(ScriptHash(new_hash)))
|
||||
}
|
||||
|
||||
/// Queues another relative-import relock of `script_path`, through the same push the fan-out
|
||||
/// uses. The version live now was deployed while a lock was generated for its predecessor;
|
||||
/// when that deploy was a sibling relock it queued nothing for this path, and the result just
|
||||
/// discarded may have been the one generated against the current imports.
|
||||
async fn requeue_relock(
|
||||
db: &DB,
|
||||
job: &MiniPulledJob,
|
||||
script_path: &str,
|
||||
deployment_message: Option<String>,
|
||||
parent_path: Option<String>,
|
||||
) -> error::Result<()> {
|
||||
let already_visited = job
|
||||
.args
|
||||
.as_ref()
|
||||
.and_then(|x| x.get("already_visited"))
|
||||
.and_then(|v| serde_json::from_str::<Vec<String>>(v.get()).ok())
|
||||
.unwrap_or_default();
|
||||
trigger_dependents_to_recompute_dependencies(
|
||||
&job.workspace_id,
|
||||
vec![DependencyDependent {
|
||||
importer_path: script_path.to_string(),
|
||||
importer_kind: "script".to_string(),
|
||||
importer_node_ids: None,
|
||||
}],
|
||||
deployment_message,
|
||||
parent_path,
|
||||
&job.permissioned_as_email,
|
||||
&job.created_by,
|
||||
&job.permissioned_as,
|
||||
db,
|
||||
already_visited,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn remove_ansi_codes(s: &str) -> String {
|
||||
lazy_static::lazy_static! {
|
||||
static ref ANSI_REGEX: regex::Regex = regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap();
|
||||
@@ -2892,8 +3148,12 @@ async fn try_skip_relock(
|
||||
}
|
||||
|
||||
// Fetch existing lock based on runnable type
|
||||
let lock = match runnable_type {
|
||||
"script" => sqlx::query_scalar!(
|
||||
let lock = match (runnable_type, existing_lock) {
|
||||
// A script's module asks with the script's own type and hands over the lock it last
|
||||
// deployed with. The path's lock below is the parent script's, and a module given
|
||||
// that loses whatever it resolves on its own.
|
||||
("script", Some(module_lock)) => Some(module_lock.to_string()),
|
||||
("script", None) => sqlx::query_scalar!(
|
||||
"SELECT lock FROM script WHERE path = $1 AND workspace_id = $2 AND lock IS NOT NULL
|
||||
AND deleted = false ORDER BY created_at DESC LIMIT 1",
|
||||
base_path,
|
||||
@@ -2903,7 +3163,7 @@ async fn try_skip_relock(
|
||||
.await?
|
||||
.flatten(),
|
||||
|
||||
"flow" | "app" => existing_lock.map(|s| s.to_string()),
|
||||
("flow" | "app", existing_lock) => existing_lock.map(|s| s.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.800.1";
|
||||
export const VERSION = "v1.803.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -603,7 +603,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
|
||||
build.onLoad(
|
||||
{ filter: /.*/, namespace: "wmill-virtual" },
|
||||
(args: any) => {
|
||||
const contents = wmillTs(port);
|
||||
const contents = wmillTs();
|
||||
log.info(
|
||||
colors.yellow(
|
||||
`[wmill-virtual] Loading virtual module: ${args.path}`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//comment this line and last to dev
|
||||
export function wmillTsDev(port: number) { return `
|
||||
export function wmillTsDev() { return `
|
||||
let reqs: Record<string, any> = {}
|
||||
let ws: WebSocket | null = null
|
||||
let wsReady: Promise<void>
|
||||
@@ -10,7 +10,7 @@ function initWebSocket() {
|
||||
wsReadyResolve = resolve
|
||||
})
|
||||
|
||||
ws = new WebSocket('ws://localhost:${port}')
|
||||
ws = new WebSocket((window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.host)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[wmill] WebSocket connected')
|
||||
@@ -157,4 +157,4 @@ export function streamJob(
|
||||
ws?.send(JSON.stringify({ jobId, type: 'streamJob', reqId }))
|
||||
})
|
||||
}
|
||||
`}
|
||||
`}
|
||||
|
||||
@@ -1144,7 +1144,7 @@ const command = new Command()
|
||||
.arguments("<path:string>")
|
||||
.option(
|
||||
"-d --data <data:string>",
|
||||
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-."
|
||||
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path."
|
||||
)
|
||||
.option(
|
||||
"-s --silent",
|
||||
@@ -1162,7 +1162,7 @@ const command = new Command()
|
||||
.arguments("<flow_path:string>")
|
||||
.option(
|
||||
"-d --data <data:string>",
|
||||
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-."
|
||||
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path."
|
||||
)
|
||||
.option(
|
||||
"-s --silent",
|
||||
|
||||
@@ -106,7 +106,8 @@ export async function pushSchedule(
|
||||
path: string,
|
||||
schedule: Schedule | ScheduleFile | undefined,
|
||||
localSchedule: ScheduleFile,
|
||||
permissionedAsContext?: PermissionedAsContext
|
||||
permissionedAsContext?: PermissionedAsContext,
|
||||
enabledOwnedByParent?: boolean
|
||||
): Promise<void> {
|
||||
path = removeType(path, "schedule").replaceAll(SEP, "/");
|
||||
log.debug(`Processing local schedule ${path}`);
|
||||
@@ -123,6 +124,21 @@ export async function pushSchedule(
|
||||
// Strip CLI-only boolean marker before sending to API
|
||||
delete (localSchedule as any).has_permissioned_as;
|
||||
|
||||
// In a fork, the file's `enabled` is the parent's for a path the parent
|
||||
// also has (see sync push's `parentOwnedScheduleEnabled`): the fork's own
|
||||
// flag stays as it is.
|
||||
if (enabledOwnedByParent && schedule) {
|
||||
if (
|
||||
localSchedule.enabled !== undefined &&
|
||||
localSchedule.enabled !== schedule.enabled
|
||||
) {
|
||||
log.warnAlways(
|
||||
`Schedule ${path} stays ${schedule.enabled ? "enabled" : "disabled"}: the file says ${localSchedule.enabled ? "enabled" : "disabled"}, but in a fork that flag is the parent workspace's`
|
||||
);
|
||||
}
|
||||
delete localSchedule.enabled;
|
||||
}
|
||||
|
||||
const preserveFields: { permissioned_as?: string; preserve_permissioned_as?: boolean } = {};
|
||||
if (permissionedAsContext?.userIsAdminOrDeployer) {
|
||||
if (schedule) {
|
||||
@@ -153,13 +169,9 @@ export async function pushSchedule(
|
||||
...preserveFields,
|
||||
},
|
||||
});
|
||||
// Tarball export from a fork strips `enabled` from schedule YAMLs so
|
||||
// the fork→parent git-sync round-trip can't flip the parent's state.
|
||||
// Skip the secondary setScheduleEnabled call when the local YAML
|
||||
// doesn't carry `enabled` — sending `{ enabled: undefined }` would
|
||||
// serialize to `{}` and the backend (`SetEnabled.enabled` is required)
|
||||
// would reject the request. Preserving the target's existing flag is
|
||||
// exactly the round-trip-safe behavior.
|
||||
// No `enabled` in the file (absent from the YAML, or set aside above)
|
||||
// leaves the remote flag alone: `SetEnabled.enabled` is required, so
|
||||
// `{ enabled: undefined }` would be rejected rather than ignored.
|
||||
if (
|
||||
localSchedule.enabled !== undefined &&
|
||||
localSchedule.enabled !== schedule.enabled
|
||||
@@ -167,13 +179,12 @@ export async function pushSchedule(
|
||||
log.info(colors.bold.yellow(
|
||||
`Schedule ${path} is ${localSchedule.enabled ? "enabled" : "disabled"} locally but not on remote, updating remote`
|
||||
));
|
||||
await wmill.setScheduleEnabled({
|
||||
workspace: workspace,
|
||||
await setEnabledUnlessParentOwned(
|
||||
workspace,
|
||||
path,
|
||||
requestBody: {
|
||||
enabled: localSchedule.enabled,
|
||||
},
|
||||
});
|
||||
localSchedule.enabled,
|
||||
schedule.enabled
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error((e as any).body);
|
||||
@@ -194,6 +205,44 @@ export async function pushSchedule(
|
||||
console.error((e as any).body);
|
||||
throw e;
|
||||
}
|
||||
// A create in a fork lands disabled whatever the request says. A fork-only
|
||||
// path the file wants enabled is enabled here, so one push converges; a
|
||||
// parent-owned one stays disabled.
|
||||
if (enabledOwnedByParent !== undefined && localSchedule.enabled === true) {
|
||||
if (enabledOwnedByParent) {
|
||||
log.warnAlways(
|
||||
`Schedule ${path} created disabled: the file says enabled, but in a fork that flag is the parent workspace's`
|
||||
);
|
||||
} else {
|
||||
await setEnabledUnlessParentOwned(workspace, path, true, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The parent listing behind `enabledOwnedByParent` sees only what the pusher
|
||||
// may read; the backend's `fork-conflict` refusal is the last word, so a path
|
||||
// it says the parent has keeps the fork's flag rather than failing the push.
|
||||
async function setEnabledUnlessParentOwned(
|
||||
workspace: string,
|
||||
path: string,
|
||||
enabled: boolean,
|
||||
remoteEnabled: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
await wmill.setScheduleEnabled({
|
||||
workspace,
|
||||
path,
|
||||
requestBody: { enabled },
|
||||
});
|
||||
} catch (e) {
|
||||
const conflict = parseForkConflict(e);
|
||||
if (!conflict) {
|
||||
throw e;
|
||||
}
|
||||
log.warnAlways(
|
||||
`Schedule ${path} left ${remoteEnabled ? "enabled" : "disabled"}: the parent workspace '${conflict.parentWorkspaceId}' has the same schedule, so its flag is the parent's to set`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2213,7 +2213,7 @@ const command = new Command()
|
||||
.arguments("<path:file>")
|
||||
.option(
|
||||
"-d --data <data:file>",
|
||||
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-."
|
||||
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path."
|
||||
)
|
||||
.option(
|
||||
"-s --silent",
|
||||
@@ -2231,7 +2231,7 @@ const command = new Command()
|
||||
.arguments("<path:file>")
|
||||
.option(
|
||||
"-d --data <data:file>",
|
||||
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-."
|
||||
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path."
|
||||
)
|
||||
.option(
|
||||
"-s --silent",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user