Files
windmill/cli/src/utils/dependency_tree.ts
T
Pyra 9643006f1e feat(cli): better stale scripts detection #3 (#8480)
* fix

Signed-off-by: pyranota <pyra@duck.com>

* reduce tests

Signed-off-by: pyranota <pyra@duck.com>

* update

Signed-off-by: pyranota <pyra@duck.com>

* fix

Signed-off-by: pyranota <pyra@duck.com>

* update

Signed-off-by: pyranota <pyra@duck.com>

* WIP: stash changes after merge with origin/main

* Delete backend/parsers/windmill-parser-wasm/Cargo.lock

* reset cargo.toml

* feat(cli): integrate dependency tree into generate-metadata command

- Add isDirectlyStale field to DependencyNode for staleness tracking
- Update addScript to accept itemType, folder, isRawApp, isDirectlyStale
- Update propagateStaleness to use isDirectlyStale field instead of parameter
- Handlers now determine staleness and pass it to tree.addScript
- generate-metadata calls propagateStaleness() and populates staleItems from tree
- Pass legacyBehaviour=false and tree to handlers during generation phase

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): store originalPath in tree for correct handler invocation

Scripts need the path with extension to be passed to the handler.
Added originalPath field to DependencyNode to track this.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix parsers

Signed-off-by: pyranota <pyra@duck.com>

* rever sqlx removal

* update sqlx

* feat: make py-imports parser WASM-compatible and add as separate WASM package

Gate heavy deps (sqlx, windmill-common, async-recursion, toml, pep440_rs,
tracing) behind cfg(not(wasm32)). Make parse_code_for_imports,
parse_relative_imports, NImport, and ImportPin public. Remove duplicate
import_parser from parser-py (reset to origin/main). Add py-imports-parser
feature to windmill-parser-wasm and py-imports target to build.nu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* safer return

* update

* fix: CLI metadata fixes - folder filter, staleness detection, WASM py-imports setup

- Fix lazy_static cfg gating for WASM compatibility (split into separate blocks)
- Fix folder argument filter to match specific file paths (not just directories)
- Fix staleness detection to use checkHash with conf (includes module hashes)
- Convert relative_imports_skip tests from Deno to bun APIs
- Add windmill-parser-wasm-py-imports to CLI and build-npm dependencies
- Relax module stale test to not require per-module change detail in output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: restore temp_script_refs parameter in parse_python_imports

Re-adds the temp_script_refs parameter that was lost when resetting
py-imports crate to origin/main. This enables resolving relative imports
from not-yet-deployed scripts during CLI lock generation.

* fixes

* extend testsuit

* update ee repo ref

* fix: diff endpoint bytea cast, upload only mismatched scripts

- Add POST /scripts/raw_temp/diff endpoint to batch-compare local content
  hashes against deployed versions using Postgres sha256()
- Use convert_to(content, 'UTF8') instead of content::bytea to avoid
  failure on scripts containing backslash sequences (e.g. \n)
- CLI now diffs all scripts against deployed, uploads only mismatched ones
- propagateStaleness no longer deletes non-stale nodes (needed for diff)
- Suppress verbose log.info messages during metadata generation
- Add E2E tests for locally modified and unpushed helper scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* rework

* sqlx

* fixes

* add index

* expand tests

* fix flows

* archive script before executing

* disable tests for ci

* skip Python-dependent E2E tests on CI

Tests requiring the python backend feature are skipped when
CI_MINIMAL_FEATURES=true since CI builds with zip-only features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: make flow fixture lock optional and reset nonDottedPaths after tests

Flow fixtures no longer emit an empty lock file by default. The lockContent
parameter controls whether a lock: "!inline ..." line appears in flow.yaml.
This prevents flows from appearing "up-to-date" when they should be processed
by generate-metadata.

Also adds afterAll to reset setNonDottedPaths(false) so global state doesn't
leak between test files when run together.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: add error logging in withTestBackend to diagnose CI failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: add --bail 1 to CI test runner to show full error on first failure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: include CLI stdout/stderr in assertion message for workspace deps test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: set WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES in test backend

The workspace deps feature requires workers to report their version, but
in test/CI there are no separate workers (standalone mode). The version
check fails because workers haven't had time to ping yet. Setting this
env var bypasses the version check.

Also reverts --bail 1 from CI workflow now that the root cause is fixed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: add --bail 1 to Windows CI and assertion messages for Windows failure diagnosis

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace TEMP_SCRIPT_REFS_PLACEHOLDER in bun builder tests

The loader.bun.js now includes a TEMP_SCRIPT_REFS_PLACEHOLDER that must
be replaced before execution. The builder tests were missing this
replacement, causing all 6 bun_builder_tests to fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use cdirFwd in Windows loader filterLoad regex

