mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 00:05:27 +00:00
* fix(cli): preserve non-dotted flow lock filenames * test(cli): add non-dotted path tests for generate-metadata and sync pull Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
import { colors } from "@cliffy/ansi/colors";
|
|
import * as log from "../../core/log.ts";
|
|
import * as path from "node:path";
|
|
import { sep as SEP } from "node:path";
|
|
import { stringify as yamlStringify } from "yaml";
|
|
import { yamlParseFile } from "../../utils/yaml.ts";
|
|
import { readFile } from "node:fs/promises";
|
|
import { GlobalOptions } from "../../types.ts";
|
|
import {
|
|
readLockfile,
|
|
checkifMetadataUptodate,
|
|
blueColor,
|
|
clearGlobalLock,
|
|
updateMetadataGlobalLock,
|
|
LockfileGenerationError,
|
|
getRawWorkspaceDependencies,
|
|
normalizeLockPath,
|
|
filterWorkspaceDependenciesForScripts,
|
|
} from "../../utils/metadata.ts";
|
|
import { ScriptLanguage } from "../../utils/script_common.ts";
|
|
import { extractInlineScripts as extractInlineScriptsForFlows } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
|
|
import { newPathAssigner } from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
|
|
|
|
import { generateHash, getHeaders, writeIfChanged } from "../../utils/utils.ts";
|
|
import { exts } from "../script/script.ts";
|
|
import { FSFSElement } from "../sync/sync.ts";
|
|
import { Workspace } from "../workspace/workspace.ts";
|
|
import { FlowFile } from "./flow.ts";
|
|
import { FlowValue } from "../../../gen/types.gen.ts";
|
|
import { replaceInlineScripts } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
|
|
import { workspaceDependenciesLanguages } from "../../utils/script_common.ts";
|
|
import {
|
|
extractNameFromFolder,
|
|
getNonDottedPaths,
|
|
} from "../../utils/resource_folders.ts";
|
|
|
|
const TOP_HASH = "__flow_hash";
|
|
async function generateFlowHash(
|
|
rawWorkspaceDependencies: Record<string, string>,
|
|
folder: string,
|
|
defaultTs: "bun" | "deno" | undefined
|
|
) {
|
|
const elems = await FSFSElement(path.join(process.cwd(), folder), [], true);
|
|
const hashes: Record<string, string> = {};
|
|
for await (const f of elems.getChildren()) {
|
|
if (exts.some((e) => f.path.endsWith(e))) {
|
|
// Embed workspace dependencies into hash
|
|
// Normalize path to ensure OS-independent hashing
|
|
const normalizedPath = normalizeLockPath(f.path);
|
|
hashes[normalizedPath] = await generateHash(
|
|
(await f.getContentText()) + JSON.stringify(rawWorkspaceDependencies)
|
|
);
|
|
}
|
|
}
|
|
return { ...hashes, [TOP_HASH]: await generateHash(JSON.stringify(hashes)) };
|
|
}
|
|
/**
|
|
* Result of generating flow locks, including which scripts were updated
|
|
*/
|
|
export interface FlowLocksResult {
|
|
path: string;
|
|
updatedScripts: string[];
|
|
}
|
|
|
|
export async function generateFlowLockInternal(
|
|
folder: string,
|
|
dryRun: boolean,
|
|
workspace: Workspace,
|
|
opts: GlobalOptions & {
|
|
defaultTs?: "bun" | "deno";
|
|
},
|
|
justUpdateMetadataLock?: boolean,
|
|
noStaleMessage?: boolean
|
|
): Promise<string | FlowLocksResult | void> {
|
|
if (folder.endsWith(SEP)) {
|
|
folder = folder.substring(0, folder.length - 1);
|
|
}
|
|
const remote_path = extractNameFromFolder(folder.replaceAll(SEP, "/"), "flow");
|
|
if (!justUpdateMetadataLock && !noStaleMessage) {
|
|
log.info(`Generating lock for flow ${folder} at ${remote_path}`);
|
|
}
|
|
|
|
// Always get out-of-sync workspace dependencies
|
|
const rawWorkspaceDependencies: Record<string, string> =
|
|
await getRawWorkspaceDependencies();
|
|
|
|
const flowValue = (await yamlParseFile(
|
|
folder! + SEP + "flow.yaml"
|
|
)) as FlowFile;
|
|
|
|
// Filter workspace dependencies based on inline scripts' languages and annotations
|
|
const filteredDeps = await filterWorkspaceDependenciesForFlow(flowValue.value as FlowValue, rawWorkspaceDependencies, folder);
|
|
|
|
let hashes = await generateFlowHash(
|
|
filteredDeps,
|
|
folder,
|
|
opts.defaultTs
|
|
);
|
|
|
|
const conf = await readLockfile();
|
|
if (await checkifMetadataUptodate(folder, hashes[TOP_HASH], conf, TOP_HASH)) {
|
|
if (!noStaleMessage) {
|
|
log.info(
|
|
colors.green(`Flow ${remote_path} metadata is up-to-date, skipping`)
|
|
);
|
|
}
|
|
return;
|
|
} else if (dryRun) {
|
|
return remote_path;
|
|
}
|
|
|
|
if (Object.keys(filteredDeps).length > 0 && !noStaleMessage) {
|
|
log.info(
|
|
(await blueColor())(
|
|
`Found workspace dependencies (${workspaceDependenciesLanguages
|
|
.map((l) => l.filename)
|
|
.join("/")}) for ${folder}, using them`
|
|
)
|
|
);
|
|
}
|
|
|
|
|
|
let changedScripts: string[] = [];
|
|
|
|
if (!justUpdateMetadataLock) {
|
|
//find hashes that do not correspond to previous hashes
|
|
for (const [path, hash] of Object.entries(hashes)) {
|
|
if (path == TOP_HASH) {
|
|
continue;
|
|
}
|
|
if (!(await checkifMetadataUptodate(folder, hash, conf, path))) {
|
|
changedScripts.push(path);
|
|
}
|
|
}
|
|
|
|
if (!noStaleMessage) {
|
|
log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`);
|
|
}
|
|
const fileReader = async (path: string) => await readFile(folder + SEP + path, "utf-8");
|
|
await replaceInlineScripts(
|
|
flowValue.value.modules,
|
|
fileReader,
|
|
log,
|
|
folder + SEP!,
|
|
SEP,
|
|
changedScripts
|
|
);
|
|
if (flowValue.value.failure_module) {
|
|
await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, changedScripts);
|
|
}
|
|
if (flowValue.value.preprocessor_module) {
|
|
await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, changedScripts);
|
|
}
|
|
|
|
//removeChangedLocks
|
|
flowValue.value = await updateFlow(
|
|
workspace,
|
|
flowValue.value,
|
|
remote_path,
|
|
filteredDeps
|
|
);
|
|
|
|
const lockAssigner = newPathAssigner(opts.defaultTs ?? "bun", {
|
|
skipInlineScriptSuffix: getNonDottedPaths(),
|
|
});
|
|
const inlineScripts = extractInlineScriptsForFlows(
|
|
flowValue.value.modules,
|
|
{},
|
|
SEP,
|
|
opts.defaultTs,
|
|
lockAssigner
|
|
);
|
|
if (flowValue.value.failure_module) {
|
|
inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], {}, SEP, opts.defaultTs, lockAssigner));
|
|
}
|
|
if (flowValue.value.preprocessor_module) {
|
|
inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], {}, SEP, opts.defaultTs, lockAssigner));
|
|
}
|
|
inlineScripts.forEach((s) => {
|
|
writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content);
|
|
});
|
|
|
|
// Overwrite `flow.yaml` with the new lockfile references
|
|
writeIfChanged(
|
|
process.cwd() + SEP + folder + SEP + "flow.yaml",
|
|
yamlStringify(flowValue as Record<string, any>)
|
|
);
|
|
}
|
|
|
|
hashes = await generateFlowHash(
|
|
filteredDeps,
|
|
folder,
|
|
opts.defaultTs
|
|
);
|
|
await clearGlobalLock(folder);
|
|
for (const [path, hash] of Object.entries(hashes)) {
|
|
await updateMetadataGlobalLock(folder, hash, path);
|
|
}
|
|
if (!noStaleMessage) {
|
|
log.info(colors.green(`Flow ${remote_path} lockfiles updated`));
|
|
}
|
|
|
|
// Return the list of updated scripts (extract just the filename from the path)
|
|
const updatedScripts = changedScripts.map(p => {
|
|
const parts = p.split(SEP);
|
|
return parts[parts.length - 1].replace(/\.[^.]+$/, ""); // Remove extension
|
|
});
|
|
return { path: remote_path, updatedScripts };
|
|
}
|
|
|
|
/**
|
|
* Filters raw workspace dependencies for a flow by extracting all inline scripts,
|
|
* filtering deps for each based on language and annotations, then computing the union.
|
|
*/
|
|
async function filterWorkspaceDependenciesForFlow(
|
|
flowValue: FlowValue,
|
|
rawWorkspaceDependencies: Record<string, string>,
|
|
folder: string
|
|
): Promise<Record<string, string>> {
|
|
const clonedValue = structuredClone(flowValue);
|
|
const depAssigner = newPathAssigner("bun");
|
|
const inlineScripts = extractInlineScriptsForFlows(clonedValue.modules, {}, SEP, undefined, depAssigner);
|
|
if (clonedValue.failure_module) {
|
|
inlineScripts.push(...extractInlineScriptsForFlows([clonedValue.failure_module], {}, SEP, undefined, depAssigner));
|
|
}
|
|
if (clonedValue.preprocessor_module) {
|
|
inlineScripts.push(...extractInlineScriptsForFlows([clonedValue.preprocessor_module], {}, SEP, undefined, depAssigner));
|
|
}
|
|
|
|
// Filter out lock files and map to common interface
|
|
const scripts = inlineScripts
|
|
.filter(s => !s.is_lock)
|
|
.map(s => ({ content: s.content, language: s.language as ScriptLanguage }));
|
|
|
|
return await filterWorkspaceDependenciesForScripts(scripts, rawWorkspaceDependencies, folder, SEP);
|
|
}
|
|
|
|
export async function updateFlow(
|
|
workspace: Workspace,
|
|
flow_value: FlowValue,
|
|
remotePath: string,
|
|
rawWorkspaceDependencies: Record<string, string>
|
|
): Promise<FlowValue | undefined> {
|
|
let rawResponse;
|
|
|
|
if (Object.keys(rawWorkspaceDependencies).length > 0) {
|
|
log.info(
|
|
colors.blue("Using raw workspace dependencies for flow dependencies")
|
|
);
|
|
|
|
// generate the script lock running a dependency job in Windmill and update it inplace
|
|
const extraHeaders = getHeaders();
|
|
rawResponse = await fetch(
|
|
`${workspace.remote}api/w/${workspace.workspaceId}/jobs/run/flow_dependencies`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: `token=${workspace.token}`,
|
|
"Content-Type": "application/json",
|
|
...extraHeaders,
|
|
},
|
|
body: JSON.stringify({
|
|
flow_value,
|
|
path: remotePath,
|
|
use_local_lockfiles: true,
|
|
raw_workspace_dependencies: rawWorkspaceDependencies,
|
|
}),
|
|
}
|
|
);
|
|
} else {
|
|
// Standard dependency resolution on the server
|
|
const extraHeaders = getHeaders();
|
|
rawResponse = await fetch(
|
|
`${workspace.remote}api/w/${workspace.workspaceId}/jobs/run/flow_dependencies`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: `token=${workspace.token}`,
|
|
"Content-Type": "application/json",
|
|
...extraHeaders,
|
|
},
|
|
body: JSON.stringify({
|
|
flow_value,
|
|
path: remotePath,
|
|
}),
|
|
}
|
|
);
|
|
}
|
|
|
|
let responseText = "reading response failed";
|
|
try {
|
|
const res = (await rawResponse.json()) as
|
|
| { updated_flow_value: any }
|
|
| { error: { message: string } }
|
|
| undefined;
|
|
if (rawResponse.status != 200) {
|
|
const msg = (res as any)?.["error"]?.["message"];
|
|
if (msg) {
|
|
throw new LockfileGenerationError(
|
|
`Failed to generate lockfile: ${msg}`
|
|
);
|
|
}
|
|
throw new LockfileGenerationError(
|
|
`Failed to generate lockfile: ${rawResponse.statusText}, ${responseText}`
|
|
);
|
|
}
|
|
return (res as any).updated_flow_value;
|
|
} catch (e) {
|
|
try {
|
|
responseText = await rawResponse.text();
|
|
} catch {
|
|
responseText = "";
|
|
}
|
|
throw new Error(
|
|
`Failed to generate lockfile. Status was: ${rawResponse.statusText}, ${responseText}, ${e}`
|
|
);
|
|
}
|
|
}
|