mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 00:01:49 +00:00
* feat: support temp_script_refs in wmill dev for local relative imports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: add unit tests for getAllTempScriptRefs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
390 lines
12 KiB
TypeScript
390 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;
|
|
}
|
|
|
|
/**
|
|
* Returns path → contentHash for ALL uploaded nodes, regardless of which
|
|
* script imports them. Superset of getTempScriptRefs(path) for every path —
|
|
* used by `wmill dev`, where the previewed item changes at runtime so no
|
|
* single anchor node exists. Must be called after uploadScripts().
|
|
*/
|
|
getAllTempScriptRefs(): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const [path, node] of this.nodes.entries()) {
|
|
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;
|
|
}
|
|
}
|