Raw cdir (with backslashes) interpolated into RegExp causes \r to
become carriage return and \w to become word-char, so filterLoad
never matches main.ts. This prevents replaceRelativeImports from
running, leaving bare relative imports like "./script_b" in the
bundled output, which scanImports then misparses as package ".".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Windows filterLoad regex + graceful fallback for old backends

- Fix filterLoad in loader.bun.windows.js to match both native backslash
  and forward-slash paths from Bun's resolver by escaping cdir for regex
- Wrap uploadScripts in try/catch so generate-metadata degrades gracefully
  when the backend lacks /raw_temp endpoints (locks use deployed versions)
- Add TODO for missing TEMP_SCRIPT_REFS support in Windows loader

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: add loader/builder debug logging for Windows CI diagnosis

Temporary console.log statements to understand:
- What path Bun passes to onLoad for main.ts
- Whether filterLoad regex matches
- Whether replaceRelativeImports fires
- What the bundled output contains
- What imports scanImports extracts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: trigger CI for cli path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: trigger CI via workflow file change

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add TEMP_SCRIPT_REFS to Windows loader, use .ts extensions in test imports

- Add TEMP_SCRIPT_REFS_PLACEHOLDER support to loader.bun.windows.js
  (mirrors loader.bun.js) so CLI lock generation can resolve imports
  from locally-modified scripts on Windows
- Use .ts extensions in all test relative imports to work around the
  Windows filterLoad regex bug (replaceRelativeImports doesn't fire
  on Windows, so extensionless imports fail)
- Remove unused uploadSucceeded variable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove debug logging from loader_builder.bun.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove windmill-parser-wasm-py-imports from frontend package.json

This dependency is only needed by the CLI, not the frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: add temp_script_refs logging for Windows CI investigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: remove --bail 1 from Windows CLI tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: normalize backslashes in folder filter treePath lookup (Windows)

On Windows, item.path (originalPath) uses backslashes but tree keys
use forward slashes. The isRelevant filter's touchesFolder call
passed the unnormalized path to traverseTransitive, which couldn't
find the node. This caused cross-folder importers to be excluded
from generate-metadata when a folder argument was specified.

Also removes debug logging from previous commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update cli-tests.yml

* fix: normalize backslashes in strict-folder-boundaries warning message (Windows)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update ee-repo-ref to fe8f0d1d7448464c98474d994e6492c0a45e8e38

This commit updates the EE repository reference after PR #467 was merged in windmill-ee-private.

Previous ee-repo-ref: 03e6eaf950776c96b9581848a583af9ad735be60

New ee-repo-ref: fe8f0d1d7448464c98474d994e6492c0a45e8e38

Automated by sync-ee-ref workflow.

* revert cli-tests.yml

---------

Signed-off-by: pyranota <pyra@duck.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-23 18:20:19 +00:00

374 lines
12 KiB
TypeScript

