fix(cli): attach the right job path to preview runs (#10606)

* fix(cli): attach the right job path to preview runs

* fix(cli): keep a deliberately-absent file in the directory it was named in

* fix(cli): resolve links for a path naming a file that is not there
This commit is contained in:
Ruben Fiszel
2026-08-10 19:37:46 +02:00
committed by GitHub
parent c725d62fb0
commit 9eef70ea8b
5 changed files with 320 additions and 18 deletions
+19 -7
View File
@@ -13,7 +13,12 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { buildFolderPath, getMetadataFileName, loadNonDottedPathsSetting } from "../../utils/resource_folders.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace, validatePath } from "../../core/context.ts";
import {
assertRemotePath,
resolveWorkspace,
toSyncRootRelativePath,
validatePath,
} from "../../core/context.ts";
import { resolve, track_job, pollForJobResult } from "../script/script.ts";
import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts";
import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts";
@@ -605,6 +610,8 @@ async function preview(
log.setSilent(true);
}
const useLocalPathScripts = !opts.remote;
// Captured before the config read, which chdirs to the wmill.yaml root.
const cwdBeforeConfig = process.cwd();
if (useLocalPathScripts) {
opts = await mergeConfigWithConfigFile(opts);
}
@@ -612,6 +619,9 @@ async function preview(
await requireLogin(opts);
const codebases = useLocalPathScripts ? listSyncCodebases(opts) : [];
const argPath = flowPath;
flowPath = toSyncRootRelativePath(flowPath, cwdBeforeConfig);
// Normalize path - ensure it's a directory path to a .flow or __flow folder
const isFlowDir = flowPath.endsWith(".flow") || flowPath.endsWith(".flow" + SEP)
|| flowPath.endsWith("__flow") || flowPath.endsWith("__flow" + SEP);
@@ -633,6 +643,14 @@ async function preview(
flowPath += SEP;
}
// The flow's windmill path (e.g. "f/cli_smoke/myrelflow"). It is what the
// preview job runs under, and the anchor for relative-import resolution:
// inline scripts in this flow are treated as living at
// "<flow_wm_path>/<step_id>", so "./util" resolves to
// "<flow_wm_path_parent>/util" — matching the keys in temp_script_refs.
const flowWmPath = stripFlowSuffix(flowPath).replaceAll(SEP, "/");
assertRemotePath(flowWmPath, argPath);
// Read and parse the flow definition
const localFlow = (await yamlParseFile(flowPath + "flow.yaml")) as FlowFile;
@@ -703,12 +721,6 @@ async function preview(
// too — PathScript modules have already been rewritten to inline rawscript
// when `useLocalPathScripts` is set, and tempScriptRefs covers relative
// imports in inline scripts.
// Compute the flow's windmill path (e.g. "f/cli_smoke/myrelflow"). Used as
// the anchor for relative-import resolution: inline scripts in this flow are
// treated as living at "<flow_wm_path>/<step_id>", so "./util" resolves to
// "<flow_wm_path_parent>/util" — matching the keys in temp_script_refs.
const flowWmPath = stripFlowSuffix(flowPath).replaceAll(SEP, "/");
if (opts.step) {
await previewStep(opts.step, localFlow, flowWmPath, workspace, input, tempScriptRefs, opts.silent, opts.tag);
return;
+15 -11
View File
@@ -1,6 +1,11 @@
import { GlobalOptions } from "../../types.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace, validatePath } from "../../core/context.ts";
import {
assertRemotePath,
resolveWorkspace,
toSyncRootRelativePath,
validatePath,
} from "../../core/context.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { applyExtraPermsDiff } from "../../core/extra_perms.ts";
import { writeFile, stat, mkdir } from "node:fs/promises";
@@ -1808,13 +1813,16 @@ async function preview(
if (opts.silent) {
log.setSilent(true);
}
// Captured before the config read, which chdirs to the wmill.yaml root.
const cwdBeforeConfig = process.cwd();
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
if (!validatePath(filePath)) {
return;
}
const argPath = filePath;
filePath = toSyncRootRelativePath(filePath, cwdBeforeConfig);
const remotePath = scriptPathToRemotePath(filePath);
assertRemotePath(remotePath, argPath);
// Same as push: a descriptor-less dbt project's content path is deliberately
// absent, and the project beside it is what says the script is real.
@@ -1869,11 +1877,7 @@ async function preview(
const { extractRelativeImports } = await import(
"../../utils/relative_imports.ts"
);
const relImports = await extractRelativeImports(
content,
scriptPathToRemotePath(filePath),
language
);
const relImports = await extractRelativeImports(content, remotePath, language);
if (relImports.length > 0) {
const { buildPreviewTempScriptRefs } = await import(
"../generate-metadata/generate-metadata.ts"
@@ -1976,7 +1980,7 @@ async function preview(
const form = new FormData();
const previewPayload = {
content: content, // Pass the original content (frontend does this too)
path: filePath.substring(0, filePath.indexOf(".")).replaceAll(SEP, "/"),
path: remotePath,
args: input,
language: language,
tag: opts.tag,
@@ -2046,7 +2050,7 @@ async function preview(
workspace: workspace.workspaceId,
requestBody: {
content,
path: filePath.substring(0, filePath.indexOf(".")).replaceAll(SEP, "/"),
path: remotePath,
args: input,
language: language as any,
tag: opts.tag,
+89
View File
@@ -22,8 +22,11 @@ import {
readConfigFile,
findWorkspaceByGitBranch,
getEffectiveWorkspaceId,
getWmillYamlPath,
WorkspaceEntryConfig,
} from "./conf.ts";
import { existsSync, realpathSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import {
getCurrentGitBranch,
getOriginalBranchForWorkspaceForks,
@@ -743,6 +746,92 @@ export async function tryResolveVersion(
}
}
/**
* Directory the local tree mirrors the workspace from: the one holding
* wmill.yaml. Not `process.cwd()` — that only lands there once a config read
* has chdir'd into it, which `--remote` and fully-flagged invocations skip.
*/
function syncRoot(): string {
const wmillYaml = getWmillYamlPath();
return wmillYaml ? dirname(wmillYaml) : process.cwd();
}
/**
* Re-express a user-supplied file or folder argument as a path relative to the
* sync root, so the Windmill path derived from it is the same whatever shape
* the argument had (`./f/a/b.ts`, `/abs/repo/f/a/b.ts`, `b.ts` from inside
* `f/a`).
*
* `cwdBeforeConfig` must be the working directory as it was *before* the
* command read wmill.yaml: reading it chdirs into the directory holding it, and
* a relative argument was written against the directory the user was in.
* Arguments are resolved against that directory first and the sync root second,
* so both readings work.
*
* Resolving the sync root leaves the process in it, which is what makes the
* returned path readable by the caller — keep that in step if this ever stops
* going through `getWmillYamlPath`.
*/
export function toSyncRootRelativePath(
arg: string,
cwdBeforeConfig: string
): string {
const root = syncRoot();
const candidates = isAbsolute(arg)
? [arg]
: [resolve(cwdBeforeConfig, arg), resolve(root, arg)];
// A descriptor-less dbt project is named by a file that is deliberately not
// there, so an argument existing under neither reading is still a real one if
// the directory holding it is; only a path whose directory is missing too
// falls through to the sync-root reading for the caller to reject.
const abs =
candidates.find((c) => existsSync(c)) ??
candidates.find((c) => existsSync(dirname(c))) ??
candidates.at(-1)!;
const rel = relative(root, abs);
if (rel === "") return ".";
if (!rel.startsWith("..")) return rel;
// `relative` is purely lexical, so a root reached through a symlink (macOS'
// /var -> /private/var, a symlinked checkout) makes an absolute argument look
// like it escapes the tree. Resolve links only then, so a symlinked file
// *inside* the tree keeps the path it is filed under.
try {
const resolved = relative(realpathSync(root), realpathOfNamed(abs));
return resolved === "" ? "." : resolved;
} catch {
return rel;
}
}
/**
* `realpathSync` needs its target to exist, and a dbt descriptor deliberately
* does not. The directory naming it does, so resolve that and reattach.
*/
function realpathOfNamed(p: string): string {
return existsSync(p)
? realpathSync(p)
: join(realpathSync(dirname(p)), basename(p));
}
/** Windmill workspace path: `u|f|g` followed by at least a folder and a name. */
const REMOTE_PATH_RE = /^[ufg](\/[^/]+){2,}$/;
/**
* Guard the Windmill path a preview run is pushed under. A preview job carries
* no runnable of its own, so this path is the only identity it has: it is what
* `WM_JOB_PATH` reports, what the runs page links to, and what relative imports
* inside the previewed code resolve against.
*/
export function assertRemotePath(remotePath: string, arg: string): void {
if (REMOTE_PATH_RE.test(remotePath)) return;
throw new Error(
`Cannot derive a Windmill path from '${arg}'` +
(remotePath ? ` (it maps to '${remotePath}')` : "") +
`: a preview runs under the path of the file it previews, which must sit inside the ` +
`wmill.yaml root and be of the form <u|g|f>/<username|group|folder>/<name>.`
);
}
export function validatePath(path: string): boolean {
if (!(path.startsWith("g") || path.startsWith("u") || path.startsWith("f"))) {
log.infoStderr(