mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 16:02:23 +00:00
* feat(cli): deduplicate identical script lockfiles into one per language * test: pin shared lockfile path classification * fix(cli): never delete a lockfile the dedup plan also writes * fix(cli): plan lockfile dedup from the whole tree, not the sync scope * fix(cli): keep dedup out of dry runs and stop hiding scripts from its scan * test: pin which files the shared-lock scan counts as readers * fix(cli): validate shared-lock refs and let the majority keep its file * fix(cli): snapshot shared-lock ownership before regeneration moves it * fix(cli): address dedup review nits (dry-run push, json shape, scan scope) * refactor(cli): put shared lockfiles in a top-level locks/ directory * fix(cli): claim only the shared lock names windmill writes, and only when on * fix(cli): read the lock field itself, and count only scripts sync reads * fix(cli): parse metadata by its real format and lint from the sync root * fix(cli): never re-hash a script whose generation failed * fix(cli): share sync's walk exclusions and fail closed on unreadable dirs * fix(cli): keep a lockfile the metadata on disk still references * fix(cli): decide a lock is unread from the metadata field sync reads * refactor(cli): name shared lockfiles after the dependency file they resolve * fix(cli): carry shared lockfiles a narrowed sync cannot speak for * fix(cli): move a shared lockfile when its dependency file moved, not on a head count * fix(cli): let the many correct a shared lockfile a lone variant planted * fix(cli): read why a lock differs from the stamp the worker writes into it * fix(cli): let an agreeing majority speak whatever the stamps say * docs(cli): count the disjuncts the comment introduces * fix(cli): keep a private lock for any script the worker locks differently * fix(cli): match annotations by the worker's own names, not by shape * fix(cli): recognize the py: interpreter pin the macro does not cover * fix(cli): let the map speak for dependency-file deletions * perf(cli): group lock entries without rebuilding the group per insert * fix(cli): defer shared-lock deletions until the metadata has settled * fix(cli): decide shared-lock readers by the lock field, failing closed * fix(cli): read folded lock refs and keep locks read by unparseable metadata * refactor(cli): one shared-lock reader scan, shared by the pull and push paths * fix(cli): keep nested dependency set names out of shared lockfiles * fix(cli): drop a shared-lock scan gate that no real repo took
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { isDbtDescriptorPath } from "./resource_folders.ts";
|
|
export type ScriptLanguage =
|
|
| "python3"
|
|
| "deno"
|
|
| "bun"
|
|
| "nativets"
|
|
| "go"
|
|
| "bash"
|
|
| "powershell"
|
|
| "postgresql"
|
|
| "mysql"
|
|
| "bigquery"
|
|
| "duckdb"
|
|
| "oracledb"
|
|
| "snowflake"
|
|
| "mssql"
|
|
| "graphql"
|
|
| "php"
|
|
| "rust"
|
|
| "csharp"
|
|
| "nu"
|
|
| "ansible"
|
|
| "ruby"
|
|
| "rlang"
|
|
| "dbt"
|
|
| "java";
|
|
// for related places search: ADD_NEW_LANG
|
|
|
|
// To make language support raw requirements:
|
|
// 1. Add value here
|
|
// 2. Modify backend to allow raw deps
|
|
export type WorkspaceDependenciesLanguage =
|
|
| { language: "bun", filename /** (raw requirements filename) */: "package.json" }
|
|
| { language: "python3", filename: "requirements.in" }
|
|
| { language: "php", filename: "composer.json" }
|
|
| { language: "go", filename: "go.mod" }
|
|
| { language: "powershell", filename: "modules.json" };
|
|
|
|
export const workspaceDependenciesLanguages: WorkspaceDependenciesLanguage[] = [
|
|
{ language: "bun", filename: "package.json" },
|
|
{ language: "python3", filename: "requirements.in" },
|
|
{ language: "php", filename: "composer.json" },
|
|
{ language: "go", filename: "go.mod" },
|
|
{ language: "powershell", filename: "modules.json" },
|
|
] as const;
|
|
|
|
export function workspaceDependenciesPathToLanguageAndFilename(path: string): { name: string | undefined, language: ScriptLanguage } | undefined {
|
|
const relativePath = path.replace("dependencies/", "");
|
|
for (const { filename, language } of workspaceDependenciesLanguages) {
|
|
if (relativePath.endsWith(filename)) {
|
|
return {
|
|
name: relativePath === filename ? undefined : relativePath.replace("." + filename, ""),
|
|
language
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Annotation parser — mirrors backend's WorkspaceDependenciesAnnotatedRefs::parse
|
|
// (windmill-common/src/workspace_dependencies.rs), so the CLI can tell which
|
|
// workspace dependency file a script resolves against without asking a worker.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type AnnotationMode = "manual" | "extra";
|
|
|
|
export interface WorkspaceDepsAnnotation {
|
|
mode: AnnotationMode;
|
|
external: string[];
|
|
inline: string | null;
|
|
}
|
|
|
|
const LANG_ANNOTATION_CONFIG: Partial<
|
|
Record<ScriptLanguage, { comment: string; keyword: string; validityRe?: RegExp }>
|
|
> = {
|
|
python3: { comment: "#", keyword: "requirements", validityRe: /^#\s?(\S+)\s*$/ },
|
|
bun: { comment: "//", keyword: "package_json" },
|
|
nativets: { comment: "//", keyword: "package_json" },
|
|
go: { comment: "//", keyword: "go_mod" },
|
|
php: { comment: "//", keyword: "composer_json" },
|
|
powershell: { comment: "#", keyword: "modules_json" },
|
|
};
|
|
|
|
export function extractWorkspaceDepsAnnotation(
|
|
scriptContent: string,
|
|
language: ScriptLanguage,
|
|
): WorkspaceDepsAnnotation | null {
|
|
const config = LANG_ANNOTATION_CONFIG[language];
|
|
if (!config) return null;
|
|
|
|
const { comment, keyword, validityRe } = config;
|
|
const extraMarkerUnderscore = `extra_${keyword}:`;
|
|
const extraMarkerHyphen = `extra-${keyword}:`;
|
|
const manualMarker = `${keyword}:`;
|
|
|
|
const stripComment = (l: string): string | null => {
|
|
if (!l.startsWith(comment)) return null;
|
|
return l.substring(comment.length).trimStart();
|
|
};
|
|
const isExtra = (l: string): boolean => {
|
|
const s = stripComment(l);
|
|
return s !== null && (s.startsWith(extraMarkerUnderscore) || s.startsWith(extraMarkerHyphen));
|
|
};
|
|
const isManual = (l: string): boolean => {
|
|
const s = stripComment(l);
|
|
return s !== null && s.startsWith(manualMarker);
|
|
};
|
|
|
|
const lines = scriptContent.split("\n");
|
|
|
|
// Find first annotation line (mirrors Rust find_position)
|
|
let pos = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (isExtra(lines[i]) || isManual(lines[i])) {
|
|
pos = i;
|
|
break;
|
|
}
|
|
}
|
|
if (pos === -1) return null;
|
|
|
|
const annotationLine = lines[pos];
|
|
const mode: AnnotationMode = isExtra(annotationLine) ? "extra" : "manual";
|
|
|
|
// Parse external references from the annotation line
|
|
const marker = mode === "extra"
|
|
? (annotationLine.includes(extraMarkerUnderscore) ? extraMarkerUnderscore : extraMarkerHyphen)
|
|
: manualMarker;
|
|
const unparsed = annotationLine.replaceAll(marker, "").replaceAll(comment, "");
|
|
const external = unparsed
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0);
|
|
|
|
// Parse inline deps from subsequent lines
|
|
const inlineParts: string[] = [];
|
|
for (let i = pos + 1; i < lines.length; i++) {
|
|
const l = lines[i];
|
|
if (validityRe) {
|
|
const match = validityRe.exec(l);
|
|
if (match && match[1]) {
|
|
inlineParts.push(match[1]);
|
|
} else {
|
|
break;
|
|
}
|
|
} else {
|
|
if (!l.startsWith(comment)) {
|
|
break;
|
|
}
|
|
inlineParts.push(l.substring(comment.length));
|
|
}
|
|
}
|
|
|
|
const inlineStr = inlineParts.join("\n");
|
|
const inline = inlineStr.trim().length > 0 ? inlineStr : null;
|
|
|
|
return { mode, external, inline };
|
|
}
|
|
|
|
/** The comment marker each language's annotations are written behind. */
|
|
export const LANG_COMMENT_LIT: Partial<Record<ScriptLanguage, string>> = {
|
|
python3: "#",
|
|
ansible: "#",
|
|
powershell: "#",
|
|
bun: "//",
|
|
nativets: "//",
|
|
deno: "//",
|
|
go: "//",
|
|
php: "//",
|
|
rust: "//!",
|
|
};
|
|
|
|
/**
|
|
* The annotations each language recognises, by the exact names the worker
|
|
* matches (`#[annotations(..)]` structs in windmill-common/src/worker.rs).
|
|
* Several change what it locks — a pinned interpreter, `npm`, `nobundling` —
|
|
* and the rest are cheap to treat the same way, since the only cost is that
|
|
* such a script keeps a lockfile of its own.
|
|
* for related places search: ADD_NEW_LANG
|
|
*/
|
|
const LANG_ANNOTATIONS: Partial<Record<ScriptLanguage, string[]>> = {
|
|
python3: [
|
|
"no_cache",
|
|
"no_postinstall",
|
|
"py_select_latest",
|
|
"skip_result_postprocessing",
|
|
"py310",
|
|
"py311",
|
|
"py312",
|
|
"py313",
|
|
"sandbox",
|
|
],
|
|
bun: ["npm", "nodejs", "native", "nobundling", "sandbox"],
|
|
nativets: ["npm", "nodejs", "native", "nobundling", "sandbox"],
|
|
deno: ["npm", "nodejs", "native", "nobundling", "sandbox"],
|
|
go: ["go1_22_compat"],
|
|
};
|
|
|
|
/**
|
|
* Whether a script's leading comment block carries an annotation the worker
|
|
* acts on, which means its lock may not be its dependency file's.
|
|
*
|
|
* Matched the way the worker matches: the key is the line, or what precedes the
|
|
* first `=`, and it has to BE one of the names above. Unknown keys are ignored
|
|
* there and so here — which is what keeps `# TODO:` or `# type: ignore` from
|
|
* quietly dropping an ordinary documented script out of deduplication.
|
|
*
|
|
* `# py: <specifier>` is the exception the macro does not cover: the python
|
|
* import parser reads it directly (`windmill-parser-py-imports`, alongside the
|
|
* `py310`..`py313` flags) to pick the interpreter, which changes what resolves.
|
|
*/
|
|
export function hasLockAffectingAnnotation(
|
|
scriptContent: string,
|
|
language: ScriptLanguage,
|
|
): boolean {
|
|
const comment = LANG_COMMENT_LIT[language];
|
|
const names = LANG_ANNOTATIONS[language];
|
|
if (!comment || !names) return false;
|
|
for (const line of scriptContent.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (trimmed === "") continue;
|
|
if (!trimmed.startsWith(comment)) break; // past the header block
|
|
// Matched on the raw line: the parser tests `# py:`/`#py:` before trimming.
|
|
if (language === "python3" && /^#\s?py:/.test(line)) return true;
|
|
const body = trimmed.slice(comment.length).trim();
|
|
const key = body.split("=")[0].trim();
|
|
if (names.includes(key)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Where the lockfiles shared by several scripts live when `dedupeLockfiles`
|
|
* is on — see `utils/lock_dedup.ts`. A top-level directory of its own: what a
|
|
* group shares is a resolved lock, which needs no workspace dependency file
|
|
* behind it, and inline-script locks would belong here too. */
|
|
export const SHARED_LOCK_DIR = "locks";
|
|
|
|
/** The lockfile shared by the scripts that resolve against a workspace
|
|
* dependency file: its own name, plus `.lock`. Appending rather than replacing
|
|
* the extension keeps the correspondence exact and reversible —
|
|
* `dependencies/team_a.requirements.in` <-> `locks/team_a.requirements.in.lock`. */
|
|
export function sharedLockPathFor(depFilePath: string): string {
|
|
const name = depFilePath.replaceAll("\\", "/").split("/").pop()!;
|
|
return `${SHARED_LOCK_DIR}/${name}.lock`;
|
|
}
|
|
|
|
/** The workspace dependency file a shared lockfile belongs to, if it is one. */
|
|
export function depFileOfSharedLock(p: string): string | undefined {
|
|
const normalized = p.replaceAll("\\", "/");
|
|
if (!normalized.startsWith(SHARED_LOCK_DIR + "/")) return undefined;
|
|
const name = normalized.slice(SHARED_LOCK_DIR.length + 1);
|
|
if (name.includes("/") || !name.endsWith(".lock")) return undefined;
|
|
const depFile = "dependencies/" + name.slice(0, -".lock".length);
|
|
const info = workspaceDependenciesPathToLanguageAndFilename(depFile);
|
|
// `locks/vendor.lock` names no dependency file, so it is not Windmill's: a
|
|
// repo that already keeps lockfiles here keeps them.
|
|
return info && languageNeedsLock(info.language) ? depFile : undefined;
|
|
}
|
|
|
|
export function isSharedLockPath(p: string): boolean {
|
|
return depFileOfSharedLock(p) !== undefined;
|
|
}
|
|
|
|
/**
|
|
* Returns true if a script in the given language requires a lock file.
|
|
* Matches the condition in updateScriptLock (metadata.ts).
|
|
*/
|
|
export function languageNeedsLock(language: ScriptLanguage | string): boolean {
|
|
return (
|
|
(workspaceDependenciesLanguages.some((l) => l.language === language) &&
|
|
language !== "powershell") ||
|
|
language === "deno" ||
|
|
language === "rust" ||
|
|
language === "ansible"
|
|
);
|
|
}
|
|
|
|
export function inferContentTypeFromFilePath(
|
|
contentPath: string,
|
|
defaultTs: "bun" | "deno" | undefined
|
|
): ScriptLanguage {
|
|
if (contentPath.endsWith(".py")) {
|
|
return "python3";
|
|
} else if (contentPath.endsWith("fetch.ts")) {
|
|
return "nativets";
|
|
} else if (contentPath.endsWith("bun.ts")) {
|
|
return "bun";
|
|
} else if (contentPath.endsWith("deno.ts")) {
|
|
return "deno";
|
|
} else if (contentPath.endsWith(".ts")) {
|
|
return defaultTs ?? "bun";
|
|
} else if (contentPath.endsWith(".go")) {
|
|
return "go";
|
|
} else if (contentPath.endsWith(".my.sql")) {
|
|
return "mysql";
|
|
} else if (contentPath.endsWith(".bq.sql")) {
|
|
return "bigquery";
|
|
} else if (contentPath.endsWith(".odb.sql")) {
|
|
return "oracledb";
|
|
} else if (contentPath.endsWith(".duckdb.sql")) {
|
|
return "duckdb";
|
|
} else if (contentPath.endsWith(".sf.sql")) {
|
|
return "snowflake";
|
|
} else if (contentPath.endsWith(".ms.sql")) {
|
|
return "mssql";
|
|
} else if (contentPath.endsWith(".pg.sql")) {
|
|
return "postgresql";
|
|
} else if (contentPath.endsWith(".gql")) {
|
|
return "graphql";
|
|
} else if (contentPath.endsWith(".sh")) {
|
|
return "bash";
|
|
} else if (contentPath.endsWith(".ps1")) {
|
|
return "powershell";
|
|
} else if (contentPath.endsWith(".php")) {
|
|
return "php";
|
|
} else if (contentPath.endsWith(".rs")) {
|
|
return "rust";
|
|
} else if (contentPath.endsWith(".cs")) {
|
|
return "csharp";
|
|
} else if (contentPath.endsWith(".playbook.yml")) {
|
|
return "ansible";
|
|
} else if (contentPath.endsWith(".nu")) {
|
|
return "nu";
|
|
} else if (contentPath.endsWith(".java")) {
|
|
return "java";
|
|
} else if (contentPath.endsWith(".rb")) {
|
|
return "ruby";
|
|
} else if (isDbtDescriptorPath(contentPath)) {
|
|
return "dbt";
|
|
} else if (contentPath.endsWith(".r")) {
|
|
return "rlang";
|
|
// for related places search: ADD_NEW_LANG
|
|
} else {
|
|
const ext = contentPath.substring(contentPath.lastIndexOf("."));
|
|
let hint = "";
|
|
if (ext === ".sql") {
|
|
hint =
|
|
"\nBare .sql is ambiguous — use a dialect extension: .pg.sql (postgresql), .my.sql (mysql), .bq.sql (bigquery), .sf.sql (snowflake), .ms.sql (mssql), .odb.sql (oracledb), .duckdb.sql (duckdb)";
|
|
}
|
|
throw new Error(
|
|
`Cannot infer script language from extension '${ext}' (file ${contentPath}).` +
|
|
hint +
|
|
"\nSupported extensions: .ts (bun/deno), .py, .go, .sh, .ps1, .php, .rs, .cs, .nu, .java, .rb, .r, .gql, .playbook.yml, .pg.sql, .my.sql, .bq.sql, .sf.sql, .ms.sql, .odb.sql, .duckdb.sql, and a dbt project folder `<name>__dbt/`"
|
|
);
|
|
}
|
|
}
|