/**
* Double-linked dependency tree for tracking script imports and propagating staleness.
*/
import { Workspace } from "../commands/workspace/workspace.ts";
import * as wmill from "../../gen/services.gen.ts";
import type { ScriptLang } from "../../gen/types.gen.ts";
import { ScriptLanguage } from "./script_common.ts";
import {
filterWorkspaceDependencies,
generateScriptHash,
checkifMetadataUptodate,
workspaceDependenciesPathToLanguageAndFilename,
updateMetadataGlobalLock,
} from "./metadata.ts";
import { generateHash } from "./utils.ts";
/**
* Diff local scripts against deployed versions, upload only those that differ.
* Only uploaded (mismatched) scripts get contentHash set, so flatten() returns
* temp_script_refs only for scripts the backend can't resolve from deployed versions.
*/
export async function uploadScripts(
tree: DoubleLinkedDependencyTree,
workspace: Workspace
): Promise<void> {
// Split into scripts vs workspace deps and compute SHA256(content) for each
const scriptHashes: Record<string, string> = {};
const workspaceDeps: { path: string; language: ScriptLang; name?: string; hash: string }[] = [];
for (const path of tree.allPaths()) {
const content = tree.getContent(path);
const itemType = tree.getItemType(path);
if (itemType === "dependencies") {
// Empty string is valid for workspace deps (means "no deps") — only skip undefined
if (content === undefined) continue;
const info = workspaceDependenciesPathToLanguageAndFilename(path);
if (info) {
const hash = await generateHash(content);
workspaceDeps.push({ path, language: info.language as ScriptLang, name: info.name, hash });
}
} else if (itemType === "script") {
if (!content) continue;
const hash = await generateHash(content);
scriptHashes[path] = hash;
}
// Skip inline_script, flow, app — they don't need temp storage uploads
}
if (Object.keys(scriptHashes).length === 0 && workspaceDeps.length === 0) return;
// Single batch query: find which scripts/deps differ from deployed versions
const mismatched = await wmill.diffRawScriptsWithDeployed({
workspace: workspace.workspaceId,
requestBody: {
scripts: scriptHashes,
workspace_deps: workspaceDeps,
},
});
// Upload only mismatched scripts to temp storage
for (const path of mismatched) {
const content = tree.getContent(path);
const itemType = tree.getItemType(path);
if (itemType === "dependencies") {
// Workspace deps don't need temp storage — just mark as mismatched.
// Empty string is valid (means the dep file was emptied locally).
if (content !== undefined) {
tree.setContentHash(path, "mismatched");
}
} else if (content) {
const hash = await wmill.storeRawScriptTemp({
workspace: workspace.workspaceId,
requestBody: content,
});
tree.setContentHash(path, hash);
}
}
}
export type ItemType = "script" | "inline_script" | "flow" | "app" | "dependencies";
interface DependencyNode {
content: string;
stalenessHash: string; // Hash for staleness detection (includes deps, content, metadata)
contentHash?: string; // Hash for temp storage lookup (content only)
language: ScriptLanguage;
metadata: string;
imports: Set<string>;
importedBy: Set<string>;
staleReason?: string;
// Item metadata for generate-metadata command
itemType: ItemType;
folder: string; // Folder path (for flows/apps) or remote path (for scripts)
originalPath: string; // Original path passed to handler (with extension for scripts)
isRawApp?: boolean; // Only set for apps
isDirectlyStale: boolean; // True if this item's content changed (vs transitively stale)
}
export class DoubleLinkedDependencyTree {
private nodes: Map<string, DependencyNode> = new Map();
private workspaceDeps: Record<string, string> = {};
setWorkspaceDeps(deps: Record<string, string>): void {
this.workspaceDeps = deps;
}
async addNode(
path: string,
content: string,
language: ScriptLanguage,
metadata: string,
imports: string[],
itemType: ItemType,
folder: string,
originalPath: string,
isDirectlyStale: boolean,
isRawApp?: boolean
): Promise<void> {
const hasWorkspaceDeps = itemType === "script" || itemType === "inline_script";
const filteredDeps = hasWorkspaceDeps
? filterWorkspaceDependencies(this.workspaceDeps, content, language)
: {};
const stalenessHash = await generateScriptHash({}, content, metadata);
if (!this.nodes.has(path)) {
this.nodes.set(path, {
content: "", stalenessHash: "", language: "deno", metadata: "",
imports: new Set(), importedBy: new Set(),
itemType: "script", folder: "", originalPath: "", isDirectlyStale: false,
});
}
const node = this.nodes.get(path)!;
node.content = content;
node.stalenessHash = stalenessHash;
node.language = language;
node.metadata = metadata;
node.itemType = itemType;
node.folder = folder;
node.originalPath = originalPath;
node.isDirectlyStale = isDirectlyStale;
node.isRawApp = isRawApp;
// Create nodes for referenced workspace deps with content and language.
const filteredDepsPaths = Object.keys(filteredDeps);
for (const depsPath of filteredDepsPaths) {
if (!this.nodes.has(depsPath)) {
const depsInfo = workspaceDependenciesPathToLanguageAndFilename(depsPath);
const contentHash = await generateHash(filteredDeps[depsPath] + depsPath);
const isUpToDate = await checkifMetadataUptodate(depsPath, contentHash, undefined);
this.nodes.set(depsPath, {
content: filteredDeps[depsPath],
stalenessHash: "", language: depsInfo?.language ?? "deno", metadata: "",
imports: new Set(), importedBy: new Set(),
itemType: "dependencies", folder: "", originalPath: depsPath,
isDirectlyStale: !isUpToDate,
});
}
}
const allImports = [...imports, ...filteredDepsPaths];
for (const importPath of allImports) {
node.imports.add(importPath);
if (!this.nodes.has(importPath)) {
this.nodes.set(importPath, {
content: "", stalenessHash: "", language: "deno", metadata: "",
imports: new Set(), importedBy: new Set(),
itemType: "script", folder: "", originalPath: "", isDirectlyStale: false,
});
}
this.nodes.get(importPath)!.importedBy.add(path);
}
}
getContent(path: string): string | undefined {
return this.nodes.get(path)?.content;
}
getStalenessHash(path: string): string | undefined {
return this.nodes.get(path)?.stalenessHash;
}
getContentHash(path: string): string | undefined {
return this.nodes.get(path)?.contentHash;
}
setContentHash(path: string, hash: string): void {
const node = this.nodes.get(path);
if (node) {
node.contentHash = hash;
}
}
getLanguage(path: string): ScriptLanguage | undefined {
return this.nodes.get(path)?.language;
}
getMetadata(path: string): string | undefined {
return this.nodes.get(path)?.metadata;
}
getStaleReason(path: string): string | undefined {
return this.nodes.get(path)?.staleReason;
}
getItemType(path: string): ItemType | undefined {
return this.nodes.get(path)?.itemType;
}
getFolder(path: string): string | undefined {
return this.nodes.get(path)?.folder;
}
getIsRawApp(path: string): boolean | undefined {
return this.nodes.get(path)?.isRawApp;
}
getIsDirectlyStale(path: string): boolean {
return this.nodes.get(path)?.isDirectlyStale ?? false;
}
getOriginalPath(path: string): string | undefined {
return this.nodes.get(path)?.originalPath;
}
getImports(path: string): Set<string> | undefined {
return this.nodes.get(path)?.imports;
}
/**
* Returns true if this node has been marked stale (directly or transitively).
*/
isStale(path: string): boolean {
return this.nodes.get(path)?.staleReason !== undefined;
}
/**
* Mutates the tree by removing all nodes that are not stale.
* Uses BFS on reverse graph (importedBy) to find all stale scripts.
* Starts from nodes with isDirectlyStale=true.
*/
propagateStaleness(): void {
// Collect directly stale nodes
const directlyStale = new Set<string>();
for (const [path, node] of this.nodes.entries()) {
if (node.isDirectlyStale) {
directlyStale.add(path);
node.staleReason = "content changed";
}
}
const allStale = new Set(directlyStale);
const queue = [...directlyStale];
const visited = new Set<string>();
while (queue.length > 0) {
const scriptPath = queue.shift()!;
if (visited.has(scriptPath)) continue;
visited.add(scriptPath);
const node = this.nodes.get(scriptPath);
if (!node) continue;
for (const importer of node.importedBy) {
if (!allStale.has(importer)) {
allStale.add(importer);
queue.push(importer);
// Set reason for transitively stale scripts
const importerNode = this.nodes.get(importer);
if (importerNode) importerNode.staleReason = `depends on ${scriptPath}`;
}
}
}
}
/**
* Walks all transitive imports for a node, calling the callback for each.
* Callback may return true to stop traversing that branch.
*/
traverseTransitive(scriptPath: string, callback: (importPath: string, node: DependencyNode) => boolean | void): void {
const queue = [scriptPath];
const visited = new Set<string>();
while (queue.length > 0) {
const current = queue.shift()!;
if (visited.has(current)) continue;
visited.add(current);
const node = this.nodes.get(current);
if (!node) continue;
for (const importPath of node.imports) {
const importNode = this.nodes.get(importPath);
if (importNode) {
const stop = callback(importPath, importNode);
if (!stop) {
queue.push(importPath);
}
}
}
}
}
allPaths(): IterableIterator<string> {
return this.nodes.keys();
}
/**
* Returns paths of all stale nodes (those with a staleReason).
*/
*stalePaths(): IterableIterator<string> {
for (const [path, node] of this.nodes.entries()) {
if (node.staleReason) {
yield path;
}
}
}
has(path: string): boolean {
return this.nodes.has(path);
}
/**
* Returns workspace deps that were uploaded as mismatched with remote.
* These need to be passed as raw_workspace_dependencies in job args
* so the backend uses local content instead of deployed.
*/
getMismatchedWorkspaceDeps(): Record<string, string> {
const result: Record<string, string> = {};
for (const [path, node] of this.nodes.entries()) {
if (node.itemType === "dependencies" && node.contentHash && node.content !== undefined) {
result[path] = node.content;
}
}
return result;
}
/**
* Returns path → contentHash for all transitive imports that have been uploaded.
* Must be called after uploadScripts() has populated contentHash values.
*/
getTempScriptRefs(scriptPath: string): Record<string, string> {
const result: Record<string, string> = {};
this.traverseTransitive(scriptPath, (_path, node) => {
if (node.contentHash) {
result[_path] = node.contentHash;
}
});
return result;
}
/**
* Persist workspace dep hashes to wmill-lock.yaml so getRawWorkspaceDependencies
* considers them up-to-date on the next run.
*/
async persistDepsHashes(depsPaths: string[]): Promise<void> {
for (const path of depsPaths) {
const node = this.nodes.get(path);
if (node?.itemType === "dependencies" && node.content !== undefined) {
const hash = await generateHash(node.content + path);
await updateMetadataGlobalLock(path, hash);
}
}
}
get size(): number {
return this.nodes.size;
}
}