From 1ead06dc22cf22ba1c79bc7c6e872409113773ce Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 13:57:43 -0500 Subject: [PATCH 01/17] fix(security): #112-#118, #120 - one policy for a workflow-supplied location A workflow JSON is untrusted input under the default posture, and every loader used to trust the location it named: fetch_image took any absolute path, gather_images handed its glob straight to the filesystem, any http(s) URL was fetched whatever host it named, and remote_text_encoder POSTed this machine's HuggingFace token to an address the JSON picked. dw/locations.py is the one place that answers where a caller-supplied location may point: - a path must land inside a root this installation works in (the workflow's directory, the asset libraries, the output root) - the remedy for anything else is an 'asset:' reference (#114) - an http(s) URL must not resolve to an address inside the deployment (loopback, link-local, private), checked after DNS rather than on the literal string (#115) - a gather_images/gather_videos glob is contained, and each match re-checked on its real path so a symlink cannot carry the expansion out (#116) - remote_text_encoder is https-only, and the HF token is attached only for huggingface.co / .cloud / hf.space (#112) - model_name must be a Hub repo id, or a path inside a root (#117), the same check download_model already applied to repo_id Containment is tested before existence, so the refusal is not a file-existence oracle for the whole filesystem (#114). All of it yields to --trust-workflows, exactly as the import and remote-code gates do. Enforced twice: location_errors() at validation time, so validate_workflow refuses before a pipeline load is spent; and in the loaders, for a location that arrives through a variable or a previous result. Also in this batch: - #113: over a dw.serve --mcp endpoint download_output writes on the server, so its destination is confined to that workspace - realpath containment, not a substring test for '..', and a relative destination is joined onto the workspace rather than onto the server's cwd. A stdio dw-mcp is unchanged, because there 'local disk' is genuinely the caller's own. - #118: additionalProperties:false on step, task, workflow_reference and pipeline_reference (each after a zero-stray-key sweep of the catalog), with the jsonschema message rewritten to name the step, the key and what the object actually takes. pipeline/pipeline_component stay open - a component's name is one of their keys. - #120: get_server_info reports trust_workflows, so a security check can confirm the posture it is testing rather than inferring it from behavior. Co-Authored-By: Claude Opus 5 --- dw/arguments.py | 19 +- dw/locations.py | 521 +++++++++++++++++++++++++++++++ dw/pipeline_processors/remote.py | 37 ++- dw/schema.py | 30 +- dw/server/app.py | 8 + dw/server/mcp_mount.py | 4 + dw/tasks/audio_utils.py | 8 +- dw/tasks/gather.py | 20 +- dw/tasks/video_utils.py | 15 +- dw/workflow.py | 7 + dw/workflow_schema.json | 8 + dw_mcp/client.py | 6 + dw_mcp/media.py | 70 ++++- dw_mcp/server.py | 5 +- tests/test_arguments.py | 22 +- tests/test_examples.py | 21 ++ tests/test_gather.py | 6 +- tests/test_locations.py | 415 ++++++++++++++++++++++++ tests/test_mcp_media.py | 96 ++++++ tests/test_schema.py | 87 ++++++ tests/test_server_info.py | 17 + 21 files changed, 1372 insertions(+), 50 deletions(-) create mode 100644 dw/locations.py create mode 100644 tests/test_locations.py diff --git a/dw/arguments.py b/dw/arguments.py index 8824fd80..4d57ff4b 100644 --- a/dw/arguments.py +++ b/dw/arguments.py @@ -17,6 +17,7 @@ ALLOWED_VIDEO_EXTENSIONS, ALLOWED_AUDIO_EXTENSIONS, ) +from .locations import validate_media_path, validate_media_url logger = logging.getLogger("dw") @@ -931,12 +932,13 @@ def fetch_image(img_spec, base_dir=None): if isinstance(img_spec, str) and ( img_spec.startswith("http://") or img_spec.startswith("https://") ): - validated_url = validate_url(img_spec) + validated_url = validate_media_url(img_spec, "an image argument") return load_image(validated_url) else: - # Treat as file path, relative to the workflow file - validated_path = validate_path( - resolve_relative_path(str(img_spec), base_dir), allow_create=False + # Treat as file path, relative to the workflow file, and confined + # to the directories this workflow may read (dw/locations.py) + validated_path = validate_media_path( + str(img_spec), base_dir, "an image argument" ) # Validate file extension ext = os.path.splitext(validated_path)[1].lower() @@ -1028,12 +1030,13 @@ def fetch_video(video_spec, base_dir=None): if isinstance(video_spec, str) and ( video_spec.startswith("http://") or video_spec.startswith("https://") ): - validated_url = validate_url(video_spec) + validated_url = validate_media_url(video_spec, "a video argument") return _with_frame_rate(load_video(validated_url), validated_url) else: - # Treat as file path, relative to the workflow file - validated_path = validate_path( - resolve_relative_path(str(video_spec), base_dir), allow_create=False + # Treat as file path, relative to the workflow file, and confined + # to the directories this workflow may read (dw/locations.py) + validated_path = validate_media_path( + str(video_spec), base_dir, "a video argument" ) # Validate file extension ext = os.path.splitext(validated_path)[1].lower() diff --git a/dw/locations.py b/dw/locations.py new file mode 100644 index 00000000..4b1ce6df --- /dev/null +++ b/dw/locations.py @@ -0,0 +1,521 @@ +"""The one policy for a location a workflow's arguments supply. + +A workflow JSON is untrusted input under the default posture (see +docs/SECURITY.md's Trust model section). Its media arguments name *where* to +read from, and until this module existed each loader answered that question +for itself: `fetch_image` accepted any absolute path the JSON wrote, +`gather_images` handed its `glob` straight to the filesystem, and any +`http(s)` URL was fetched whatever host it named. That is arbitrary file read +and SSRF from a document the server treats as data (#114, #115, #116, #112). + +Two rules, applied wherever a caller-supplied location is resolved: + +- A path must land inside one of the roots this installation already works + in - the workflow's own directory, the asset libraries on the search path, + the output root. `..` never appears in a legitimate one and `validate_path` + already refuses it, so in practice this closes the *absolute* path that + pointed somewhere else entirely. The remedy is an `asset:` reference, which + is what the roots exist for. +- An `http(s)` URL must not name a host inside the deployment - loopback, + link-local (the cloud metadata address), or a private range. The check runs + on the resolved address, not on the literal string, so a hostname that + answers 127.0.0.1 is caught too. + +Both yield to `--trust-workflows`, exactly as the import and remote-code +gates do: an operator who has vouched for a workflow's source may point it at +a scratch directory or an internal endpoint. Neither yields to anything else - +there is no per-argument opt-out, because the argument is the untrusted part. + +Enforcement is in two places on purpose. `location_errors` runs at validation +time, so `validate_workflow` refuses the workflow before a model load is +spent on it; the loaders call the same functions at run time, because a +location that arrives through a variable or a previous result was never in +the document to check. +""" + +import ipaddress +import logging +import os +import socket +from urllib.parse import urlparse + +from .security import ( + InvalidInputError, + PathTraversalError, + validate_path, + validate_url, + workflows_are_trusted, +) + +logger = logging.getLogger("dw") + +# Media argument names follow the same conventions realize_args dispatches on +# (dw/arguments.py): a key named like its media, or an explicit +# {"media_type", "location"} reference, or an object's "from_file" +MEDIA_KEY_SUFFIXES = ("_image", "_video", "_audio") +MEDIA_KEY_NAMES = ("image", "video", "audio", "location", "from_file") + +# The tasks whose arguments name a filesystem pattern rather than one file +GLOB_ARGUMENT = "glob" + + +def is_http_url(value): + """Whether a value is a string the loaders would fetch over HTTP.""" + return isinstance(value, str) and value.startswith(("http://", "https://")) + + +def media_roots(base_dir=None): + """Every directory a workflow's own locations may point inside, resolved. + + The workflow's directory, each asset library on the search path, and the + output root - the three places this installation keeps the media a + workflow works with. A root that cannot be resolved (no workspace, no + active run) is dropped rather than failing the check open. + + Args: + base_dir: The workflow file's directory, when one anchors the search + """ + candidates = [] + if base_dir: + candidates.append(base_dir) + + from .assets import asset_search_path + + try: + candidates.extend(asset_search_path(base_dir=base_dir)) + except Exception: + logger.debug("Could not resolve the asset search path", exc_info=True) + + from .runs import output_root + + try: + candidates.append(output_root()) + except Exception: + logger.debug("Could not resolve the output root", exc_info=True) + + roots = [] + for candidate in candidates: + if not candidate: + continue + try: + resolved = os.path.normpath( + os.path.realpath(os.path.abspath(os.path.expanduser(str(candidate)))) + ) + except (OSError, ValueError): + continue + if resolved not in roots: + roots.append(resolved) + return roots + + +def _within(path, root): + return path == root or path.startswith(root + os.sep) + + +def validate_media_path( + location, base_dir=None, what="a media argument", require_exists=True +): + """The validated absolute path a media location names, confined. + + Args: + location: The path the workflow supplied, relative or absolute + base_dir: Directory a relative path is resolved against - the + workflow file's directory + what: Short phrase naming the argument, for the error message + require_exists: Whether a contained path that does not exist is an + error. False for the validation-time pass, which is about policy + rather than about what happens to be on disk right now + + Returns: + The absolute, resolved, contained path + + Raises: + PathTraversalError: If the path resolves outside every root + InvalidInputError, PathTraversalError: Whatever validate_path raises + """ + # base_dir is the first root, so a relative path keeps resolving against + # the workflow file exactly as it did before this check existed. + # allow_create here, with the existence check moved below the containment + # one: refusing an out-of-root path only once it turned out to exist made + # the refusal itself a file-existence oracle for the whole filesystem + # (#114) + resolved = validate_path( + location if os.path.isabs(str(location)) else _joined(location, base_dir), + allow_create=True, + ) + if not workflows_are_trusted(): + roots = media_roots(base_dir) + if not any(_within(resolved, root) for root in roots): + raise PathTraversalError( + f"Refusing to read {what} at '{location}': it resolves " + f"outside every directory this workflow may read " + f"({', '.join(roots) or 'none configured'}). Put the file in " + f"the asset library and name it with an 'asset:' reference, " + f"or pass --trust-workflows if you trust this workflow's " + f"source." + ) + + if require_exists and not os.path.exists(resolved): + raise InvalidInputError(f"Path does not exist: {resolved}") + return resolved + + +def _joined(location, base_dir): + return os.path.join(base_dir, str(location)) if base_dir else str(location) + + +def validate_media_glob(pattern, base_dir=None, what="a glob argument"): + """The validated glob pattern, confined to one of the media roots. + + A glob is a location with wildcards in it, and it is checked the same + way - but on the pattern's fixed leading directory, since the pattern + itself does not exist as a path. Each *match* is checked individually + too by the loader that opens it, which is what catches a wildcard + escaping through a symlink. + + Returns: + The pattern, absolute, for glob to expand + """ + absolute = pattern if os.path.isabs(str(pattern)) else _joined(pattern, base_dir) + if workflows_are_trusted(): + return absolute + + # The part of the pattern before the first wildcard: the directory the + # expansion starts from, which is the thing containment is about + fixed = str(absolute) + for wildcard in ("*", "?", "["): + cut = fixed.find(wildcard) + if cut >= 0: + fixed = fixed[:cut] + fixed = os.path.dirname(fixed) if not fixed.endswith(os.sep) else fixed + if ".." in fixed.replace("\\", "/").split("/"): + raise PathTraversalError( + f"Refusing {what} '{pattern}': it contains a '..' path segment." + ) + try: + resolved = os.path.normpath(os.path.realpath(os.path.abspath(fixed or "."))) + except (OSError, ValueError) as e: + raise InvalidInputError(f"Invalid glob pattern {pattern!r}: {e}") + + roots = media_roots(base_dir) + if not any(_within(resolved, root) for root in roots): + raise PathTraversalError( + f"Refusing {what} '{pattern}': it expands under {resolved}, " + f"outside every directory this workflow may read " + f"({', '.join(roots) or 'none configured'}). Glob inside the " + f"asset library, or pass --trust-workflows if you trust this " + f"workflow's source." + ) + return absolute + + +def contained_matches(paths, base_dir=None, what="a glob argument"): + """The matches of an allowed glob that are themselves inside a root. + + A pattern can be contained and still match outside its own tree through + a symlink, so every match is re-checked on its real path. A match that + escapes is dropped with a warning rather than failing the run: the + pattern was legitimate, one entry under it was not. + """ + if workflows_are_trusted(): + return list(paths) + + roots = media_roots(base_dir) + kept = [] + for path in paths: + try: + resolved = os.path.normpath(os.path.realpath(os.path.abspath(path))) + except (OSError, ValueError): + continue + if any(_within(resolved, root) for root in roots): + kept.append(path) + else: + logger.warning( + f"Skipping {what} match '{path}': it resolves to {resolved}, " + f"outside every directory this workflow may read" + ) + return kept + + +# Hosts a workflow may not send the server to: its own loopback, the +# link-local range cloud metadata services answer on, and the private ranges +# that make up whatever network the box sits in. This is the SSRF boundary - +# an internal address is exactly the thing a caller cannot otherwise reach, +# which is why naming one is the attack rather than a mistake +def _is_internal(address): + return ( + address.is_loopback + or address.is_link_local + or address.is_private + or address.is_reserved + or address.is_multicast + or address.is_unspecified + ) + + +def _resolved_addresses(host): + """Every IP a hostname answers on, as ip_address objects. + + A literal is returned as itself without a lookup. A name that does not + resolve yields nothing - the fetch will fail on its own, and refusing it + here would turn a typo into a security error. + """ + try: + return [ipaddress.ip_address(host)] + except ValueError: + pass + try: + infos = socket.getaddrinfo(host, None) + except (socket.gaierror, UnicodeError, OSError): + logger.debug(f"Could not resolve {host!r} for the host policy") + return [] + addresses = [] + for info in infos: + try: + addresses.append(ipaddress.ip_address(info[4][0])) + except ValueError: + continue + return addresses + + +def validate_media_url(url, what="a media argument"): + """The validated URL, refused if it names a host inside the deployment. + + Args: + url: The http(s) URL the workflow supplied + what: Short phrase naming the argument, for the error message + + Returns: + The URL, unchanged + + Raises: + InvalidInputError: If the scheme is not http(s), or the host is + internal to the deployment + """ + validated = validate_url(url) + if workflows_are_trusted(): + return validated + + host = (urlparse(validated).hostname or "").strip("[]") + internal = [ + address for address in _resolved_addresses(host) if _is_internal(address) + ] + if internal: + raise InvalidInputError( + f"Refusing to fetch {what} from '{url}': {host} resolves to " + f"{internal[0]}, an address inside this deployment (loopback, " + f"link-local or private). A workflow may not use the server to " + f"reach its own network. Pass --trust-workflows if you trust " + f"this workflow's source." + ) + return validated + + +# Hosts this machine's HuggingFace token belongs to. The token is the +# credential the box holds for the Hub; a workflow chooses +# `remote_text_encoder.url`, so attaching the token to whatever it named +# would let an untrusted document exfiltrate it with one POST (#112). An +# endpoint outside these is still reachable - it just does not get the +# credential, and an endpoint that needs one is by definition a HuggingFace +# endpoint +HF_TOKEN_HOST_SUFFIXES = ( + "huggingface.co", + "huggingface.cloud", + "hf.space", +) + + +def token_host_allowed(host): + """Whether the HuggingFace token may be attached to a request to `host`.""" + host = (host or "").lower() + return any( + host == suffix or host.endswith("." + suffix) + for suffix in HF_TOKEN_HOST_SUFFIXES + ) + + +def validate_remote_encoder_url(url): + """The validated remote text-encoder URL, or a refusal saying why. + + https only, and no address inside this deployment: the workflow file is + untrusted input, and this field sends a request - with a credential - to + an address it chooses. `--trust-workflows` lifts both, for an operator + running their own endpoint on the box or over plain http on a LAN. + + Raises: + InvalidInputError: On a non-https scheme or an internal host + """ + if not workflows_are_trusted(): + scheme = urlparse(str(url)).scheme + if scheme != "https": + raise InvalidInputError( + f"Refusing the remote text encoder at '{url}': its scheme is " + f"'{scheme or 'none'}', and an untrusted workflow may only " + f"reach an https endpoint - the request carries this " + f"machine's HuggingFace token. Pass --trust-workflows if you " + f"trust this workflow's source." + ) + return validate_media_url(url, "the remote text encoder url") + + +def validate_model_name(name, base_dir=None): + """A model identifier: a Hub repo id, or a path inside the media roots. + + `download_model` has always refused a path-shaped `repo_id`; the same + name reached `from_pretrained_arguments.model_name` unchecked, so a + workflow could name an absolute path and let diffusers decide (#117). + A local model directory stays supported - inside a root, like any other + location. + + Raises: + PathTraversalError, InvalidInputError: If it is neither + """ + from huggingface_hub.utils import HFValidationError, validate_repo_id + + try: + validate_repo_id(str(name)) + return str(name) + except HFValidationError: + pass + return validate_media_path( + str(name), base_dir, "a model_name", require_exists=False + ) + + +# ------------------------------------------------------------- validation + + +def _is_media_key(key): + return isinstance(key, str) and ( + key in MEDIA_KEY_NAMES or key.endswith(MEDIA_KEY_SUFFIXES) + ) + + +def _deferred(value): + """Whether a location is resolved later rather than being one now.""" + return value.startswith( + ( + "variable:", + "previous_result:", + "item:", + "gather:", + "asset:", + "output:", + "prompt:", + "constant:", + "builtin:", + ) + ) + + +def _check(value, base_dir, what): + """The policy message for one literal location, or None if it is fine.""" + if not isinstance(value, str) or not value or _deferred(value): + return None + try: + if is_http_url(value): + validate_media_url(value, what) + elif os.path.isabs(value): + # Only an absolute path can escape: validate_path already + # refuses '..', so a relative one is under base_dir by + # construction and costs a stat nobody asked for here + validate_media_path(value, base_dir, what, require_exists=False) + except (PathTraversalError, InvalidInputError) as e: + return str(e) + except Exception: + # A path that simply does not exist is not this check's business - + # the loader will say so, with the run's own error + logger.debug(f"Location check skipped for {value!r}", exc_info=True) + return None + + +def location_errors(definition, source_indices=None, base_dir=None): + """Every media location in the definition that policy refuses. + + Reported as [{path, message}] like the other validation passes, so a + caller learns before `run_workflow` that the workflow will not be + allowed to read what it names - rather than after a pipeline load. + + Args: + definition: The expanded, substituted workflow definition + source_indices: Step index -> index in the file the author wrote + base_dir: The workflow file's directory + """ + errors = [] + steps = definition.get("steps") or [] + for index, step in enumerate(steps): + if not isinstance(step, dict): + continue + source = ( + source_indices[index] + if source_indices and index < len(source_indices) + else index + ) + _walk(step, f"steps[{source}]", base_dir, errors) + return errors + + +def _walk(node, path, base_dir, errors): + if isinstance(node, dict): + for key, value in node.items(): + here = f"{path}.{key}" + if key == "model_name" and isinstance(value, str): + message = _model_name_message(value, base_dir) + if message: + errors.append({"path": here, "message": message}) + continue + if key == "remote_text_encoder" and isinstance(value, dict): + url = value.get("url") + if isinstance(url, str) and not _deferred(url): + message = _refusal(validate_remote_encoder_url, url) + if message: + errors.append({"path": f"{here}.url", "message": message}) + continue + if key == GLOB_ARGUMENT and isinstance(value, str): + message = _glob_message(value, base_dir) + if message: + errors.append({"path": here, "message": message}) + continue + if _is_media_key(key): + for sub_path, item in _each(value, here): + message = _check(item, base_dir, f"'{key}'") + if message: + errors.append({"path": sub_path, "message": message}) + if key == "urls" and isinstance(value, list): + for sub_path, item in _each(value, here): + message = _check(item, base_dir, f"'{key}'") + if message: + errors.append({"path": sub_path, "message": message}) + _walk(value, here, base_dir, errors) + elif isinstance(node, list): + for index, item in enumerate(node): + _walk(item, f"{path}[{index}]", base_dir, errors) + + +def _each(value, path): + if isinstance(value, list): + return [(f"{path}[{i}]", item) for i, item in enumerate(value)] + return [(path, value)] + + +def _glob_message(pattern, base_dir): + if _deferred(pattern): + return None + return _refusal(validate_media_glob, pattern, base_dir) + + +def _model_name_message(name, base_dir): + if _deferred(name): + return None + return _refusal(validate_model_name, name, base_dir) + + +def _refusal(check, *args): + """The message `check` refused its argument with, or None if it allowed it.""" + try: + check(*args) + except (PathTraversalError, InvalidInputError) as e: + return str(e) + except Exception: + logger.debug(f"Location check skipped for {args[0]!r}", exc_info=True) + return None diff --git a/dw/pipeline_processors/remote.py b/dw/pipeline_processors/remote.py index 1008d0c1..c9eab0a1 100644 --- a/dw/pipeline_processors/remote.py +++ b/dw/pipeline_processors/remote.py @@ -1,18 +1,35 @@ +import io +import logging +from urllib.parse import urlparse + +import requests import torch from huggingface_hub import get_token -import requests -import io + +from ..locations import ( + HF_TOKEN_HOST_SUFFIXES, + token_host_allowed, + validate_remote_encoder_url, +) +from ..security import workflows_are_trusted + +logger = logging.getLogger("dw") def remote_text_encoder(prompts, url, device): - response = requests.post( - url, - json={"prompt": prompts}, - headers={ - "Authorization": f"Bearer {get_token()}", - "Content-Type": "application/json", - }, - ) + url = validate_remote_encoder_url(url) + headers = {"Content-Type": "application/json"} + host = urlparse(url).hostname + if token_host_allowed(host) or workflows_are_trusted(): + headers["Authorization"] = f"Bearer {get_token()}" + else: + logger.warning( + f"Not sending the HuggingFace token to {host}: it is outside " + f"{', '.join(HF_TOKEN_HOST_SUFFIXES)}. If the endpoint needs the " + f"token, run with --trust-workflows." + ) + + response = requests.post(url, json={"prompt": prompts}, headers=headers) content_type = response.headers.get("Content-Type", "") # An endpoint that has moved or been retired answers with an HTML page, # and torch.load's unpickling error about it names nothing a reader diff --git a/dw/schema.py b/dw/schema.py index 7f79ec48..3d40e432 100644 --- a/dw/schema.py +++ b/dw/schema.py @@ -13,7 +13,7 @@ def validate_data(data, schema): except ValidationError as ve: path = json_path(ve.absolute_path) location = f" at {path}" if path else "" - return False, f"Validation error{location}: {ve.message}" + return False, f"Validation error{location}: {error_message(ve)}" except json.JSONDecodeError as je: return False, f"JSON parsing error: {str(je)}" except Exception as e: @@ -38,7 +38,7 @@ def validate_data_all(data, schema): seen = {} for error in validator.iter_errors(data): chosen = best_match([error]) - key = (json_path(chosen.absolute_path), chosen.message) + key = (json_path(chosen.absolute_path), error_message(chosen)) seen.setdefault(key, None) ordered = sorted(seen, key=lambda key: (key[0] or "", key[1])) return [ @@ -47,6 +47,32 @@ def validate_data_all(data, schema): ] +def error_message(error): + """The message an agent can act on for one schema violation. + + Only `additionalProperties` is rewritten. jsonschema says "Additional + properties are not allowed ('when' was unexpected)", which does not say + what the object *does* take - and the whole point of closing these + objects is that an invented key ('when', 'retry') or a mistyped real one + ('relase_pipeline') is a silent no-op the engine never reads (#118). So + the message names the object, the key, and the full set that is read. + """ + if error.validator != "additionalProperties": + return error.message + + allowed = sorted((error.schema or {}).get("properties") or {}) + instance = error.instance if isinstance(error.instance, dict) else {} + unknown = sorted(key for key in instance if key not in allowed) + name = instance.get("name") + named = f'"{name}": ' if isinstance(name, str) and name else "" + keys = ", ".join(f'"{key}"' for key in unknown) or "a property" + plural = "properties" if len(unknown) > 1 else "property" + return ( + f"{named}unknown {plural} {keys} - the engine reads only " + f"{', '.join(allowed)}, so anything else would be silently ignored" + ) + + def format_validation_errors(errors): """The message a raised validation failure carries. diff --git a/dw/server/app.py b/dw/server/app.py index 3e3ebc41..efe4eb78 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -39,6 +39,7 @@ validate_commit_hash, InvalidInputError, SecurityError, + workflows_are_trusted, ) from ..introspection import ( describe_class, @@ -2981,6 +2982,13 @@ def server_info(): "port": port, "wildcard_bind": wildcard_bind, "auth_required": bool(token), + # The posture a security check has to know it is testing: with + # this off, a workflow file is untrusted input - no arbitrary + # imports, no remote code, no location outside the workspace's + # roots. It is not a secret (the refusals name the flag), and + # without it the posture could only be inferred from behavior + # (#120) + "trust_workflows": workflows_are_trusted(), "mcp": {"mounted": bool(app.state.mcp_mounted), "path": MCP_PATH}, "addresses": addresses, "directories": { diff --git a/dw/server/mcp_mount.py b/dw/server/mcp_mount.py index fb69b573..f3a36771 100644 --- a/dw/server/mcp_mount.py +++ b/dw/server/mcp_mount.py @@ -75,6 +75,10 @@ def build_mcp_app(*, host, port, token): from dw_mcp.server import build_server client = DwClient(base_url=client_base_url(host, port), token=token) + # The tools now run *on* the server rather than on the agent's machine, + # so a tool that writes a local file is writing on the GPU box. It has to + # know that to confine where it writes (#113) + client.mounted = True server = build_server(client) asgi = server.streamable_http_app( # the SDK app routes at "/"; the parent routes /mcp here and diff --git a/dw/tasks/audio_utils.py b/dw/tasks/audio_utils.py index b0aba909..859804b8 100644 --- a/dw/tasks/audio_utils.py +++ b/dw/tasks/audio_utils.py @@ -219,7 +219,9 @@ def load_audio(location, base_dir=None): if location.startswith(("http://", "https://")): import requests - validated_url = validate_url(location) + from ..locations import validate_media_url + + validated_url = validate_media_url(location, "an audio argument") logger.debug(f"Downloading audio from {validated_url}") response = requests.get(validated_url, timeout=60) response.raise_for_status() @@ -227,7 +229,9 @@ def load_audio(location, base_dir=None): io.BytesIO(response.content), dtype="float32" ) else: - validated_path = validate_path(location, base_dir=base_dir, allow_create=False) + from ..locations import validate_media_path + + validated_path = validate_media_path(location, base_dir, "an audio argument") validate_file_extension(validated_path, ALLOWED_AUDIO_EXTENSIONS) logger.debug(f"Reading audio from {validated_path}") data, sample_rate = soundfile.read(validated_path, dtype="float32") diff --git a/dw/tasks/gather.py b/dw/tasks/gather.py index 02803699..3e6b3f11 100644 --- a/dw/tasks/gather.py +++ b/dw/tasks/gather.py @@ -2,7 +2,8 @@ import logging from diffusers.utils import load_image from ..arguments import fetch_image -from ..security import validate_url, SecurityError +from ..security import SecurityError +from ..locations import contained_matches, validate_media_glob, validate_media_url from .video_utils import load_audio_video logger = logging.getLogger("dw") @@ -30,10 +31,17 @@ def gather_images(glob=None, urls=None): # Load local images matching glob pattern if glob is not None: logger.debug(f"Searching for images matching pattern: {glob}") + # The pattern is a location like any other, and goes through the same + # policy: it may only expand inside the directories this workflow may + # read, and each match is re-checked because a wildcard can leave the + # tree through a symlink (dw/locations.py) + pattern = validate_media_glob(glob, what="the images glob") # Sorted, because glob returns filesystem order: a numbered sequence # of images gathered for concatenation has to come back in its own # order, not in whatever order the directory happens to hold - image_paths = sorted(glob_lib.glob(glob)) + image_paths = contained_matches( + sorted(glob_lib.glob(pattern)), what="the images glob" + ) logger.info(f"Found {len(image_paths)} local images") for path in image_paths: @@ -52,7 +60,7 @@ def gather_images(glob=None, urls=None): for url in urls: try: logger.debug(f"Loading image from URL: {url}") - validated_url = validate_url(url) + validated_url = validate_media_url(url, "a gathered image url") images.append(load_image(validated_url)) except SecurityError: raise @@ -102,10 +110,14 @@ def gather_videos(glob=None, urls=None): # Load local videos matching glob pattern if glob is not None: logger.debug(f"Searching for videos matching pattern: {glob}") + # Same containment as gather_images - one policy, two tasks + pattern = validate_media_glob(glob, what="the videos glob") # Sorted, because glob returns filesystem order: a numbered sequence # of videos gathered for concatenation has to come back in its own # order, not in whatever order the directory happens to hold - video_paths = sorted(glob_lib.glob(glob)) + video_paths = contained_matches( + sorted(glob_lib.glob(pattern)), what="the videos glob" + ) logger.info(f"Found {len(video_paths)} local videos") for path in video_paths: diff --git a/dw/tasks/video_utils.py b/dw/tasks/video_utils.py index ecbf3eb5..f23596a4 100644 --- a/dw/tasks/video_utils.py +++ b/dw/tasks/video_utils.py @@ -259,12 +259,8 @@ def load_audio_video(location, base_dir=None): carries an audio stream, its waveform as a (channels, samples) float32 array with the stream's sample rate """ - from ..security import ( - ALLOWED_VIDEO_EXTENSIONS, - validate_file_extension, - validate_path, - validate_url, - ) + from ..security import ALLOWED_VIDEO_EXTENSIONS, validate_file_extension + from ..locations import validate_media_path, validate_media_url if _URL_SCHEME.match(location): import io @@ -272,14 +268,15 @@ def load_audio_video(location, base_dir=None): # Any other scheme - ftp:, file:, data: - is refused here rather than # falling through to be read as a relative path that happens to - # contain a colon - validated_url = validate_url(location) + # contain a colon. An http(s) one still has to name a host outside + # this deployment (dw/locations.py) + validated_url = validate_media_url(location, "a video argument") logger.debug(f"Downloading video from {validated_url}") response = requests.get(validated_url, timeout=300) response.raise_for_status() handle = io.BytesIO(response.content) else: - validated_path = validate_path(location, base_dir=base_dir, allow_create=False) + validated_path = validate_media_path(location, base_dir, "a video argument") validate_file_extension(validated_path, ALLOWED_VIDEO_EXTENSIONS) logger.debug(f"Reading video from {validated_path}") handle = validated_path diff --git a/dw/workflow.py b/dw/workflow.py index f1df0684..c81f5eb0 100644 --- a/dw/workflow.py +++ b/dw/workflow.py @@ -27,6 +27,7 @@ StepResults, previous_result_reference_errors, ) +from .locations import location_errors from .subfolders import step_subfolder, subfolder_errors from .step import Step from .step_cache import ( @@ -583,9 +584,15 @@ def validation_errors(self, arguments=None, composing=None): # reported against 'variables' as a whole rather than escaping # as an unhandled exception return [{"path": "variables", "message": str(e)}] + base_dir = ( + os.path.dirname(os.path.abspath(self.file_spec)) if self.file_spec else None + ) return ( previous_result_reference_errors(expanded, source_indices) + subfolder_errors(expanded, source_indices) + # A location policy refuses before a model load is spent on the + # run rather than after it (dw/locations.py) + + location_errors(expanded, source_indices, base_dir) + self.sub_workflow_errors(expanded, source_indices, composing) ) diff --git a/dw/workflow_schema.json b/dw/workflow_schema.json index 37884d58..37d3d258 100644 --- a/dw/workflow_schema.json +++ b/dw/workflow_schema.json @@ -111,6 +111,8 @@ }, "step": { "type": "object", + "additionalProperties": false, + "$comment": "A step is closed: the engine reads only the properties above, so an invented control-flow key ('when', 'retry') or a mistyped real one ('relase_pipeline') is a hard error rather than a silent no-op.", "properties": { "name": { "type": "string" @@ -199,6 +201,8 @@ }, "pipeline_reference": { "type": "object", + "additionalProperties": false, + "$comment": "Closed - see 'step'.", "properties": { "reference_name": { "type": "string" @@ -1051,6 +1055,8 @@ }, "task": { "type": "object", + "additionalProperties": false, + "$comment": "Closed - see 'step'. A task's own arguments are open; it is the task object itself that is fixed.", "properties": { "command": { "type": "string" @@ -1088,6 +1094,8 @@ }, "workflow_reference": { "type": "object", + "additionalProperties": false, + "$comment": "Closed - see 'step'.", "properties": { "path": { "description": "The workflow this step composes: a catalog name as list_workflows reports it (with or without '.json'), a path relative to the workflow that names it ('../models/x.json'), or 'builtin:name.json' for one of the packaged fragments. A name is resolved first beside the referencing file, then against this run's own workflows directory, then against each read-only source on the server's search path - so a stored template can be composed without copying it into the workspace. A path outside every source is refused. When the composing step declares a 'result', that is where the composed output is saved and the composed workflow's own last step does not save it again.", diff --git a/dw_mcp/client.py b/dw_mcp/client.py index 725a9166..210603f3 100644 --- a/dw_mcp/client.py +++ b/dw_mcp/client.py @@ -105,6 +105,12 @@ def __init__( workspace=None, ): self.base_url = resolve_base_url(base_url) + # True only for the client dw.serve --mcp builds for its own mounted + # tool surface (dw/server/mcp_mount.py). It is what distinguishes + # "local disk" meaning the caller's own machine (stdio dw-mcp) from + # it meaning the GPU box a remote agent is talking to - which decides + # whether download_output may write outside the workspace (#113) + self.mounted = False # Mutable: use_workspace switches it for the rest of the session, # which is what makes a switch one visible call rather than a # parameter on every tool diff --git a/dw_mcp/media.py b/dw_mcp/media.py index da19981b..e0387581 100644 --- a/dw_mcp/media.py +++ b/dw_mcp/media.py @@ -141,6 +141,57 @@ def delete_output(client, name, workspace=None): return client.delete_json(api_path("api", "gallery", name), workspace=workspace) +def _remote_root(client): + """The workspace a remote write is confined to, or None when local. + + Only the mounted MCP surface is remote: there the tool runs inside + dw.serve, so the path a caller names is a path on the operator's box + rather than on its own machine. A stdio `dw-mcp` returns None and keeps + writing wherever the user can. + """ + if not getattr(client, "mounted", False): + return None + + directories = (client.get_json("/api/server").get("directories")) or {} + root = directories.get("workspace") + if not root: + raise DwApiError( + "This server cannot say where its workspace is, so it will not " + "write a file for you. Use the url list_gallery reports, " + "get_output_image / get_output_text, or keep_output." + ) + return os.path.realpath(os.path.abspath(os.path.expanduser(str(root)))) + + +def _confine(destination, root): + """Refuse a destination outside `root`, on the resolved real path. + + Containment is on realpath, not on a substring: an absolute path or a + '~' needs no '..' to reach anywhere the server process can write (#113), + and a symlink inside the workspace would otherwise carry the write out. + """ + # realpath of the nearest existing ancestor: the file itself usually does + # not exist yet, and realpath of a missing path leaves symlinks in its + # existing prefix unresolved on some platforms + probe = destination + while not os.path.exists(probe) and os.path.dirname(probe) != probe: + probe = os.path.dirname(probe) + resolved = os.path.join( + os.path.realpath(probe), os.path.relpath(destination, probe) + ) + resolved = os.path.normpath(resolved) + if resolved != root and not resolved.startswith(root + os.sep): + raise DwApiError( + f"Refusing to write {destination} - this MCP endpoint is served " + f"by dw.serve, so the file would land on the server, where a " + f"destination is confined to the workspace ({root}). Pass a " + f"relative destination, or - to see the file where you are - use " + f"the url list_gallery reports, get_output_image / " + f"get_output_text for inline content, or keep_output to make it " + f"an asset for a later workflow." + ) + + def download_output(client, name, destination=None, overwrite=False, workspace=None): """Fetch one output file and save it to local disk, for an agent that wants the artifact itself rather than a description of it. @@ -158,6 +209,12 @@ def download_output(client, name, destination=None, overwrite=False, workspace=N directory. Missing parent directories are created. A `destination` containing a '..' path segment is refused. An existing file at the resolved path is left alone unless `overwrite=True`. + + Over a `dw.serve --mcp` endpoint the file lands on the *server*, not on + the calling agent's machine, so there the destination is confined to that + workspace: an absolute or '~' path outside it is refused rather than + written (#113). A stdio `dw-mcp` keeps writing anywhere the user can, + because there "local disk" is genuinely their own. """ if destination is None: destination = os.path.basename(name) @@ -169,7 +226,18 @@ def download_output(client, name, destination=None, overwrite=False, workspace=N ) if os.path.isdir(destination) or destination.endswith(os.sep): destination = os.path.join(destination, os.path.basename(name)) - destination = os.path.abspath(destination) + # A relative destination is joined onto whichever directory is "here" for + # this transport: the caller's own working directory for stdio, the + # server's workspace when the tool runs inside dw.serve - where the + # process's cwd is an implementation detail the caller never chose + root = _remote_root(client) + destination = ( + os.path.abspath(os.path.join(root, destination)) + if root and not os.path.isabs(destination) + else os.path.abspath(destination) + ) + if root: + _confine(destination, root) if os.path.exists(destination) and not overwrite: raise DwApiError( diff --git a/dw_mcp/server.py b/dw_mcp/server.py index 979462b5..2d434022 100644 --- a/dw_mcp/server.py +++ b/dw_mcp/server.py @@ -312,7 +312,10 @@ def get_server_info() -> dict: an mps or cpu server, and `directories` is what a path passed to run_workflow or download_output is relative to. If this session works in a named workspace, `directories` are scoped to that - workspace.""" + workspace. `trust_workflows` reports the posture a submitted + workflow is read under: false - the default - means the file is + untrusted input, so an out-of-ecosystem import, remote code, and a + media location outside the workspace's roots are all refused.""" return workspaces.server_info(client) def list_jobs( diff --git a/tests/test_arguments.py b/tests/test_arguments.py index cbf6a892..84afbc55 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -63,7 +63,7 @@ def test_fetch_image_from_file(self): assert loaded_image.size == (100, 100) @patch("dw.arguments.load_image") - @patch("dw.arguments.validate_url") + @patch("dw.arguments.validate_media_url") def test_fetch_image_from_url(self, mock_validate_url, mock_load_image): mock_validate_url.return_value = "https://example.com/image.jpg" mock_image = Image.new("RGB", (100, 100)) @@ -71,7 +71,8 @@ def test_fetch_image_from_url(self, mock_validate_url, mock_load_image): result = fetch_image("https://example.com/image.jpg") - mock_validate_url.assert_called_once_with("https://example.com/image.jpg") + mock_validate_url.assert_called_once() + assert mock_validate_url.call_args[0][0] == "https://example.com/image.jpg" mock_load_image.assert_called_once_with("https://example.com/image.jpg") assert result == mock_image @@ -161,7 +162,7 @@ def test_fetch_video_invalid_type(self): def test_fetch_video_dict_format(self): """Test that video can be specified as dict with 'location' key""" with patch("dw.arguments.load_video") as mock_load: - with patch("dw.arguments.validate_url") as mock_validate: + with patch("dw.arguments.validate_media_url") as mock_validate: mock_validate.return_value = "https://example.com/video.mp4" mock_load.return_value = ["frame1", "frame2"] @@ -175,7 +176,7 @@ def test_fetch_video_dict_missing_location(self): assert "location" in str(exc_info.value).lower() @patch("dw.arguments.load_video") - @patch("dw.arguments.validate_url") + @patch("dw.arguments.validate_media_url") def test_fetch_video_from_url(self, mock_validate_url, mock_load_video): mock_validate_url.return_value = "https://example.com/video.mp4" mock_frames = ["frame1", "frame2"] @@ -183,7 +184,8 @@ def test_fetch_video_from_url(self, mock_validate_url, mock_load_video): result = fetch_video("https://example.com/video.mp4") - mock_validate_url.assert_called_once_with("https://example.com/video.mp4") + mock_validate_url.assert_called_once() + assert mock_validate_url.call_args[0][0] == "https://example.com/video.mp4" mock_load_video.assert_called_once_with("https://example.com/video.mp4") assert result == mock_frames @@ -199,10 +201,10 @@ def test_fetch_video_invalid_extension(self): assert "extension not allowed" in str(exc_info.value) @patch("dw.arguments.load_video") - @patch("dw.arguments.validate_url") + @patch("dw.arguments.validate_media_url") def test_fetch_video_list(self, mock_validate_url, mock_load_video): """Test that fetch_video can handle a list of video specifications""" - mock_validate_url.side_effect = lambda x: x + mock_validate_url.side_effect = lambda url, what=None: url mock_load_video.side_effect = [["frames1"], ["frames2"]] result = fetch_video( @@ -215,10 +217,10 @@ def test_fetch_video_list(self, mock_validate_url, mock_load_video): assert result[1] == ["frames2"] @patch("dw.arguments.load_video") - @patch("dw.arguments.validate_url") + @patch("dw.arguments.validate_media_url") def test_fetch_video_list_with_dicts(self, mock_validate_url, mock_load_video): """Test that fetch_video can handle a list of dict specifications""" - mock_validate_url.side_effect = lambda x: x + mock_validate_url.side_effect = lambda url, what=None: url mock_load_video.side_effect = [["frames1"], ["frames2"]] result = fetch_video( @@ -460,7 +462,7 @@ def test_remaining_keys_are_passed_to_from_file(self): assert args["reference"].arguments == {"fps": 30.0} - @patch("dw.arguments.validate_url") + @patch("dw.arguments.validate_media_url") def test_object_is_constructed_from_a_url(self, mock_validate_url): url = "https://example.com/subject.jpg" mock_validate_url.return_value = url diff --git a/tests/test_examples.py b/tests/test_examples.py index 83663743..84a67ccc 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -107,6 +107,27 @@ def test_example_workflow(example_file): pytest.fail(f"Example {example_file} failed validation: {str(e)}") +@pytest.mark.parametrize("example_file", get_example_files()) +def test_example_workflow_validates_untrusted(example_file, monkeypatch): + """And validates on the posture a server actually runs on. + + conftest's autouse fixture trusts workflows for the whole suite, which + would hide exactly the thing the location policy changed (#114-#117): a + shipped workflow that names a media location outside the roots it is + allowed to read would pass every other test here and fail on the box. + Closing the step object (#118) is checked by the same pass. + """ + from dw.security import TRUST_WORKFLOWS_ENV_VAR + + monkeypatch.setenv(TRUST_WORKFLOWS_ENV_VAR, "0") + path = os.path.join(REPO_ROOT, example_file) + try: + workflow = workflow_from_file(path, ".") + workflow.validate() + except Exception as e: + pytest.fail(f"Example {example_file} failed untrusted validation: {str(e)}") + + @pytest.mark.parametrize("example_file", get_example_files()) def test_example_workflow_references_resolve(example_file): """Test that every sub-workflow an example references exists""" diff --git a/tests/test_gather.py b/tests/test_gather.py index 61575c4e..957f230a 100644 --- a/tests/test_gather.py +++ b/tests/test_gather.py @@ -78,10 +78,10 @@ def test_gather_images_sorted_not_filesystem_order(self, tmp_path): ] @patch("dw.tasks.gather.load_image") - @patch("dw.tasks.gather.validate_url") + @patch("dw.tasks.gather.validate_media_url") def test_gather_images_from_urls(self, mock_validate_url, mock_load_image): """Test gathering images from URLs""" - mock_validate_url.side_effect = lambda x: x + mock_validate_url.side_effect = lambda url, what=None: url mock_image = Image.new("RGB", (100, 100)) mock_load_image.return_value = mock_image @@ -104,7 +104,7 @@ def test_gather_images_mixed_sources(self): glob_pattern = os.path.join(temp_dir, "*.jpg") with patch("dw.tasks.gather.load_image") as mock_load: - with patch("dw.tasks.gather.validate_url") as mock_validate: + with patch("dw.tasks.gather.validate_media_url") as mock_validate: mock_validate.return_value = "https://example.com/remote.jpg" mock_load.side_effect = [ Image.new("RGB", (50, 50)), # For file diff --git a/tests/test_locations.py b/tests/test_locations.py new file mode 100644 index 00000000..39133dfe --- /dev/null +++ b/tests/test_locations.py @@ -0,0 +1,415 @@ +""" +Unit tests for dw/locations.py - the one policy for a location a workflow's +arguments supply. + +The regression suite's SE-F014/F015/F018/F009/F019 all reduce to the same +hole: a workflow file is untrusted input, and every loader used to trust the +location it named. These tests are written from the untrusted side, so each +one overrides tests/conftest.py's autouse _trust_workflows_by_default fixture +back to the posture a deployed server actually runs on. +""" + +import os +import tempfile +from unittest.mock import patch + +import pytest +from PIL import Image + +from dw.arguments import fetch_image +from dw.locations import ( + contained_matches, + location_errors, + token_host_allowed, + validate_media_glob, + validate_media_path, + validate_media_url, + validate_model_name, + validate_remote_encoder_url, +) +from dw.security import ( + InvalidInputError, + PathTraversalError, + TRUST_WORKFLOWS_ENV_VAR, +) +from dw.tasks.gather import gather_images + + +@pytest.fixture +def untrusted(monkeypatch): + """The posture a server runs on: workflow files are untrusted input.""" + monkeypatch.setenv(TRUST_WORKFLOWS_ENV_VAR, "0") + + +@pytest.fixture +def trusted(monkeypatch): + monkeypatch.setenv(TRUST_WORKFLOWS_ENV_VAR, "1") + + +@pytest.fixture +def workflow_dir(): + """A directory standing in for the one a workflow file sits in - the + first of the roots a location may point inside.""" + with tempfile.TemporaryDirectory() as directory: + yield directory + + +def _image(path): + Image.new("RGB", (4, 4), "red").save(path) + return path + + +class TestMediaPathContainment: + """SE-F014: a media argument may not name a file outside every root.""" + + def test_absolute_path_outside_every_root_is_refused(self, untrusted, workflow_dir): + with tempfile.TemporaryDirectory() as elsewhere: + outside = _image(os.path.join(elsewhere, "secret.png")) + with pytest.raises(PathTraversalError) as refusal: + validate_media_path(outside, workflow_dir, "an image argument") + assert "outside every directory this workflow may read" in str(refusal.value) + + def test_refusal_does_not_depend_on_the_file_existing( + self, untrusted, workflow_dir + ): + """The oracle SE-F014 names: an out-of-root path that exists and one + that does not must be refused the same way, or the refusal itself + discriminates what is on the filesystem.""" + with tempfile.TemporaryDirectory() as elsewhere: + present = _image(os.path.join(elsewhere, "present.png")) + absent = os.path.join(elsewhere, "absent.png") + + with pytest.raises(PathTraversalError) as for_present: + validate_media_path(present, workflow_dir, "an image argument") + with pytest.raises(PathTraversalError) as for_absent: + validate_media_path(absent, workflow_dir, "an image argument") + + assert str(for_present.value).replace(present, "X") == str( + for_absent.value + ).replace(absent, "X") + + def test_a_path_inside_the_workflow_directory_is_allowed( + self, untrusted, workflow_dir + ): + inside = _image(os.path.join(workflow_dir, "subject.png")) + assert validate_media_path(inside, workflow_dir) == os.path.realpath(inside) + + def test_a_relative_path_still_resolves_against_the_workflow( + self, untrusted, workflow_dir + ): + _image(os.path.join(workflow_dir, "subject.png")) + assert validate_media_path("subject.png", workflow_dir) == os.path.realpath( + os.path.join(workflow_dir, "subject.png") + ) + + def test_trust_lifts_containment(self, trusted, workflow_dir): + with tempfile.TemporaryDirectory() as elsewhere: + outside = _image(os.path.join(elsewhere, "scratch.png")) + assert validate_media_path(outside, workflow_dir) == os.path.realpath( + outside + ) + + def test_fetch_image_refuses_an_out_of_root_absolute_path( + self, untrusted, workflow_dir + ): + """The loader, not just the helper: SE-F014's probe (a) went through + fetch_image and came back with a decoded 48x48 image.""" + with tempfile.TemporaryDirectory() as elsewhere: + outside = _image(os.path.join(elsewhere, "debian-logo.png")) + with pytest.raises(PathTraversalError): + fetch_image(outside, workflow_dir) + + +class TestHostPolicy: + """SE-F018 / SE-F009: a workflow may not send the server at its own + network.""" + + @pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1:8765/api/server", + "http://localhost:8765/api/server", + "http://169.254.169.254/latest/meta-data/", + "http://10.0.0.5/image.png", + "http://192.168.1.4/image.png", + ], + ) + def test_internal_addresses_are_refused(self, untrusted, url): + with pytest.raises(InvalidInputError) as refusal: + validate_media_url(url, "an image argument") + assert "inside this deployment" in str(refusal.value) + + def test_a_hostname_resolving_to_loopback_is_refused(self, untrusted): + """Resolved, not string-matched: a name that answers 127.0.0.1 is + the same request as naming 127.0.0.1.""" + with patch( + "dw.locations.socket.getaddrinfo", + return_value=[(None, None, None, "", ("127.0.0.1", 80))], + ): + with pytest.raises(InvalidInputError): + validate_media_url("http://sneaky.example.com/x.png", "an image") + + def test_a_public_host_is_allowed(self, untrusted): + with patch( + "dw.locations.socket.getaddrinfo", + return_value=[(None, None, None, "", ("93.184.216.34", 80))], + ): + assert ( + validate_media_url("https://example.com/x.png", "an image") + == "https://example.com/x.png" + ) + + def test_a_host_that_does_not_resolve_is_left_to_the_fetch(self, untrusted): + """A typo is the loader's error to report, not a security refusal.""" + import socket + + with patch("dw.locations.socket.getaddrinfo", side_effect=socket.gaierror): + assert validate_media_url("https://nope.invalid/x.png", "an image") + + def test_trust_lifts_the_host_policy(self, trusted): + assert validate_media_url("http://127.0.0.1:8765/x.png", "an image") + + +class TestGlobContainment: + """SE-F015: gather_images' glob enumerated and republished any readable + directory.""" + + def test_an_absolute_glob_outside_every_root_is_refused( + self, untrusted, workflow_dir + ): + with tempfile.TemporaryDirectory() as elsewhere: + _image(os.path.join(elsewhere, "debian-logo.png")) + with pytest.raises(PathTraversalError) as refusal: + validate_media_glob( + os.path.join(elsewhere, "*.png"), workflow_dir, "the images glob" + ) + assert "outside every directory this workflow may read" in str(refusal.value) + + def test_gather_images_refuses_the_uncontained_glob(self, untrusted): + with tempfile.TemporaryDirectory() as elsewhere: + _image(os.path.join(elsewhere, "debian-logo.png")) + with pytest.raises(PathTraversalError): + gather_images(glob=os.path.join(elsewhere, "*.png")) + + def test_a_glob_inside_a_root_is_allowed(self, untrusted, workflow_dir): + _image(os.path.join(workflow_dir, "a.png")) + pattern = validate_media_glob( + os.path.join(workflow_dir, "*.png"), workflow_dir, "the images glob" + ) + assert pattern.endswith("*.png") + + def test_a_match_escaping_through_a_symlink_is_dropped( + self, untrusted, workflow_dir + ): + """The pattern can be contained and still reach out: containment is + re-checked on each match's real path.""" + with tempfile.TemporaryDirectory() as elsewhere: + outside = _image(os.path.join(elsewhere, "outside.png")) + inside = _image(os.path.join(workflow_dir, "inside.png")) + link = os.path.join(workflow_dir, "linked.png") + os.symlink(outside, link) + kept = contained_matches( + sorted([inside, link]), workflow_dir, "the images glob" + ) + assert kept == [inside] + + def test_trust_lifts_glob_containment(self, trusted, workflow_dir): + with tempfile.TemporaryDirectory() as elsewhere: + pattern = os.path.join(elsewhere, "*.png") + assert validate_media_glob(pattern, workflow_dir) == pattern + + +class TestRemoteEncoderUrl: + """SE-F009: the field that POSTs this machine's HuggingFace token.""" + + def test_a_non_https_scheme_is_refused(self, untrusted): + with pytest.raises(InvalidInputError) as refusal: + validate_remote_encoder_url("file:///etc/hostname") + assert "may only reach an https endpoint" in str(refusal.value) + + def test_loopback_is_refused(self, untrusted): + with pytest.raises(InvalidInputError): + validate_remote_encoder_url("http://127.0.0.1:8765/api/server") + + def test_https_loopback_is_refused_too(self, untrusted): + with pytest.raises(InvalidInputError) as refusal: + validate_remote_encoder_url("https://127.0.0.1:8765/encode") + assert "inside this deployment" in str(refusal.value) + + def test_the_token_only_goes_to_huggingface_hosts(self): + assert token_host_allowed("api-inference.huggingface.co") + assert token_host_allowed("abc.endpoints.huggingface.cloud") + assert not token_host_allowed("evil.example.com") + assert not token_host_allowed("huggingface.co.evil.example.com") + + def test_an_untrusted_run_withholds_the_token_from_a_third_party(self, untrusted): + """Reachable, but without the credential - the exfiltration half of + SE-F009 is closed even for a host the host policy allows.""" + from dw.pipeline_processors import remote + + sent = {} + + class _Response: + ok = True + headers = {"Content-Type": "application/octet-stream"} + content = b"" + status_code = 200 + + def _post(url, json=None, headers=None): + sent["headers"] = headers + return _Response() + + with ( + patch.object(remote.requests, "post", _post), + patch.object(remote.torch, "load", return_value=_Embeds()), + patch( + "dw.locations.socket.getaddrinfo", + return_value=[(None, None, None, "", ("93.184.216.34", 443))], + ), + ): + remote.remote_text_encoder(["a"], "https://evil.example.com/encode", "cpu") + + assert "Authorization" not in sent["headers"] + + +class _Embeds: + def to(self, device): + return self + + +class TestModelName: + """SE-F019: download_model checked its repo_id; model_name did not.""" + + def test_a_repo_id_is_allowed(self, untrusted): + assert validate_model_name("stabilityai/sd-turbo") == "stabilityai/sd-turbo" + + def test_an_absolute_path_outside_every_root_is_refused( + self, untrusted, workflow_dir + ): + with pytest.raises(PathTraversalError): + validate_model_name("/etc/passwd", workflow_dir) + + def test_a_traversal_shaped_name_is_refused(self, untrusted, workflow_dir): + with pytest.raises(PathTraversalError): + validate_model_name("org/name/../../x", workflow_dir) + + def test_a_local_model_directory_inside_a_root_is_allowed( + self, untrusted, workflow_dir + ): + local = os.path.join(workflow_dir, "my-model") + os.makedirs(local) + assert validate_model_name(local, workflow_dir) + + +class TestValidationTimeErrors: + """Refused by validate_workflow rather than after a pipeline load.""" + + def test_an_out_of_root_image_is_an_error_with_its_path( + self, untrusted, workflow_dir + ): + definition = { + "steps": [ + { + "name": "edit", + "pipeline": {"arguments": {"image": "/usr/share/pixmaps/x.png"}}, + } + ] + } + errors = location_errors(definition, base_dir=workflow_dir) + assert [error["path"] for error in errors] == [ + "steps[0].pipeline.arguments.image" + ] + + def test_a_loopback_url_is_an_error(self, untrusted, workflow_dir): + definition = { + "steps": [ + { + "name": "edit", + "pipeline": { + "arguments": {"image": "http://127.0.0.1:8765/api/server"} + }, + } + ] + } + assert location_errors(definition, base_dir=workflow_dir) + + def test_an_uncontained_glob_is_an_error(self, untrusted, workflow_dir): + definition = { + "steps": [ + { + "name": "gather", + "task": { + "command": "gather_images", + "arguments": {"glob": "/usr/share/pixmaps/*.png"}, + }, + } + ] + } + assert location_errors(definition, base_dir=workflow_dir) + + def test_a_path_shaped_model_name_is_an_error(self, untrusted, workflow_dir): + definition = { + "steps": [ + { + "name": "draw", + "pipeline": { + "from_pretrained_arguments": {"model_name": "/etc/passwd"} + }, + } + ] + } + assert [ + error["path"] + for error in location_errors(definition, base_dir=workflow_dir) + ] == ["steps[0].pipeline.from_pretrained_arguments.model_name"] + + def test_a_remote_encoder_url_is_an_error(self, untrusted, workflow_dir): + definition = { + "steps": [ + { + "name": "draw", + "pipeline": { + "remote_text_encoder": {"url": "file:///etc/hostname"} + }, + } + ] + } + assert [ + error["path"] + for error in location_errors(definition, base_dir=workflow_dir) + ] == ["steps[0].pipeline.remote_text_encoder.url"] + + def test_references_and_relative_paths_are_left_alone( + self, untrusted, workflow_dir + ): + definition = { + "steps": [ + { + "name": "edit", + "pipeline": { + "arguments": { + "image": "asset:iris.png", + "mask_image": "variable:mask", + "control_image": "previous_result:draw", + } + }, + } + ] + } + assert location_errors(definition, base_dir=workflow_dir) == [] + + def test_the_error_carries_the_authored_step_index(self, untrusted, workflow_dir): + """A for_each member reports against the step the author wrote.""" + definition = { + "steps": [ + {"name": "first", "task": {"command": "gather_inputs"}}, + { + "name": "shot@a", + "pipeline": {"arguments": {"image": "/etc/hosts"}}, + }, + ] + } + errors = location_errors( + definition, source_indices=[0, 0], base_dir=workflow_dir + ) + assert errors[0]["path"].startswith("steps[0]") diff --git a/tests/test_mcp_media.py b/tests/test_mcp_media.py index 29c69209..b207bdcb 100644 --- a/tests/test_mcp_media.py +++ b/tests/test_mcp_media.py @@ -580,3 +580,99 @@ def handler(request): assert not destination.exists() assert list(tmp_path.iterdir()) == [] + + +# ------------------------------------------------- a mounted server's writes +# +# SE-F016 (#113): over a `dw.serve --mcp` endpoint the tool runs on the GPU +# box, so `destination` is a path on the operator's machine rather than on the +# calling agent's. The '..' check it had could not see that - an absolute or +# '~' path needs no '..' to reach anywhere the server process can write. + + +def mounted(content, content_type, workspace_root): + """A client shaped like the one dw.serve builds for its own /mcp.""" + + def handler(request): + if request.url.path == "/api/server": + return httpx.Response( + 200, + json={"directories": {"workspace": str(workspace_root)}}, + headers={"content-type": "application/json"}, + ) + return httpx.Response( + 200, content=content, headers={"content-type": content_type} + ) + + client = DwClient(transport=httpx.MockTransport(handler)) + client.mounted = True + return client + + +def test_a_mounted_server_refuses_an_absolute_destination_outside_the_workspace( + tmp_path, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + client = mounted(png_bytes(4, 4), "image/png", workspace) + outside = tmp_path / "elsewhere" / "probe.jpg" + + with pytest.raises(DwApiError) as refusal: + download_output(client, "run/probe.jpg", destination=str(outside)) + + assert "confined to the workspace" in str(refusal.value) + assert not outside.exists() + + +def test_a_mounted_server_refuses_a_home_relative_destination(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + client = mounted(png_bytes(4, 4), "image/png", workspace) + + with pytest.raises(DwApiError): + download_output(client, "run/probe.jpg", destination="~/probe.jpg") + + assert not (home / "probe.jpg").exists() + + +def test_a_mounted_server_refuses_an_overwrite_outside_the_workspace(tmp_path): + """The escalation SE-F016 flagged but would not probe: the same absolute + destination with overwrite=true is an arbitrary file overwrite.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + victim = tmp_path / "bashrc" + victim.write_text("mine") + client = mounted(png_bytes(4, 4), "image/png", workspace) + + with pytest.raises(DwApiError): + download_output( + client, "run/probe.jpg", destination=str(victim), overwrite=True + ) + + assert victim.read_text() == "mine" + + +def test_a_mounted_server_writes_a_relative_destination_into_its_workspace(tmp_path): + """And the default keeps working: a relative destination is joined onto + the workspace rather than onto whatever the server's cwd happens to be.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + client = mounted(png_bytes(4, 4), "image/png", workspace) + + result = download_output(client, "run/probe.jpg", destination="kept/probe.jpg") + + assert result["saved_to"] == str(workspace / "kept" / "probe.jpg") + assert (workspace / "kept" / "probe.jpg").read_bytes() == png_bytes(4, 4) + + +def test_a_stdio_client_still_writes_wherever_the_user_can(tmp_path): + """Unmounted, 'local disk' is genuinely the caller's own machine.""" + client = serving(png_bytes(4, 4), "image/png") + destination = tmp_path / "anywhere" / "probe.jpg" + + download_output(client, "run/probe.jpg", destination=str(destination)) + + assert destination.read_bytes() == png_bytes(4, 4) diff --git a/tests/test_schema.py b/tests/test_schema.py index 35a85be3..d93b36b2 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -507,3 +507,90 @@ def test_every_section_answers(self, schema): def test_an_unknown_section_names_the_ones_there_are(self, schema): with pytest.raises(SchemaSectionError, match="steps"): schema_section(schema, "pipline") + + +class TestClosedObjects: + """#118: a step is the one object an agent could invent control flow on + and be told it validates. `when`, `retry`, a typo'd `relase_pipeline` - + the engine reads none of them, so the expensive work ran with the input + silently having had no effect. Closed, with a message that says what the + object does take, because "Additional properties are not allowed ('when' + was unexpected)" does not. + """ + + def workflow(self, **step_keys): + return { + "id": "probe", + "steps": [ + { + "name": "video", + "task": {"command": "gather_inputs", "arguments": {}}, + **step_keys, + } + ], + } + + @pytest.mark.parametrize( + "stray", + [ + {"when": "previous_result:judge.pass"}, + {"retry": 3}, + {"select": "argmax"}, + {"relase_pipeline": True}, + ], + ) + def test_an_unknown_step_property_is_an_error(self, stray): + schema = load_schema("workflow") + errors = validate_data_all(self.workflow(**stray), schema) + assert [error["path"] for error in errors] == ["steps[0]"] + message = errors[0]["message"] + assert list(stray)[0] in message + # names the step and what a step actually takes + assert '"video"' in message + assert "release_pipeline" in message and "for_each" in message + + def test_several_stray_keys_are_reported_together(self): + schema = load_schema("workflow") + errors = validate_data_all(self.workflow(when="x", retry=3), schema) + assert len(errors) == 1 + assert "unknown properties" in errors[0]["message"] + assert '"retry"' in errors[0]["message"] + assert '"when"' in errors[0]["message"] + + def test_a_well_formed_step_still_validates(self): + schema = load_schema("workflow") + assert validate_data_all(self.workflow(), schema) == [] + + @pytest.mark.parametrize( + "definition", + [ + {"task": {"command": "gather_inputs", "arguments": {}, "inupts": {}}}, + {"workflow": {"path": "builtin:x.json", "argumnets": {}}}, + {"pipeline_reference": {"reference_name": "draw", "chian": []}}, + ], + ) + def test_the_other_swept_objects_are_closed_too(self, definition): + """task, workflow_reference and pipeline_reference carried no stray + key anywhere in the catalog, so closing them breaks nothing. The + pipeline object is deliberately left open - component names are its + keys (latent_upsampler, prompt_enhancer, processor all appear in + shipped templates), so it cannot be swept the same way.""" + schema = load_schema("workflow") + workflow = {"id": "probe", "steps": [{"name": "s", **definition}]} + errors = validate_data_all(workflow, schema) + assert errors, f"{definition} should not have validated" + + def test_the_pipeline_object_stays_open(self): + """The reason `pipeline` is not in the list above: a component's name + is one of its keys - `latent_upsampler`, `prompt_enhancer` and + `processor` all appear that way in shipped templates - so the + zero-stray-key sweep it would need cannot pass until those are named + properties. Pinned so a later sweep does not close it by eye.""" + schema = load_schema("workflow") + for name in ("pipeline", "pipeline_component"): + assert schema["$defs"][name].get("additionalProperties") is not False + + def test_the_swept_objects_are_closed_in_the_schema(self): + schema = load_schema("workflow") + for name in ("step", "task", "workflow_reference", "pipeline_reference"): + assert schema["$defs"][name]["additionalProperties"] is False diff --git a/tests/test_server_info.py b/tests/test_server_info.py index 205649d6..68e28f00 100644 --- a/tests/test_server_info.py +++ b/tests/test_server_info.py @@ -46,6 +46,7 @@ def test_payload_shape(tmp_path): assert body["port"] == 8765 assert body["wildcard_bind"] is False assert body["auth_required"] is False + assert isinstance(body["trust_workflows"], bool) assert body["mcp"] == {"mounted": False, "path": "/mcp"} assert isinstance(body["addresses"], list) for entry in body["addresses"]: @@ -59,6 +60,22 @@ def test_payload_shape(tmp_path): assert directories["prompts"] == str(tmp_path / "prompts") +def test_trust_posture_is_reported(tmp_path, monkeypatch): + """SE-F001 (#120): the security suite has to be able to confirm it is + testing the untrusted default rather than assuming it. Inferring the + posture from behavior only works while the trust-gated cases happen to + fail closed.""" + from dw.security import TRUST_WORKFLOWS_ENV_VAR + + monkeypatch.setenv(TRUST_WORKFLOWS_ENV_VAR, "0") + with client(tmp_path) as c: + assert c.get("/api/server").json()["trust_workflows"] is False + + monkeypatch.setenv(TRUST_WORKFLOWS_ENV_VAR, "1") + with client(tmp_path) as c: + assert c.get("/api/server").json()["trust_workflows"] is True + + def test_wildcard_bind_and_port_reported(tmp_path): with client(tmp_path, host="0.0.0.0", port=9000) as c: body = c.get("/api/server").json() From 1154fa8826e022a9e006aa3cf0add3c47f0318c7 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 14:13:01 -0500 Subject: [PATCH 02/17] fix(tasks): #108 #109 #110 - a rate mismatch resamples, and two documented conventions #108: concat_videos refused to join videos whose soundtracks were at different sample rates, mid-run, after the earlier steps had written their files, with an error that named neither which shot to fix nor the resample_audio task that was the remedy. Unlike a level jump the difference carries no editorial meaning, so it is converted: every track is resampled to the highest rate among them (or to an explicit `sample_rate`), with a warning naming each video and its rate. resample_waveform is the conversion resample_audio already did, lifted out so there is one implementation. #110: `denoise_total_steps` comes back one less than `num_inference_steps` on every H3 run because MiniMaxH3Scheduler counts sigma grid points with the terminal zero among them and evaluates the model N-1 times - the vendor's convention, not a dropped step or an off-by-one in our reporting, which faithfully reports len(scheduler.timesteps). Documented in wait_for_job, in diagnose, and in the H3 skill, and pinned to the scheduler in tests/test_plugin_skills.py because from outside the two are indistinguishable. #109: a shot entry's subject reference takes `from_file` with an `asset:` path, so an episode can be cast from portraits that already exist - it worked and was documented nowhere. Said now in the template's description and in the H3 skill, including what it does not yet do: the two Z-Image steps still run and their portraits are discarded. Eliding a step nothing references is an engine change, proposed in docs/proposals/unreferenced-step-elision.md rather than taken here. Co-Authored-By: Claude Opus 5 --- docs/proposals/unreferenced-step-elision.md | 113 ++++++++++++++++++ dw/tasks/audio_utils.py | 56 +++++---- dw/tasks/concat_videos.py | 65 ++++++++-- dw_mcp/diagnose.py | 4 +- dw_mcp/server.py | 9 +- plugins/dw/skills/minimax-h3/SKILL.md | 78 ++++++------ tests/test_concat_videos.py | 51 +++++++- tests/test_plugin_skills.py | 19 +++ .../templates/minimax/dialogue-short.json | 2 +- 9 files changed, 323 insertions(+), 74 deletions(-) create mode 100644 docs/proposals/unreferenced-step-elision.md diff --git a/docs/proposals/unreferenced-step-elision.md b/docs/proposals/unreferenced-step-elision.md new file mode 100644 index 00000000..b6974b2f --- /dev/null +++ b/docs/proposals/unreferenced-step-elision.md @@ -0,0 +1,113 @@ +# Proposal: a step nothing references does not run + +Status: awaiting approval. Raised by #109 (tester agent, model `opus` via +provider `anthropic`); written by the implementer agent, same model and +provider. + +## The case that raised it + +`templates/minimax/dialogue-short` draws its two characters with Z-Image and +then references those portraits from every shot. An episode can just as well +be cast from portraits that already exist - a shot entry's subject reference +takes `from_file: "asset:cast/priya.jpg"` exactly as its voice references do, +and it works today. What does not work is the consequence: the two +`draw_character_a` / `draw_character_b` steps still run, and their output is +discarded. Job `48000580aec1` spent roughly 55 seconds and two model loads on +portraits nothing in the run looked at. + +The recurring cast is the headline use of this template. Paying for it every +episode is the wrong default, and no argument the caller can pass avoids it. + +The documentation half of #109 is shipped: the template's description and the +`dw:minimax-h3` skill now say a shot reference may be a file, and say plainly +that the draw steps still run. This proposal is the part that needs a +decision. + +## What is being proposed + +At run time, before the first step executes, drop any step whose result no +later step references and which saves nothing. + +A step is *referenced* when a later step (after `for_each` expansion and +variable substitution) names it through any of: + +- `previous_result:` or `from_previous_result: ` +- `gather:` +- a `pipeline_reference` naming its pipeline +- `shared_components` / `reused_components` keyed on it + +A step is *kept regardless* when: + +- it declares a `result` with `save` not false - it is a deliverable, and a + workflow whose whole point is writing three images references nothing +- it is the last step +- it carries `release_pipeline` / `release_models` - dropping it would leak + the memory it was there to free. (Better: move the release onto the step + that now runs in its place, the way `expand_for_each` already moves it onto + the last member. That is the more useful rule but the more delicate one.) + +Elision is transitive: dropping a step can make the step it referenced +unreferenced in turn, so it iterates to a fixed point. + +## Why it is a decision rather than an edit + +1. **It is a new engine property every workflow inherits.** A step that runs + today and stops running tomorrow is a behaviour change across the whole + catalog, not one template. The `save`-not-false carve-out is what keeps + that from being destructive, and it is exactly the kind of rule that is + right in the common case and surprising in some particular one. +2. **It interacts with the step cache and with `plan`.** `plan.estimate`, + `plan.steps` and the cost acknowledgement all count steps; eliding changes + the number a caller acknowledged. The plan would have to be computed after + elision, which means `POST /api/validate` has to do the elision too. +3. **It is observable in the manifest.** A run that used to write + `intermediate/…draw_character_a.0-0.0.jpg` stops writing it. Anything + built on an `output:` reference to a now-elided step breaks. +4. **Silent elision is its own trap.** If a reference is misspelled, the step + feeding it becomes unreferenced and quietly vanishes, and the failure moves + from "previous result not found" to "the picture is wrong". The static + `previous_result` check (`dw/previous_results.py`) already refuses an + unresolvable reference, which contains this - but only for references the + definition spells literally. + +## The cheaper alternative, for comparison + +Template-local, no engine change: give `dialogue-short` the variables +`character_a_portrait` / `character_b_portrait`, defaulting to null, and have +each shot's subject reference read `from_file: "variable:character_a_portrait"` +the way the voices already read `variable:character_a_voice`. A reference +whose file is null is dropped from the list, so the default run is unchanged. + +This is the idiom a reader of the variable list would expect to find, and it +is what #109's option (2) asks for. It does **not** save the portrait cost on +its own: the draw steps still run, and the shots would then carry both a +`from_previous_result` entry and a `from_file` entry for the same subject, +one of which has to drop. There is no mechanism for dropping the +`from_previous_result` one - the null-file rule only covers `from_file`. So +option (2) delivers a better-shaped argument surface and no saving, unless it +is paired either with this proposal or with a narrower "a reference whose +`from_previous_result` names an elided step is dropped" rule. + +That is the trade to decide: the general engine property, the narrow template +change, or both. + +## Recommendation + +Take the general rule, with these guardrails: + +- keep any step that saves, is last, or releases +- report every elided step as a run warning naming it and why, so a + misspelled reference shows up as "draw_character_a was skipped: nothing + references it" rather than as a silently different picture +- compute `plan` after elision, so the quoted cost is the cost +- record the elided steps in the manifest, so a run says what it did not do + +Without the warning this is a trap; with it, it is the property the tester +asked for and a real saving on every recurring-cast episode. + +## Not in scope + +Conditional execution (`when:`) is a different feature and a much larger one - +#118 has just closed the step object specifically so an invented `when` is a +hard error rather than a silent no-op. Elision is static: it depends only on +what the realized workflow references, never on a value produced at run time. diff --git a/dw/tasks/audio_utils.py b/dw/tasks/audio_utils.py index 859804b8..b0260729 100644 --- a/dw/tasks/audio_utils.py +++ b/dw/tasks/audio_utils.py @@ -337,31 +337,15 @@ def slice_audio( return _as_track(slice_samples(waveform, start, length), sample_rate) -def resample_audio(audio, target_sample_rate, sample_rate=None): - """Task command: resample an audio track to a different sample rate. - - MiniMax H3 conditions on audio at its audio VAE's own rate and resamples - anything else with torchaudio, which dw does not depend on. Resampling a - supplied recording once, up front, feeds the pipeline what it already wants - and keeps the dependency out - PyAV, which dw needs for video anyway, does - the conversion. +def resample_waveform(waveform, sample_rate, target_sample_rate): + """A waveform at a different rate, as a plain (channels, samples) array. - Args: - audio: Path or URL of an audio file (or of a video file, whose - soundtrack is taken), a video generated with a - soundtrack (which brings its sample rate along), or a waveform - (which needs sample_rate alongside it) - target_sample_rate: Rate to convert to - sample_rate: Sample rate of a waveform passed directly; given for a - file or a video it overrides the rate they carry - - Returns: - An AudioTrack holding the resampled waveform and its new rate + The conversion resample_audio performs, without the task's argument + handling or its AudioTrack return, so a task that has waveforms in hand + already can reach the rate conversion directly. """ - waveform, sample_rate = _waveform_and_rate(audio, sample_rate, "resample_audio") - if sample_rate == target_sample_rate: - return _as_track(waveform, sample_rate) + return waveform import av from av.audio.resampler import AudioResampler @@ -384,8 +368,34 @@ def resample_audio(audio, target_sample_rate, sample_rate=None): f"Resampled {waveform.shape[1]} samples at {sample_rate}Hz " f"to {target_sample_rate}Hz" ) + return numpy.concatenate(converted, axis=1).astype(numpy.float32) + + +def resample_audio(audio, target_sample_rate, sample_rate=None): + """Task command: resample an audio track to a different sample rate. + + MiniMax H3 conditions on audio at its audio VAE's own rate and resamples + anything else with torchaudio, which dw does not depend on. Resampling a + supplied recording once, up front, feeds the pipeline what it already wants + and keeps the dependency out - PyAV, which dw needs for video anyway, does + the conversion. + + Args: + audio: Path or URL of an audio file (or of a video file, whose + soundtrack is taken), a video generated with a + soundtrack (which brings its sample rate along), or a waveform + (which needs sample_rate alongside it) + target_sample_rate: Rate to convert to + sample_rate: Sample rate of a waveform passed directly; given for a + file or a video it overrides the rate they carry + + Returns: + An AudioTrack holding the resampled waveform and its new rate + """ + waveform, sample_rate = _waveform_and_rate(audio, sample_rate, "resample_audio") return _as_track( - numpy.concatenate(converted, axis=1).astype(numpy.float32), target_sample_rate + resample_waveform(waveform, sample_rate, target_sample_rate), + target_sample_rate, ) diff --git a/dw/tasks/concat_videos.py b/dw/tasks/concat_videos.py index df9c60e5..eccd6c2d 100644 --- a/dw/tasks/concat_videos.py +++ b/dw/tasks/concat_videos.py @@ -17,6 +17,7 @@ equal_power_crossfade_join, frames_to_samples, match_levels as match_track_levels, + resample_waveform, warn_on_level_spread, ) from .video_utils import check_same_frame_size, frames_as_pil_list, load_audio_video @@ -24,6 +25,20 @@ logger = logging.getLogger("dw") +def video_names(videos): + """A name per video, for an error or a warning that has to say which one. + + A caller passes a path, or a previous step's result; only the path says + anything by itself, so the rest are named by position - which is what a + six-entry `shots` list needs to be actionable ("24000 then 32000" does + not say which entry to fix). + """ + return [ + original if isinstance(original, str) else f"video {index + 1}" + for index, original in enumerate(videos) + ] + + def concat_videos( videos, trim_frames=0, @@ -33,6 +48,7 @@ def concat_videos( fps=None, match_levels=None, match_levels_dbfs=None, + sample_rate=None, ): """Concatenate a list of videos into a single AudioVideo. @@ -76,6 +92,13 @@ def concat_videos( defaults to -1 dBFS for "peak" and -20 dBFS for "rms". A shot that would clip at the target is held just below full scale instead + sample_rate: The rate the joined soundtrack is at. Shots that come + from different sources routinely carry different rates - a 24 kHz + voice clip paired onto a 32 kHz generation - and unlike a level + jump that difference has no editorial meaning, so by default the + highest rate among the inputs is chosen and the rest are + resampled up to it, with a warning naming which. Give this to pin + the target instead (#108) Returns: One AudioVideo; its audio is None when no input video carries any @@ -83,6 +106,9 @@ def concat_videos( if not isinstance(videos, list) or not videos: raise ValueError("concat_videos needs a non-empty list of videos") + # Named before they are loaded: a path is the only thing that names + # itself, and the load below replaces it with what it holds + names = video_names(videos) # A shot an earlier run already wrote is loaded here rather than by # gather_videos, which reads frames only and would join it silent videos = [load_audio_video(v) if isinstance(v, str) else v for v in videos] @@ -100,6 +126,37 @@ def concat_videos( ) for video in videos ] + # One rate before anything is joined. Shots assembled from different + # sources disagree routinely, and the disagreement carries no meaning - + # so it is converted rather than refused, which is what made an agent + # invent a resample_audio step by hand (#108) + rates = [ + video.sample_rate + for video, waveform in zip(videos, waveforms) + if waveform is not None and video.sample_rate + ] + sample_rate = sample_rate or (max(rates) if rates else None) + if rates and any(rate != sample_rate for rate in rates): + logger.warning( + "Videos carry audio at different sample rates (" + + ", ".join( + f"{name}: {video.sample_rate} Hz" + for name, video in zip(names, videos) + if isinstance(video, AudioVideo) and video.audio is not None + ) + + f") - resampling them all to {sample_rate} Hz. Pass " + "'sample_rate' to pin a different target, or resample ahead of " + "this step with the 'resample_audio' task." + ) + waveforms = [ + ( + waveform + if waveform is None or video.sample_rate == sample_rate + else resample_waveform(waveform, video.sample_rate, sample_rate) + ) + for video, waveform in zip(videos, waveforms) + ] + if match_levels: waveforms = match_track_levels(waveforms, match_levels, match_levels_dbfs) else: @@ -107,7 +164,6 @@ def concat_videos( frames = [] audio = None - sample_rate = None for index, (video, clip) in enumerate(zip(videos, clips)): head_trim = trim_frames if index > 0 else 0 @@ -118,14 +174,9 @@ def concat_videos( waveform = waveforms[index] if audio is None: - audio, sample_rate = waveform, video.sample_rate + audio = waveform continue - if video.sample_rate != sample_rate: - raise ValueError( - f"Videos carry audio at different sample rates: " - f"{sample_rate} then {video.sample_rate}" - ) if head_trim > 0 and fps is None: raise ValueError( "concat_videos needs 'fps' to trim audio in step with the frames" diff --git a/dw_mcp/diagnose.py b/dw_mcp/diagnose.py index 0153bf19..13a5d09c 100644 --- a/dw_mcp/diagnose.py +++ b/dw_mcp/diagnose.py @@ -206,7 +206,9 @@ def wait_for_job(client, job_id, timeout_seconds=20): (`loading`, `generating`, `decoding`, `saving`) with the model or step named in `phase_detail`, `seconds_in_phase`, `seconds_since_event`, and `denoise_step`/`denoise_total_steps`, which are null until the denoise - loop starts. Two calls with the same phase and a growing + loop starts. `denoise_total_steps` is the schedule that actually runs, + which is not always the `num_inference_steps` asked for - MiniMax H3 + runs N-1 evaluations for N (#110). Two calls with the same phase and a growing `seconds_in_phase` but a moving `denoise_step` is a slow run; one where `denoise_step` is a number that does not move while `seconds_since_event` climbs is a stuck one. diff --git a/dw_mcp/server.py b/dw_mcp/server.py index 2d434022..f65ae779 100644 --- a/dw_mcp/server.py +++ b/dw_mcp/server.py @@ -906,7 +906,14 @@ def wait_for_job(job_id: str, timeout_seconds: int = 20) -> dict: are uneven too where a transformer block cache is configured. Both are normal, and the model family's own skill carries the measured figures. The signal is whether `denoise_step` has moved since a - poll minutes ago, not silence past a fixed threshold.""" + poll minutes ago, not silence past a fixed threshold. + + `denoise_total_steps` is the schedule the pipeline actually runs, + which is not always the `num_inference_steps` that was asked for: + MiniMax H3's scheduler counts sigma grid points including the + terminal zero, so it runs N-1 model evaluations for N (9 reports 8, + 20 reports 19). That is the vendor's convention, not a dropped step - + raising the number still buys the steps it looks like it does.""" return diagnose.wait_for_job(client, job_id, timeout_seconds=timeout_seconds) # The cap is a number a caller paces against, so the description states diff --git a/plugins/dw/skills/minimax-h3/SKILL.md b/plugins/dw/skills/minimax-h3/SKILL.md index 212e434b..747d7d8a 100644 --- a/plugins/dw/skills/minimax-h3/SKILL.md +++ b/plugins/dw/skills/minimax-h3/SKILL.md @@ -7,8 +7,7 @@ description: Use when a dw MCP server is connected and the user wants MiniMax H3 H3 generates video and audio together: speech with lip sync, ambient sound, score. Every template here fits a 24 GB card. This skill chooses the template -and the arguments; the prompt format is MiniMax's and comes from their text, -not from here. +and the arguments; the prompt format is MiniMax's, from their text not here. ## Before anything @@ -30,45 +29,46 @@ not from here. `templates/minimax/last-frame-only`; a one-line idea plus a picture `templates/minimax/enhance-prompt-with-image`. - **A subject that must look the same**: `templates/minimax/reference-to-video` - (an image fixes appearance, an audio clip fixes voice); + (an image fixes appearance, an audio clip voice); `templates/minimax/composable-references` adds a video reference for framing and camera, at about 3.4x the cost; - `templates/minimax/generated-subject-reference` draws the subject - with Z-Image first and references it in the same workflow; + `templates/minimax/generated-subject-reference` draws the subject with + Z-Image first and references it in the same workflow; `templates/minimax/voice-timbre-reference` fixes a voice from a Bark-spoken line. - **Several boards in one generation, one unbroken score**: `templates/minimax/storyboard` - H3 cuts between the boards inside a single generation, which no concat of separate clips can match for continuous audio. - It is one beat with fixed cut points, not a building block: four - concatenated give twelve equal shots and a cast redrawn four times. - Past one beat with a recurring cast, use the cuts pattern below. + It is one beat with fixed cut points, not a building block; past one beat + with a recurring cast, use the cuts pattern below. - **Longer than 14.4 seconds**: decide first whether the seam is a cut or a - continuation. Chain when the same action or line of speech has to cross the - seam; cut when the scene changes, and treat each cut as its own generation. - Six distinct scenes are a cuts piece, not a chain. + continuation. Chain when the same action or line of speech crosses the seam; + cut when the scene changes, and treat each cut as its own generation. Six + distinct scenes are a cuts piece, not a chain. - **Longer than 14.4 seconds as one take**: a chain. `templates/minimax/chained-segments` (last-frame continuity), `templates/minimax/chain-video-continuity` (the previous segment's tail rides along as a video reference - motion, camera and voice carry across the seam), `templates/minimax/chain-matched-to-audio` (a supplied track sets the length and is muxed back seamless), - `templates/minimax/chain-matched-and-aligned` (all of it, per-segment prompts). - Drift compounds per seam: reference the subject picture in every segment, - prefer `last_segment` continuity, and use the longest segments memory allows. + `templates/minimax/chain-matched-and-aligned` (all of it, per-segment + prompts). Drift compounds per seam: reference the subject picture in every + segment, prefer `last_segment` continuity, use the longest segments memory + allows. - **A piece with cuts**: fresh shots from shared portraits, then a concat. `templates/minimax/dialogue-short` (Z-Image draws the cast, one shot per - entry of its `shots` list on one loaded model, `concat_videos` splices) and + `shots` entry on one loaded model, `concat_videos` splices) and `templates/minimax/music-video` (a song, one slice and one lip-synced shot per entry). `shots` is one list argument: a dialogue entry is `name`, - `prompt`, `references` (which portraits and voices this shot uses) and + `prompt`, `references` (portraits and voices: `from_previous_result` for + one drawn here, `from_file` for an `asset:` cast that already exists) and `num_frames`; a music-video entry is `name`, `prompt` and `start_frame`. - A six-shot piece is one more entry, not another file. - The listing's `lists` block says what an entry carries; its `cost` - carries `per_entry` when one shot was measured: quote + A six-shot piece is one more entry, not another file. The listing's + `lists` block says what an entry carries; its `cost` carries + `per_entry` when one shot was measured: quote `minutes - per_entry.minutes × per_entry.entries + per_entry.minutes × N` for N entries. Without `per_entry`, quote the total and say it is the default list's. - A cut erases drift; the last shot is as clean as the first. Write shots, - not takes. Each shot generates its own audio, so write + A cut erases drift: the last shot is as clean as the first. Each shot + generates its own audio, so write `non_diegetic_music: N/A` in every shot and lay one score under the concat afterwards: `templates/minimax/music` writes the track and `templates/assemble-and-score` shows the `pair_audio` step that mixes it @@ -103,25 +103,27 @@ read the `workflows` guide's authoring section first. LoRA is distilled against the base transformer and they load the reference one. `storyboard`, `dialogue-short`, `music-video` and `chain-matched-and-aligned` pass references *and* keep the turbo LoRA at - nine steps; say nine for those, not 20. + nine steps; say nine for those, not 20. `denoise_total_steps` comes back one + less (9 reports 8): the scheduler counts sigma grid points, terminal zero + included. Expected. - Nothing carries between generations except what is passed as a reference: - no latent memory and no extension mode, in the checkpoint, the hosted API or + no latent memory and no extension mode, in the checkpoint, the API or diffusers. Identity rides on a picture, voice on an audio clip, motion and camera on a video tail (what a chain passes forward), and a score across - cuts is laid under the concat afterwards. -- H3 is guidance-distilled: no `guidance_scale`, no negative prompt. Say what is - there, never what is not. -- When deriving a variant, keep `release_pipeline` on the step the template - puts it on: it frees the Z-Image boards before H3 loads. A run SIGKILLed near - the end in a warm worker that succeeds on a retry in a fresh one is host - memory, not the prompt. + cuts is laid under the concat. +- H3 is guidance-distilled: no `guidance_scale`, no negative prompt. Say what + is there, never what is not. +- When deriving a variant, keep `release_pipeline` where the template puts it: + it frees the Z-Image boards before H3 loads. A run SIGKILLed near the end in + a warm worker that succeeds on a retry in a fresh one is host memory, not + the prompt. - Ref2VA limits: at most 9 images, 3 videos, 3 audio clips, 12 files; audio can never be the only reference. References are labelled in the order passed. - Music3 reads `audio_duration` as a ceiling, not a target: ask for more than - the song needs and trim with `templates/audio-trim-fade`; the - `minimax-music3` skill has the rest. -- Write the prompt for the length being generated: shot timestamps should span - the duration, or a five-second script conditions a five-second story + the song needs and trim with `templates/audio-trim-fade`; see the + `minimax-music3` skill. +- Write the prompt for the length being generated: shot timestamps should + span the duration, or a five-second script conditions a five-second story whatever the frame count. ## Prompts @@ -145,7 +147,7 @@ paraphrase it from examples: framed as `Task: T2VA. Duration: 5.17 seconds. Idea: ...`. Whichever route: write the whole script before the first shot - the lines in -order, read once, should carry the piece on their own - then place them. +order, read once, should carry the piece - then place them. Repeat a speaker's voice description verbatim across shots, and when a reference picture should fix identity but not framing, say so in the prompt itself - in a reference-conditioned request, in the lines that @@ -158,9 +160,9 @@ inherits the portrait's composition. rejects. 2. Quote `plan.estimate` from the validate answer (warm minutes; a first load or a `downloads_required` is longer). When `basis` is `unknown`, - say so and give the shape instead: a 124-frame turbo clip is a few minutes on a 24 GB card, 345 - frames three times that, an image reference twice a turbo clip, a video - reference beside it 3.4x again, and a chain multiplies by its segments. + say so and give the shape instead: a 124-frame turbo clip is a few minutes + on a 24 GB card, 345 frames three times that, an image reference twice a + turbo clip, a video reference 3.4x again, a chain times its segments. Get the go-ahead, then `run_workflow` with `acknowledged_cost` = the plan's `{fingerprint, minutes, downloads}`. 3. `wait_for_job`, then `get_job` for the manifest. A cancelled H3 job runs diff --git a/tests/test_concat_videos.py b/tests/test_concat_videos.py index a4567213..14ac1293 100644 --- a/tests/test_concat_videos.py +++ b/tests/test_concat_videos.py @@ -3,6 +3,8 @@ chained pipeline step's stitching. """ +from unittest.mock import patch + import numpy import pytest import torch @@ -70,11 +72,54 @@ def test_mixed_inputs_keep_the_audio_that_exists(self): assert len(result.frames) == 12 assert result.audio.shape == (2, 200) - def test_mismatched_sample_rates_raise(self): + def test_mismatched_sample_rates_are_resampled_to_the_highest(self, caplog): + """#108: a 24 kHz voice clip paired onto a 32 kHz generation used to + fail the run mid-way, after the earlier steps had already written + their files, with an error that named neither which shot to fix nor + the `resample_audio` task that was the remedy. The difference has no + editorial meaning - unlike a level jump - so it is converted.""" + videos = [audio_video(8, 1), audio_video(8, 2, sample_rate=200)] + + with caplog.at_level("WARNING"): + result = concat_videos(videos) + + assert result.sample_rate == 200 + # both halves at the joined rate: two 2 s videos at 200 Hz. The + # first was resampled up from 100, so its 200 samples became 400 + assert result.audio.shape[1] == pytest.approx(800, abs=4) + assert "resampling them all to 200 Hz" in caplog.text + + def test_the_resample_warning_names_which_video(self, caplog): + """ "24000 then 32000" does not say which entry of a six-shot list to + look at.""" + videos = ["first.mp4", audio_video(8, 2, sample_rate=200)] + + with caplog.at_level("WARNING"): + with patch( + "dw.tasks.concat_videos.load_audio_video", + return_value=audio_video(8, 1), + ): + concat_videos(videos) + + assert "first.mp4: 100 Hz" in caplog.text + assert "video 2: 200 Hz" in caplog.text + assert "resample_audio" in caplog.text + + def test_an_explicit_sample_rate_pins_the_target(self): videos = [audio_video(8, 1), audio_video(8, 2, sample_rate=200)] - with pytest.raises(ValueError, match="different sample rates"): - concat_videos(videos) + result = concat_videos(videos, sample_rate=100) + + assert result.sample_rate == 100 + + def test_matching_rates_are_left_alone(self, caplog): + videos = [audio_video(8, 1), audio_video(8, 2)] + + with caplog.at_level("WARNING"): + result = concat_videos(videos) + + assert result.sample_rate == 100 + assert "resampling" not in caplog.text def test_trimmed_audio_without_fps_raises(self): videos = [audio_video(8, 1), audio_video(8, 2)] diff --git a/tests/test_plugin_skills.py b/tests/test_plugin_skills.py index 0f430944..ba3fd663 100644 --- a/tests/test_plugin_skills.py +++ b/tests/test_plugin_skills.py @@ -145,6 +145,25 @@ def test_the_frame_rule_and_bounds_are_the_pipeline_s(self): and 345 / modular_pipeline.MINIMAX_H3_FPS <= 15 ) + def test_the_denoise_step_count_is_the_scheduler_s(self): + """#110: `denoise_total_steps` comes back one less than the + `num_inference_steps` asked for, on every H3 run. Not a dropped step + and not an off-by-one in our progress reporting - MiniMaxH3Scheduler + counts sigma grid points with the terminal zero among them, so the + schedule it builds evaluates the model N-1 times, and the bar we + report is `len(scheduler.timesteps)`. Pinned here because from + outside the two are indistinguishable, which is what got it filed. + """ + from diffusers import MiniMaxH3Scheduler + + scheduler = MiniMaxH3Scheduler(shift=12.0) + for requested, evaluations in ((9, 8), (20, 19)): + scheduler.set_timesteps(requested) + assert len(scheduler.timesteps) == evaluations + + text = skill_text(H3_SKILL) + assert "denoise_total_steps" in text and "9 reports 8" in text + def test_the_canvas_rules_are_the_pipeline_s(self): import inspect diff --git a/workflows/templates/minimax/dialogue-short.json b/workflows/templates/minimax/dialogue-short.json index 4427da76..507e1b7a 100644 --- a/workflows/templates/minimax/dialogue-short.json +++ b/workflows/templates/minimax/dialogue-short.json @@ -1,6 +1,6 @@ { "id": "MiniMaxH3SitcomShort", - "description": "A digital short built the way television is built: from cuts, not from one long take. Chained generation degrades with length - every segment conditions on the previous segment's output, so artifacts compound and identity drifts. A scene cut resets that completely: each shot here is generated fresh from the same two character portraits, so shot five is exactly as clean as shot one and the scene can run as long as the script does. Two Z-Image steps draw the cast (the second reuses the first's loaded pipeline - identical configurations share one model - and 'release_pipeline' frees it before the video model loads). The shots are one 'for_each' step over the 'shots' list: one entry per shot, carrying its 'name', its 'prompt', its 'references' and its 'num_frames' - the tag entry runs 141 frames where the others run 124, since length is per-shot. The list is an argument, so a six-shot scene is one more entry, not another file, and the members are named for their entries ('shot@react'); the loaded MiniMax-H3 is reused across all of them. Character consistency across cuts comes from referencing the same portraits in every shot; voice consistency comes from repeating each character's voice description verbatim in every prompt, and, when 'character_a_voice' / 'character_b_voice' name a clip ('asset:cast/priya.wav'), from the audio reference each entry lists for whoever speaks in it - an entry's reference says 'variable:character_a_voice', so one variable sets the voice in every shot that character has. Both default to null, and a reference whose file is null is left out of the list, so a run that names no voice generates exactly what it generated before the variables existed. A shot where both speak lists both; if that reads worse than one, name one voice and leave the other null. The variables are named for roles rather than for the cast of this example - 'character_a', the 'react' entry - because the beats are the reusable part and the sketch is not. The soundscape writes the laugh track. A final 'concat_videos' task is the editor, gathering the shots in list order into one episode - hard cuts, no trims, no seams to hide, because nothing was carried between them. Only the picture cuts hard: 'audio_bleed_ms' rings each shot's laugh track on over the silent opening of the next, the way a live audience carries across a cut. It and 'seam_fade_ms' are variables, so a seam is re-tuned with an argument rather than a copy of the workflow; 1800 ms is where a five-shot cut measured best, since a generated shot opens on more silence than it looks. Shots generated independently also drift in loudness - 10 dB between two shots of one scene is ordinary - and no seam control can hide a level jump, because it is either side of the cut rather than at it; 'match_levels' ('rms' for perceived level, 'peak' for the loudest sample) evens the shots out before they are joined, and left null, as it is by default, a wide spread is warned about in the log rather than passing in silence.", + "description": "A digital short built the way television is built: from cuts, not from one long take. Chained generation degrades with length - every segment conditions on the previous segment's output, so artifacts compound and identity drifts. A scene cut resets that completely: each shot here is generated fresh from the same two character portraits, so shot five is exactly as clean as shot one and the scene can run as long as the script does. Two Z-Image steps draw the cast (the second reuses the first's loaded pipeline - identical configurations share one model - and 'release_pipeline' frees it before the video model loads). The shots are one 'for_each' step over the 'shots' list: one entry per shot, carrying its 'name', its 'prompt', its 'references' and its 'num_frames' - the tag entry runs 141 frames where the others run 124, since length is per-shot. The list is an argument, so a six-shot scene is one more entry, not another file, and the members are named for their entries ('shot@react'); the loaded MiniMax-H3 is reused across all of them. Character consistency across cuts comes from referencing the same portraits in every shot; voice consistency comes from repeating each character's voice description verbatim in every prompt, and, when 'character_a_voice' / 'character_b_voice' name a clip ('asset:cast/priya.wav'), from the audio reference each entry lists for whoever speaks in it - an entry's reference says 'variable:character_a_voice', so one variable sets the voice in every shot that character has. Both default to null, and a reference whose file is null is left out of the list, so a run that names no voice generates exactly what it generated before the variables existed. A shot where both speak lists both; if that reads worse than one, name one voice and leave the other null. The variables are named for roles rather than for the cast of this example - 'character_a', the 'react' entry - because the beats are the reusable part and the sketch is not. The soundscape writes the laugh track. A final 'concat_videos' task is the editor, gathering the shots in list order into one episode - hard cuts, no trims, no seams to hide, because nothing was carried between them. Only the picture cuts hard: 'audio_bleed_ms' rings each shot's laugh track on over the silent opening of the next, the way a live audience carries across a cut. It and 'seam_fade_ms' are variables, so a seam is re-tuned with an argument rather than a copy of the workflow; 1800 ms is where a five-shot cut measured best, since a generated shot opens on more silence than it looks. Shots generated independently also drift in loudness - 10 dB between two shots of one scene is ordinary - and no seam control can hide a level jump, because it is either side of the cut rather than at it; 'match_levels' ('rms' for perceived level, 'peak' for the loudest sample) evens the shots out before they are joined, and left null, as it is by default, a wide spread is warned about in the log rather than passing in silence. An episode can be cast from portraits that already exist rather than drawn here: a shot entry's subject reference takes 'from_file' the way its voice references do ('asset:cast/priya.jpg'), so a recurring cast carries across episodes by name instead of being redrawn each time - which is the whole point of a cast. Note what that does not do yet: the two Z-Image steps still run and their portraits are discarded, because the engine has no way to skip a step nothing references (#109). Until it does, an episode cast entirely from files pays about a minute for two portraits it throws away.", "summary": "A multi-shot dialogue short: Z-Image draws the cast, each shot is generated fresh from the same portraits, then cut.", "cost": [ { From 44524649a0ca5338dd777cd74297fcb3fce5240a Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 14:17:19 -0500 Subject: [PATCH 03/17] docs: the location policy, the closed step object, and download_output's confinement SECURITY.md gets a "Where a workflow may read and reach" section under the trust model - the second thing a workflow file chooses besides which code to import. WORKFLOW_GUIDE.md's authoring section (which the MCP `workflows` guide serves verbatim) gets the agent-facing version plus "A step takes only the keys the engine reads", so an agent learns there is no `when`/`retry` before it invents one. SECURITY_QUICKREF.md points new filesystem access at dw/locations.py rather than at validate_path directly. MCP.md says download_output is confined to the workspace over a mounted endpoint and why the transport is what decides it. Refs #112-#118, #120. Co-Authored-By: Claude Opus 5 --- docs/MCP.md | 21 ++++++++++++----- docs/SECURITY.md | 48 +++++++++++++++++++++++++++++++++++++-- docs/SECURITY_QUICKREF.md | 20 ++++++++++++++++ docs/WORKFLOW_GUIDE.md | 33 ++++++++++++++++++++++++++- 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index 7a68fb04..ecf70c4a 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -235,7 +235,7 @@ when no single workflow covers it. | --- | --- | --- | | `get_output_image(name, max_dimension=768, workspace=None)` | `name`, `max_dimension`, `workspace` | Look at a generated image, downscaled to `max_dimension` on its longest side. Returns the image plus a text part reporting `original_size`, `returned_size` and `bytes`, so a downscale is never silent. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it | | `get_output_text(name, max_characters=20000, workspace=None)` | `name`, `max_characters`, `workspace` | Read a text output — a prompt enhancement, or any step whose result is `text/plain` or JSON. Reports the file's real length and whether it was truncated. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it | -| `download_output(name, destination=None, overwrite=False, workspace=None)` | `name`, `destination`, `overwrite`, `workspace` | Save one output file to local disk, of any content type. `destination` may be a full path, a directory, or omitted to save under the output's own name in the current working directory; `~` expands and missing parent directories are created. `overwrite=True` is required to replace a file already at the resolved path. Returns nothing to the conversation but where the file landed — unlike the other media tools, the point is a file on disk, not a payload in context. Writes on the machine running the MCP server - over `dw.serve --mcp` that is the GPU box. A write that fails there (a path that exists only on the client, for instance) comes back as an error naming the server-side write and the client-side alternatives, not as an anonymous tool failure. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it | +| `download_output(name, destination=None, overwrite=False, workspace=None)` | `name`, `destination`, `overwrite`, `workspace` | Save one output file to local disk, of any content type. `destination` may be a full path, a directory, or omitted to save under the output's own name in the current working directory; `~` expands and missing parent directories are created. `overwrite=True` is required to replace a file already at the resolved path. Over a `dw.serve --mcp` endpoint the file lands on the server, so the destination is confined to that workspace and a relative one is joined onto it. Returns nothing to the conversation but where the file landed — unlike the other media tools, the point is a file on disk, not a payload in context. Writes on the machine running the MCP server - over `dw.serve --mcp` that is the GPU box. A write that fails there (a path that exists only on the client, for instance) comes back as an error naming the server-side write and the client-side alternatives, not as an anonymous tool failure. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it | | `delete_output(name, workspace=None)` | `name`, `workspace` | Permanently remove one generated file from the output directory. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it | ### Authoring, assets and workspaces @@ -412,13 +412,22 @@ localhost binding, no auth, `Origin` header checks, and path confinement in Nothing under `dw_mcp/` re-implements or loosens that confinement; it is purely a client of the same validated endpoints the web UI uses - except for `download_output`, the one tool that writes a local file for the MCP client -rather than only reading through the API. It may write anywhere the -client's own filesystem lets it (a full path, a directory, or the current -working directory by default, `~` expanded), the way a shell redirect -would for the same user; a `..` path segment in `destination` is refused, -and an existing file is left alone unless the caller passes +rather than only reading through the API. Over a stdio `dw-mcp` it may write +anywhere the client's own filesystem lets it (a full path, a directory, or +the current working directory by default, `~` expanded), the way a shell +redirect would for the same user; a `..` path segment in `destination` is +refused, and an existing file is left alone unless the caller passes `overwrite=True`. +Over `dw.serve --mcp` the write happens **on the server**, and there the +destination is confined to that workspace: an absolute or `~` path outside +it is refused, and a relative one is joined onto the workspace rather than +onto whatever the server process's working directory happens to be. The +transport is what distinguishes the two - on stdio "local disk" is genuinely +the caller's own machine, over HTTP it is the operator's. Confinement is on +the resolved real path, not a substring test, because an absolute path needs +no `..` to reach anywhere the server can write. + `dw-mcp` may be pointed at a `dw.serve` on another machine only when that server was started with a token, and the same token is passed here (`--token` / `DW_API_TOKEN`); it refuses to start otherwise. The token is diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 21358442..e2de43e6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -91,6 +91,47 @@ diffusers' remote-code paths open. No bundled catalog entry sets either (`tests/test_catalog_structure.py` refuses one that does); a workflow that needs them needs `--trust-workflows`. +### Where a workflow may read and reach (`dw/locations.py`) + +Code execution is not the only thing a workflow file chooses. Its arguments +choose *locations* - which image to open, which URL to fetch, which +directory a glob expands over - and until 2026-09-13 each loader trusted the +one it was handed. A workflow could name `/usr/share/pixmaps/debian-logo.png` +as its `image` and get it decoded, glob `/usr/share/pixmaps/*.png` and get +every match republished verbatim as an output, or point an `image` at +`http://127.0.0.1:8765` and have the server fetch its own loopback. +`remote_text_encoder.url` was the worst of them: the request carries this +machine's HuggingFace token. + +One policy now answers all of it, untrusted: + +- **A path** must resolve inside a root this installation already works in - + the workflow file's own directory, the asset libraries on the search path, + the output root. `validate_path` already refuses `..`, so in practice this + closes the absolute path that pointed somewhere else entirely. The remedy + for a file outside is to put it in the asset library and use an `asset:` + reference. Containment is checked **before** existence, so the refusal + cannot be used as a file-existence oracle. +- **A glob** is contained the same way, on the fixed directory its pattern + starts from, and every match is re-checked on its real path so a symlink + cannot carry the expansion out. +- **An `http(s)` URL** must not resolve to an address inside the deployment - + loopback, link-local (`169.254.0.0/16`, the cloud metadata address), + private ranges. Checked after DNS resolution, not on the literal string. +- **`remote_text_encoder.url`** is https-only, and the HuggingFace token is + attached only for `huggingface.co`, `huggingface.cloud` and `hf.space`. An + endpoint elsewhere is still reachable; it just does not get the credential. +- **`model_name`** must be a Hub repo id or a path inside a root - the same + shape check `download_model` has always applied to `repo_id`. + +Enforced twice: `location_errors` runs inside `validation_errors`, so +`validate_workflow` refuses before a model load is spent on the run, and the +loaders call the same functions for a location that arrives through a +variable or a previous result. All of it yields to `--trust-workflows`. + +`GET /api/server` reports `trust_workflows`, so a client can read the posture +it is running against rather than infer it. + `--trust-workflows` is a blanket, process-wide choice - it is not scoped per-workflow or per-request. A `dw-serve` instance that accepts jobs from anything other than yourself (including an MCP client - see below) should @@ -160,10 +201,13 @@ SecurityError - **Path traversal** — Cannot access files outside allowed directories - **Command injection** — No shell interpretation is used anywhere in `dw/`; `sanitize_command_args()` is available as a guard should a subprocess call be added - **Resource exhaustion** — File size limits prevent memory exhaustion -- **Malicious URLs** — Only http/https schemes allowed +- **Malicious URLs** — Only http/https schemes allowed, and an untrusted + workflow may not name a host inside the deployment (SSRF) +- **Arbitrary file read through a media argument** — a location a workflow + supplies is confined to the roots it may read (`dw/locations.py`) ## Testing ```bash -pytest tests/test_security.py -v +pytest tests/test_security.py tests/test_locations.py tests/test_workflow_trust.py -v ``` diff --git a/docs/SECURITY_QUICKREF.md b/docs/SECURITY_QUICKREF.md index a76cbad8..ccaeb634 100644 --- a/docs/SECURITY_QUICKREF.md +++ b/docs/SECURITY_QUICKREF.md @@ -19,6 +19,26 @@ from dw.security import ( ) ``` +## Locations a workflow supplies + +A media path, glob or URL that comes out of a workflow's arguments is not +just a path - it is untrusted input choosing where the server reads. Use +`dw/locations.py`, never `validate_path`/`validate_url` directly, for +anything a workflow names: + +```python +from dw.locations import ( + validate_media_path, # confined to the workflow dir / assets / outputs + validate_media_glob, # the same, on a pattern's fixed prefix + contained_matches, # each match re-checked on its real path + validate_media_url, # no loopback / link-local / private host + validate_model_name, # a Hub repo id, or a contained path +) +``` + +Containment is checked before existence, so a refusal never discloses +whether the file is there. All of it yields to `--trust-workflows`. + ## Common Patterns ```python diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index 83093038..a9794a84 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -336,6 +336,37 @@ in braces keeps it a plain string — `"{nf4}"` is the string `nf4`. Getting thi wrong fails at load time, after validation has already passed, so a value that is meant as text under one of those keys must be braced. +### A step takes only the keys the engine reads + +`step`, `task`, `workflow` and `pipeline_reference` are closed objects: a +property the engine does not read is a validation error naming the step and +the key, not a warning. There is no `when`, no `retry`, no `select` - if a +draft reaches for one, the shape it wants is a different arrangement of +steps, not a flag. The error exists because a plausible invented key used to +validate cleanly and then do nothing, so the expensive work ran with the +input silently having had no effect. `result` and `from_pretrained_arguments` +are open on purpose; `pipeline` is open because a component's name is one of +its keys. + +### Where a workflow may read and reach + +An argument that names a *location* is confined, untrusted (the default): + +- a path must resolve inside the workflow's own directory, the asset + libraries, or the output root. An absolute path elsewhere is refused + whether or not it exists. The remedy is `upload_asset` (or `keep_output`) + and an `asset:` reference - which is what those exist for. +- a `glob` is confined the same way, and each match re-checked. +- an `http(s)` URL may not resolve to an address inside the deployment - + loopback, link-local, private ranges. +- `remote_text_encoder.url` is https-only, and only a HuggingFace host is + sent this machine's token. +- `model_name` must be a Hub repo id, or a path inside one of those roots. + +All of it is reported by `validate_workflow`, before anything is queued, so +a draft that names a file the server may not read costs nothing to find out. +`get_server_info`'s `trust_workflows` says which posture is in force. + ### Remote code is refused by default A server started without `--trust-workflows` refuses any @@ -343,7 +374,7 @@ A server started without `--trust-workflows` refuses any `custom_pipeline`, at load time, after validation has passed. Use a pipeline diffusers ships: no bundled catalog entry carries either key, and a workflow that does runs only on a server whose operator turned trust on, -which `get_server_info` does not report. +which `get_server_info` reports as `trust_workflows`. ### Several `previous_result` references multiply From 00131824fa45b61eb36d50ae5c9252607261eb70 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 14:18:00 -0500 Subject: [PATCH 04/17] tmp --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 253a8407..bce1b6d5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ __pycache__/ *.so # Distribution / packaging +tmp/ .Python build/ develop-eggs/ From 781a4c8bff56d200d2f022d3b19a77e2bda9a5a3 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 15:05:23 -0500 Subject: [PATCH 05/17] fix(mcp): #108 #117 #123 #124 - a warning the caller can read, and three objects that stop ignoring keys #108: the resample concat_videos performs is emitted with emit_warning rather than logger.warning, kind 'sample_rate_mismatch', carrying the rate per video and the chosen target - a conversion made on the caller's behalf was landing with nothing in the job saying so. #117: a URL model_name joined onto the workflow directory resolved inside a root and validated clean; it is neither a repo id nor a model directory and is refused as such. #123: the workflow object and result are closed the way #118 closed step, and pipeline is closed to everything except a key whose value is a component definition - which is the rule declared_component_names already applies. 'argument_template' is declared, being engine-injected onto a sub-workflow. #124: a relative '..' media argument is refused at validation, where the absolute form and gather_images' glob already were, rather than three seconds into a queued job. --- docs/SECURITY.md | 9 ++- docs/WORKFLOW_GUIDE.md | 32 ++++++--- dw/locations.py | 30 ++++++-- dw/schema.py | 31 +++++++- dw/tasks/concat_videos.py | 28 +++++--- dw/workflow_schema.json | 32 ++++++--- tests/test_concat_videos.py | 37 ++++++++++ tests/test_locations.py | 61 ++++++++++++++++ tests/test_schema.py | 139 +++++++++++++++++++++++++++++++++--- 9 files changed, 355 insertions(+), 44 deletions(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index e2de43e6..0dcbc911 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -108,7 +108,10 @@ One policy now answers all of it, untrusted: - **A path** must resolve inside a root this installation already works in - the workflow file's own directory, the asset libraries on the search path, the output root. `validate_path` already refuses `..`, so in practice this - closes the absolute path that pointed somewhere else entirely. The remedy + closes the absolute path that pointed somewhere else entirely; a relative + one that climbs out is refused on its `..` segments, at validation time as + well as at the loader, so both spellings are answered at the same moment + rather than one of them three seconds into a queued job (#124). The remedy for a file outside is to put it in the asset library and use an `asset:` reference. Containment is checked **before** existence, so the refusal cannot be used as a file-existence oracle. @@ -122,7 +125,9 @@ One policy now answers all of it, untrusted: attached only for `huggingface.co`, `huggingface.cloud` and `hf.space`. An endpoint elsewhere is still reachable; it just does not get the credential. - **`model_name`** must be a Hub repo id or a path inside a root - the same - shape check `download_model` has always applied to `repo_id`. + shape check `download_model` has always applied to `repo_id`. A URL is + neither, and is refused as such rather than resolving into the workflow's + own directory as a path-shaped name (#117). Enforced twice: `location_errors` runs inside `validation_errors`, so `validate_workflow` refuses before a model load is spent on the run, and the diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index a9794a84..0a11e852 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -336,17 +336,27 @@ in braces keeps it a plain string — `"{nf4}"` is the string `nf4`. Getting thi wrong fails at load time, after validation has already passed, so a value that is meant as text under one of those keys must be braced. -### A step takes only the keys the engine reads - -`step`, `task`, `workflow` and `pipeline_reference` are closed objects: a -property the engine does not read is a validation error naming the step and -the key, not a warning. There is no `when`, no `retry`, no `select` - if a -draft reaches for one, the shape it wants is a different arrangement of -steps, not a flag. The error exists because a plausible invented key used to -validate cleanly and then do nothing, so the expensive work ran with the -input silently having had no effect. `result` and `from_pretrained_arguments` -are open on purpose; `pipeline` is open because a component's name is one of -its keys. +### A workflow takes only the keys the engine reads + +The workflow object itself, `step`, `task`, `workflow`, +`pipeline_reference` and `result` are closed: a property the engine does not +read is a validation error naming the object and the key, not a warning. +There is no `when`, no `retry`, no `select` - if a draft reaches for one, the +shape it wants is a different arrangement of steps, not a flag. The error +exists because a plausible invented key used to validate cleanly and then do +nothing, so the expensive work ran with the input silently having had no +effect - a mistyped `sedd` left the run unseeded while validation advised +setting a seed, and a mistyped `subfoldr` put the deliverable at the run +root rather than in `final/`. + +`pipeline` is closed to the same rule with one opening: any key whose value +is a *component definition* - an object carrying `from_pretrained_arguments` - +names a component to load, because diffusers grows component names faster +than the schema does (`latent_upsampler`, `prompt_enhancer` and `processor` +all appear that way in shipped templates). A pipeline key that is not one of +those is refused, which is what catches `pipeline_type` or `model_name` +written a level too high. `from_pretrained_arguments` stays open - it passes +its keys through to `from_pretrained`. ### Where a workflow may read and reach diff --git a/dw/locations.py b/dw/locations.py index 4b1ce6df..3b17eb93 100644 --- a/dw/locations.py +++ b/dw/locations.py @@ -375,8 +375,17 @@ def validate_model_name(name, base_dir=None): try: validate_repo_id(str(name)) return str(name) - except HFValidationError: - pass + except HFValidationError as e: + # A URL is neither a repo id nor a path, but joined onto the workflow + # directory it resolves inside a root and so passed the containment + # check below - the one shape of the four `download_model` refuses + # that got through here (#117). `from_pretrained` would refuse it + # downstream; the point of this check is not to rely on that + if "://" in str(name): + raise InvalidInputError( + f"Refusing a model_name of '{name}': it is a URL, not a Hub " + f"repo id or a local model directory ({e})." + ) return validate_media_path( str(name), base_dir, "a model_name", require_exists=False ) @@ -416,10 +425,21 @@ def _check(value, base_dir, what): if is_http_url(value): validate_media_url(value, what) elif os.path.isabs(value): - # Only an absolute path can escape: validate_path already - # refuses '..', so a relative one is under base_dir by - # construction and costs a stat nobody asked for here validate_media_path(value, base_dir, what, require_exists=False) + elif ".." in value.replace("\\", "/").split("/"): + # A relative path is under base_dir by construction unless it + # climbs out, and validate_path refuses '..' - but only when the + # loader reaches it, which is a queued job and three seconds in + # rather than a validation answer. Checked on the segments here + # the way validate_media_glob checks a pattern's, so the two + # spellings of "read outside the roots" are refused at the same + # moment (#124) + raise PathTraversalError( + f"Refusing to read {what} at '{value}': it contains a '..' " + f"path segment, so it does not resolve inside any directory " + f"this workflow may read. Put the file in the asset library " + f"and name it with an 'asset:' reference." + ) except (PathTraversalError, InvalidInputError) as e: return str(e) except Exception: diff --git a/dw/schema.py b/dw/schema.py index 3d40e432..58a15030 100644 --- a/dw/schema.py +++ b/dw/schema.py @@ -40,13 +40,34 @@ def validate_data_all(data, schema): chosen = best_match([error]) key = (json_path(chosen.absolute_path), error_message(chosen)) seen.setdefault(key, None) - ordered = sorted(seen, key=lambda key: (key[0] or "", key[1])) + ordered = sorted( + _only_unknown_property(seen), key=lambda key: (key[0] or "", key[1]) + ) return [ {"path": path, "message": message} for path, message in ordered[:MAX_VALIDATION_ERRORS] ] +def _only_unknown_property(keys): + """Drop the shape complaints about a key that is simply unknown. + + A key refused by a typed `additionalProperties` fails that shape's own + 'type' and 'required' checks too, so one mistyped 'trasformer' arrives + as three errors, two of which describe the component definition the + author never meant to write. Where a path has the unknown-property + message, it is the whole story (#123). + """ + unknown = { + path for path, message in keys if message.startswith('unknown property "') + } + return [ + (path, message) + for path, message in keys + if path not in unknown or message.startswith('unknown property "') + ] + + def error_message(error): """The message an agent can act on for one schema violation. @@ -58,6 +79,14 @@ def error_message(error): the message names the object, the key, and the full set that is read. """ if error.validator != "additionalProperties": + # An object that is closed except for one shape of key - the pipeline, + # where any other key may name a component - refuses an unknown key + # through that shape's own 'type'/'required' rather than through + # additionalProperties, and jsonschema then reports the shape rather + # than the key. The schema carries the sentence to say instead (#123) + explanation = (error.schema or {}).get("unknownPropertyMessage") + if explanation and error.absolute_path: + return f'unknown property "{error.absolute_path[-1]}" - {explanation}' return error.message allowed = sorted((error.schema or {}).get("properties") or {}) diff --git a/dw/tasks/concat_videos.py b/dw/tasks/concat_videos.py index eccd6c2d..1b18365f 100644 --- a/dw/tasks/concat_videos.py +++ b/dw/tasks/concat_videos.py @@ -10,6 +10,7 @@ import logging +from ..events import emit_warning from ..result import AudioVideo from .audio_utils import ( as_channels_samples, @@ -137,16 +138,27 @@ def concat_videos( ] sample_rate = sample_rate or (max(rates) if rates else None) if rates and any(rate != sample_rate for rate in rates): - logger.warning( - "Videos carry audio at different sample rates (" - + ", ".join( - f"{name}: {video.sample_rate} Hz" - for name, video in zip(names, videos) - if isinstance(video, AudioVideo) and video.audio is not None - ) + # emit_warning rather than logger.warning, for the reason the level + # spread below is emitted: resampling every track is an audio + # decision made on the caller's behalf, and a caller reading the job + # over the API or MCP sees the warnings list and nothing else - the + # conversion landing silently is worse than the loud failure it + # replaced (#108) + per_video = { + name: video.sample_rate + for name, video in zip(names, videos) + if isinstance(video, AudioVideo) and video.audio is not None + } + emit_warning( + "concat_videos: videos carry audio at different sample rates (" + + ", ".join(f"{name}: {rate} Hz" for name, rate in per_video.items()) + f") - resampling them all to {sample_rate} Hz. Pass " "'sample_rate' to pin a different target, or resample ahead of " - "this step with the 'resample_audio' task." + "this step with the 'resample_audio' task.", + kind="sample_rate_mismatch", + command="concat_videos", + sample_rate=sample_rate, + sample_rates=per_video, ) waveforms = [ ( diff --git a/dw/workflow_schema.json b/dw/workflow_schema.json index 37d3d258..e33235ce 100644 --- a/dw/workflow_schema.json +++ b/dw/workflow_schema.json @@ -71,6 +71,10 @@ "pattern": "^variable:", "format": "int64" }, + "argument_template": { + "description": "Engine-injected: the arguments a parent workflow passed to this one when it ran it as a sub-workflow. Written by create_step_action from the step's 'arguments' block, not authored - a workflow file carrying one is read, but a sub-workflow step is how they are meant to be supplied.", + "type": "object" + }, "steps": { "type": "array", "minItems": 1, @@ -79,6 +83,7 @@ } } }, + "additionalProperties": false, "required": [ "id", "steps" @@ -452,6 +457,19 @@ "$ref": "#/$defs/arguments" } }, + "additionalProperties": { + "description": "Any other key names a pipeline component to load. Diffusers grows component names faster than this schema does, so a key whose value is a component definition - an object carrying 'from_pretrained_arguments' - is loaded under that name. Anything else is a mistyped or misplaced key and is refused rather than ignored.", + "unknownPropertyMessage": "the engine reads the properties this object declares, plus any other key whose value is a component definition (an object carrying 'from_pretrained_arguments') - anything else would be silently ignored", + "type": "object", + "required": [ + "from_pretrained_arguments" + ], + "allOf": [ + { + "$ref": "#/$defs/pipeline_component" + } + ] + }, "required": [ "configuration", "from_pretrained_arguments", @@ -1163,17 +1181,13 @@ "description": "Whether to embed generation parameters as metadata in saved images (PNG info chunks or EXIF). Only applies to image content types.", "type": "boolean", "default": false + }, + "format": { + "description": "Audio container format handed to the writer, e.g. 'WAV' or 'FLAC'. Derived from content_type unless set. Only used when output is audio.", + "type": "string" } }, - "additionalProperties": { - "type": [ - "string", - "number", - "object", - "array", - "boolean" - ] - }, + "additionalProperties": false, "required": [ "content_type" ] diff --git a/tests/test_concat_videos.py b/tests/test_concat_videos.py index 14ac1293..b415cc21 100644 --- a/tests/test_concat_videos.py +++ b/tests/test_concat_videos.py @@ -481,6 +481,43 @@ def test_matched_shots_emit_nothing(self): assert [e for e in events if e["event"] == "warning"] == [] + def test_the_resample_warning_is_emitted_as_an_event(self): + """#108 again: the conversion shipped, the warning that says it + happened only reached the server's log, so a caller had every track + resampled on their behalf with nothing in the job saying so.""" + from dw.events import RunContext, activate_context, deactivate_context + + events = [] + token = activate_context(RunContext(on_event=events.append)) + try: + with patch( + "dw.tasks.concat_videos.load_audio_video", + return_value=audio_video(8, 0.5), + ): + concat_videos(["first.mp4", audio_video(8, 0.5, sample_rate=200)]) + finally: + deactivate_context(token) + + warnings = [e for e in events if e.get("kind") == "sample_rate_mismatch"] + assert len(warnings) == 1 + assert warnings[0]["command"] == "concat_videos" + assert warnings[0]["sample_rate"] == 200 + assert warnings[0]["sample_rates"] == {"first.mp4": 100, "video 2": 200} + assert "resampling them all to 200 Hz" in warnings[0]["message"] + assert "resample_audio" in warnings[0]["message"] + + def test_one_rate_emits_no_resample_warning(self): + from dw.events import RunContext, activate_context, deactivate_context + + events = [] + token = activate_context(RunContext(on_event=events.append)) + try: + concat_videos([audio_video(4, 0.5), audio_video(4, 0.5)]) + finally: + deactivate_context(token) + + assert [e for e in events if e.get("kind") == "sample_rate_mismatch"] == [] + class TestFrameRateTravelsWithTheJoin: """result.fps defaults to 8, so a 24 fps cut that says nothing there diff --git a/tests/test_locations.py b/tests/test_locations.py index 39133dfe..a257489e 100644 --- a/tests/test_locations.py +++ b/tests/test_locations.py @@ -300,6 +300,17 @@ def test_a_local_model_directory_inside_a_root_is_allowed( os.makedirs(local) assert validate_model_name(local, workflow_dir) + @pytest.mark.parametrize( + "name", + ["http://127.0.0.1:8765/", "https://evil.example.com/model", "file:///etc"], + ) + def test_a_url_shaped_name_is_refused(self, untrusted, workflow_dir, name): + """#117: joined onto the workflow directory a URL resolved inside a + root, so the one shape download_model refuses that reached + model_name went on validating clean.""" + with pytest.raises(InvalidInputError): + validate_model_name(name, workflow_dir) + class TestValidationTimeErrors: """Refused by validate_workflow rather than after a pipeline load.""" @@ -398,6 +409,56 @@ def test_references_and_relative_paths_are_left_alone( } assert location_errors(definition, base_dir=workflow_dir) == [] + @pytest.mark.parametrize( + "location", + [ + "../../../../../usr/share/pixmaps/debian-logo.png", + "../../../../etc/hostname", + ], + ) + def test_a_relative_traversal_is_an_error_too( + self, untrusted, workflow_dir, location + ): + """#124: the absolute spelling was refused here and the relative one + only when the loader reached it - three seconds into a queued job, + after validate_workflow had said to go ahead.""" + definition = { + "steps": [ + { + "name": "probe", + "task": { + "command": "get_image_size", + "arguments": {"image": location}, + }, + } + ] + } + + errors = location_errors(definition, base_dir=workflow_dir) + + assert [error["path"] for error in errors] == ["steps[0].task.arguments.image"] + assert "'..'" in errors[0]["message"] + + def test_a_url_shaped_model_name_is_an_error(self, untrusted, workflow_dir): + """#117 at validation time, where the tester found it.""" + definition = { + "steps": [ + { + "name": "load", + "pipeline": { + "from_pretrained_arguments": { + "model_name": "http://127.0.0.1:8765/" + } + }, + } + ] + } + + assert [ + error["path"] + for error in location_errors(definition, base_dir=workflow_dir) + ] == ["steps[0].pipeline.from_pretrained_arguments.model_name"] + def test_the_error_carries_the_authored_step_index(self, untrusted, workflow_dir): """A for_each member reports against the step the author wrote.""" definition = { diff --git a/tests/test_schema.py b/tests/test_schema.py index d93b36b2..4f53b86d 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -580,15 +580,138 @@ def test_the_other_swept_objects_are_closed_too(self, definition): errors = validate_data_all(workflow, schema) assert errors, f"{definition} should not have validated" - def test_the_pipeline_object_stays_open(self): - """The reason `pipeline` is not in the list above: a component's name - is one of its keys - `latent_upsampler`, `prompt_enhancer` and - `processor` all appear that way in shipped templates - so the - zero-stray-key sweep it would need cannot pass until those are named - properties. Pinned so a later sweep does not close it by eye.""" + def test_the_pipeline_object_is_open_only_to_a_component(self): + """#123: `pipeline` cannot be closed outright - a component's name is + one of its keys, and `latent_upsampler`, `prompt_enhancer` and + `processor` all appear that way in shipped templates. So it takes the + rule `declared_component_names` applies: any other key whose value is + a component definition, and nothing else.""" schema = load_schema("workflow") - for name in ("pipeline", "pipeline_component"): - assert schema["$defs"][name].get("additionalProperties") is not False + extra = schema["$defs"]["pipeline"]["additionalProperties"] + assert extra["required"] == ["from_pretrained_arguments"] + assert extra["type"] == "object" + # pipeline_component itself stays open - its own keys are the + # from_pretrained kwargs a component takes + assert ( + schema["$defs"]["pipeline_component"].get("additionalProperties") + is not False + ) + + @pytest.mark.parametrize( + "stray", + [ + {"pipeline_type": "diffusers.Whatever"}, + {"model_name": "org/typo"}, + {"trasformer": {}}, + ], + ) + def test_a_key_that_is_not_a_component_is_an_error(self, stray): + """#123: `pipeline_type` and `model_name` are real names from the + wrong level and `trasformer` is a typo; all three used to validate + and be ignored, and the `model_name` one ran against whatever + `from_pretrained_arguments` said.""" + schema = load_schema("workflow") + workflow = { + "id": "probe", + "steps": [ + { + "name": "draw", + "pipeline": { + "configuration": {"component_type": "StableDiffusionPipeline"}, + "from_pretrained_arguments": {"model_name": "org/model"}, + "arguments": {"prompt": "x"}, + **stray, + }, + "result": {"content_type": "image/jpeg"}, + } + ], + } + + errors = validate_data_all(workflow, schema) + + assert [error["path"] for error in errors] == [ + f"steps[0].pipeline.{list(stray)[0]}" + ] + # one message per stray key, and it is the one that says what is + # wrong rather than a complaint about the component it is not + assert errors[0]["message"].startswith(f'unknown property "{list(stray)[0]}"') + assert "from_pretrained_arguments" in errors[0]["message"] + + def test_a_component_named_by_a_key_still_validates(self): + schema = load_schema("workflow") + workflow = { + "id": "probe", + "steps": [ + { + "name": "draw", + "pipeline": { + "configuration": {"component_type": "LTXPipeline"}, + "from_pretrained_arguments": {"model_name": "org/model"}, + "arguments": {"prompt": "x"}, + "latent_upsampler": { + "configuration": {"component_type": "LatentUpsampler"}, + "from_pretrained_arguments": {"model_name": "org/up"}, + }, + }, + "result": {"content_type": "video/mp4"}, + } + ], + } + + assert validate_data_all(workflow, schema) == [] + + @pytest.mark.parametrize( + "stray", + [{"varaibles": {"x": 1}}, {"sedd": 42}, {"when": "always"}], + ) + def test_an_unknown_top_level_property_is_an_error(self, stray): + """#123: `sedd` was the sharp one - validation answered that the + workflow set no seed and advised setting one, with the misspelling + of it in front of it.""" + schema = load_schema("workflow") + workflow = { + "id": "probe", + "steps": [ + {"name": "s", "task": {"command": "gather_inputs", "arguments": {}}} + ], + **stray, + } + + errors = validate_data_all(workflow, schema) + + assert [error["path"] for error in errors] == [None] + assert list(stray)[0] in errors[0]["message"] + assert "steps" in errors[0]["message"] + + @pytest.mark.parametrize( + "stray", [{"subfoldr": "final"}, {"fille_base_name": "x"}, {"fsp": 24}] + ) + def test_an_unknown_result_property_is_an_error(self, stray): + """#123: a mistyped `subfoldr` means the deliverable quietly lands at + the run root rather than in the `final/` the convention promises.""" + schema = load_schema("workflow") + workflow = { + "id": "probe", + "steps": [ + { + "name": "s", + "task": {"command": "gather_inputs", "arguments": {}}, + "result": {"content_type": "text/plain", **stray}, + } + ], + } + + errors = validate_data_all(workflow, schema) + + assert [error["path"] for error in errors] == ["steps[0].result"] + assert list(stray)[0] in errors[0]["message"] + assert "subfolder" in errors[0]["message"] + + def test_the_later_swept_objects_are_closed_in_the_schema(self): + """#123: the three the #118 sweep left open.""" + schema = load_schema("workflow") + assert schema["additionalProperties"] is False + assert schema["$defs"]["result"]["additionalProperties"] is False def test_the_swept_objects_are_closed_in_the_schema(self): schema = load_schema("workflow") From 9edb3b599ba8e86a430a49179f4cb4f6d7c247ff Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 15:10:29 -0500 Subject: [PATCH 06/17] fix(mcp): #123 - the engine-injected key is legal but not advertised --- dw/schema.py | 11 ++++++++++- tests/test_schema.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/dw/schema.py b/dw/schema.py index 58a15030..f2d866e2 100644 --- a/dw/schema.py +++ b/dw/schema.py @@ -89,7 +89,16 @@ def error_message(error): return f'unknown property "{error.absolute_path[-1]}" - {explanation}' return error.message - allowed = sorted((error.schema or {}).get("properties") or {}) + properties = (error.schema or {}).get("properties") or {} + # An engine-injected key is legal but is not something an author writes, + # so listing it as one of the properties on offer only invites its use + allowed = sorted( + name + for name, subschema in properties.items() + if not str((subschema or {}).get("description", "")).startswith( + "Engine-injected" + ) + ) instance = error.instance if isinstance(error.instance, dict) else {} unknown = sorted(key for key in instance if key not in allowed) name = instance.get("name") diff --git a/tests/test_schema.py b/tests/test_schema.py index 4f53b86d..ff0c3967 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -707,6 +707,22 @@ def test_an_unknown_result_property_is_an_error(self, stray): assert list(stray)[0] in errors[0]["message"] assert "subfolder" in errors[0]["message"] + def test_the_engine_injected_key_is_not_advertised(self): + """'argument_template' is written onto a sub-workflow by the engine, + so it is legal - but listing it among the properties on offer would + invite an author to write it by hand (#123).""" + schema = load_schema("workflow") + errors = validate_data_all( + { + "id": "probe", + "sedd": 1, + "steps": [{"name": "s", "task": {"command": "x", "arguments": {}}}], + }, + schema, + ) + assert "argument_template" not in errors[0]["message"] + assert "seed" in errors[0]["message"] + def test_the_later_swept_objects_are_closed_in_the_schema(self): """#123: the three the #118 sweep left open.""" schema = load_schema("workflow") From 094fb2c5689d2bab72ae60febec5443c2cf467e5 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 15:34:20 -0500 Subject: [PATCH 07/17] fix(mcp): #126 #127 - padding that says so, and an asset you can measure #126: slice_audio zero-pads a slice that reaches past the end of its source and said nothing. In templates/assemble-and-score that is the default path - total_frames is the length of the cut, the score is a separate asset with its own length - so a short score left the film unscored for the rest of its length with warnings: []. It now emits a `slice_past_end` warning carrying the source, requested and padded lengths, and naming loop_audio as the remedy; padding under 10 ms is the rounding frame-aligned slicing produces and stays quiet. The padding itself is unchanged - a few frames of tail pad is a legitimate thing to want. The task's docs, TASKS.md and the template's description now say what happens past the end. #127: get_gallery_metadata resolved its name against the outputs root only, so an input asset's duration, frame count, fps, sample rate and channels were unreadable - the numbers a caller has to supply as total_frames, fps and sample_rate were obtainable for a file it had generated and not for one it was about to consume. `name` may now be an `asset:` reference, resolved down the same search path a run resolves one in; `job` is null and a new `source` field says which root answered. The MCP hint for an asset says these are pre-run numbers, and list_assets points at it. Co-Authored-By: Claude Opus 5 --- docs/MCP.md | 2 +- docs/TASKS.md | 10 +- dw/server/app.py | 63 +++++++++-- dw/tasks/audio_utils.py | 55 ++++++++- dw_mcp/catalog.py | 16 ++- dw_mcp/server.py | 15 ++- tests/test_audio_utils.py | 117 ++++++++++++++++++++ tests/test_mcp_catalog.py | 26 +++++ tests/test_server.py | 102 +++++++++++++++++ workflows/templates/assemble-and-score.json | 2 +- 10 files changed, 390 insertions(+), 18 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index ecf70c4a..6ba3e3d4 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -227,7 +227,7 @@ when no single workflow covers it. | `get_server_info()` | — | What this installation can do and where it keeps things: `device` (the accelerator a run will use), `version`, the `workspace` this session is working in and the workflow/asset/output/prompt `directories` of *that* workspace, the bind address and port, whether a token is required, and whether MCP is mounted. Check the device before authoring - a CUDA-only choice (bitsandbytes, `torch.compile`, flash attention) is not available on an `mps` or `cpu` server | | `list_jobs(limit=20, status=None, workspace=None)` | optional `limit` (newest N), `status` (one state or a comma-separated set of `queued`, `running`, `succeeded`, `failed`, `cancelled`), `workspace` | List queued, running and recent jobs, **newest first**. Bounded by default: the unbounded listing was over a client's tool-result limit on a server with a few months of history, which made it a tool that could not be called at all. `total` says how many matched and `truncated`/`next` say so when the answer was cut - raise `limit` or narrow with `status`. Without `workspace`, a named workspace lists its own jobs and the default one lists every job the server holds | | `list_gallery(limit=50, subfolder=None, workspace=None)` | `limit`, `subfolder`, `workspace` | List generated output files, newest first. A name is `//`, where `` may sit in the subfolder the step chose (`final/episode.mp4`); each entry carries `folder` (the workflow) and `subfolder` (by convention `final` or `intermediate`, `''` when the step chose none, any path the workflow wrote otherwise), and `subfolder="final"` lists only deliverables. Each entry also carries a ready-made `url`, already scoped to the workspace that made it - a hand-built `/outputs/` URL 404s for anything but the default workspace. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it | -| `get_gallery_metadata(name, envelope=False, workspace=None)` | `name`, `workspace` | Get the metadata embedded in a generated file: the exact workflow and arguments that produced it, and, for audio/video, a `media` block (duration, rate, channels, fps, size, peak/mean dBFS). `envelope=true` adds `media.envelope` — `rms_dbfs` and `peak_dbfs` one entry per second — which is what locates something in a track rather than measuring the whole of it. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it | +| `get_gallery_metadata(name, envelope=False, workspace=None)` | `name`, `workspace` | Get the metadata embedded in a generated file — or, when `name` is an `asset:` reference, what an *input* asset holds (`source` says which; `job` is null for an asset). Reading an input's duration, frame count, fps and sample rate before a run is how a caller learns the `total_frames`, `fps` and `sample_rate` a workflow expects it to supply: the exact workflow and arguments that produced it, and, for audio/video, a `media` block (duration, rate, channels, fps, size, peak/mean dBFS). `envelope=true` adds `media.envelope` — `rms_dbfs` and `peak_dbfs` one entry per second — which is what locates something in a track rather than measuring the whole of it. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it | ### Media diff --git a/docs/TASKS.md b/docs/TASKS.md index df1c74bb..d4c1ce5a 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -438,8 +438,14 @@ returns frames without it, and this puts it back: ### slice_audio Cut a slice out of an audio track, addressed in seconds or in video frames. -Slices reaching past the end of the track are zero-padded. Either half of a pair -may be left out - an omitted start begins at the head of the track, an omitted +Slices reaching past the end of the track are zero-padded — asking for more +than the source holds returns a track of the length you asked for whose tail is +digital silence, not a shorter track and not an error. Anything past a few +milliseconds of that padding is reported as a `slice_past_end` warning on the +job, because a score laid under a longer cut goes silent for the rest of the +film without anything else saying so; to fill a cut longer than the recording, +build a bed with [`loop_audio`](#loop_audio) first and slice that. Either half +of a pair may be left out - an omitted start begins at the head of the track, an omitted duration runs to the end of it - so a workflow that trims only when it is given a length still passes the whole track along: diff --git a/dw/server/app.py b/dw/server/app.py index efe4eb78..48a2b263 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -2137,6 +2137,29 @@ def _output_file(name, root=None): raise HTTPException(status_code=404, detail="Unknown file") return path + def _asset_file(reference, ws): + """The file an 'asset:' reference names in this workspace, or a 404. + + Looked for down the same search path a run resolves 'asset:' in + (_asset_roots), so what the API can read is what a job would load. + The first root's failure is the one reported: it names the + workspace's own library, which is where a caller expects their + asset to be, rather than an examples directory they never wrote to. + """ + first = None + for root in _asset_roots(ws) or [ws.assets]: + if not root: + continue + try: + return resolve_asset_reference(reference, asset_dir=root) + except (SecurityError, ValueError) as e: + first = first or e + raise HTTPException( + status_code=404, + detail=str(first) + or f"Unknown asset {reference!r}: this workspace has no asset library", + ) + def _static_files_for(root): """The StaticFiles instance bound to one root, built on first use and cached on app.state - see the comment where the cache is created.""" @@ -2347,23 +2370,43 @@ def gallery_metadata( is what says *where* in a track something is - whether a shot is still voiced at its last frame, how deep the hole at a seam goes. Opt-in: a ten-minute track is 600 numbers, and the default call has - to stay small.""" - path = _output_file(name, ws.outputs) + to stay small. + + `name` may also be an 'asset:' reference, and then it is the input + asset of that name that is described rather than an output (#127). + The numbers here - duration, frame count, fps, sample rate - are + what decide whether a call will work at all, and for a file the + caller is about to *consume* they were previously unobtainable: + the only way to read a wav's length was to run a job that copied it + into the output directory. `job` is null for an asset (nothing here + produced it) and `source` says which of the two roots answered.""" + if is_asset_reference(name): + path = _asset_file(name, ws) + source, job = "asset", None + else: + path = _output_file(name, ws.outputs) + source = "output" + try: + # Scoped to this workspace: two workspaces can each write a + # file with the same relative name, and an unscoped lookup + # could attribute this one to the wrong workspace's job + job = manager.history.job_for_file(name, workspace=ws.name) + except Exception: + job = None metadata = read_embedded_metadata(path) - try: - # Scoped to this workspace: two workspaces can each write a file - # with the same relative name, and an unscoped lookup could - # attribute this one to the wrong workspace's job - job = manager.history.job_for_file(name, workspace=ws.name) - except Exception: - job = None extension = os.path.splitext(path)[1].lower() media = ( probe_media(path, envelope=envelope) if MEDIA_KINDS.get(extension) in ("audio", "video") else None ) - return {"name": name, "metadata": metadata, "job": job, "media": media} + return { + "name": name, + "source": source, + "metadata": metadata, + "job": job, + "media": media, + } @app.get("/api/gallery/{name:path}/thumbnail") @query_token_ok diff --git a/dw/tasks/audio_utils.py b/dw/tasks/audio_utils.py index b0260729..29614f9e 100644 --- a/dw/tasks/audio_utils.py +++ b/dw/tasks/audio_utils.py @@ -28,6 +28,10 @@ # discontinuity does not click DECLICK_MS = 3.0 +# Padding shorter than this at the end of a slice is the rounding that +# frame-aligned slicing produces, not a slice that overran its source +SLICE_PAD_WARN_MS = 10.0 + def as_channels_samples(audio): """Normalize a waveform to a (channels, samples) float32 numpy array. @@ -280,8 +284,15 @@ def slice_audio( """Task command: cut a slice out of an audio track. The slice is addressed either in seconds (start_seconds + duration_seconds) - or in video frames (start_frame + num_frames + fps). Slices reaching past - the end of the track are zero-padded. + or in video frames (start_frame + num_frames + fps). + + A slice reaching past the end of the track is zero-padded to the length + asked for - it does not fail and it is not shortened - and the padding is + digital silence, so asking for more than the source holds returns a track + that is partly empty. Anything past a few milliseconds of that is + reported as a 'slice_past_end' warning on the job. To fill a cut longer + than the recording, make a bed with the 'loop_audio' task first + ('target_frames' + 'fps' matches one exactly) and slice that. Either half of a pair may be left out: with no start the slice begins at the head of the track, and with no duration it runs to the end of it. A workflow @@ -334,9 +345,49 @@ def slice_audio( "'start_frame'/'num_frames'/'fps'" ) + _warn_on_slice_past_end(total, start, length, sample_rate) return _as_track(slice_samples(waveform, start, length), sample_rate) +def _warn_on_slice_past_end(total, start, length, sample_rate): + """Say when a slice asked for more material than its source holds. + + slice_samples zero-pads the shortfall, which is what makes frame-aligned + chunking near the end of a track work at all - but the same padding is + how a score shorter than the film it is laid under leaves the film + unscored for the rest of its length, with nothing anywhere saying so + (#126). emit_warning rather than logger.warning for the reason the + concat_videos resample warning is emitted: silently substituting silence + for four fifths of a track is an audio decision made on the caller's + behalf, and a caller reading the job over the API or MCP sees the + warnings list and nothing else (#82, #108). + """ + available = max(0, min(total - start, length)) + padded = length - available + if padded <= 0 or not sample_rate: + return + padded_seconds = padded / float(sample_rate) + if padded_seconds * 1000.0 < SLICE_PAD_WARN_MS: + # Frame-aligned slicing lands a sample or two past the end routinely; + # that is rounding, not a decision anyone can act on + return + emit_warning( + f"slice_audio: the requested slice runs " + f"{padded_seconds:.2f} s past the end of a " + f"{total / float(sample_rate):.2f} s source, so that much of the " + f"{length / float(sample_rate):.2f} s returned is digital silence. " + f"If you meant to fill a cut of this length, make a bed with the " + f"'loop_audio' task ('target_frames' + 'fps' matches one exactly) " + f"and slice that; if you meant the tail pad, nothing is wrong.", + kind="slice_past_end", + command="slice_audio", + source_seconds=round(total / float(sample_rate), 3), + requested_seconds=round(length / float(sample_rate), 3), + padded_seconds=round(padded_seconds, 3), + sample_rate=sample_rate, + ) + + def resample_waveform(waveform, sample_rate, target_sample_rate): """A waveform at a different rate, as a plain (channels, samples) array. diff --git a/dw_mcp/catalog.py b/dw_mcp/catalog.py index 7b6d50c1..91a4ba96 100644 --- a/dw_mcp/catalog.py +++ b/dw_mcp/catalog.py @@ -212,6 +212,10 @@ def get_gallery_metadata(client, name, envelope=False, workspace=None): audio and video what the file holds - duration, sample rate, channels, fps, size, peak and mean level in dBFS. + `name` is a gallery name, or an 'asset:' reference to read an input + asset the same way (#127) - the same numbers, `job` null, and `source` + saying which of the two answered. + With envelope=True the soundtrack's level is reported second by second as well, which is what locates something in a track rather than only measuring the whole of it. Opt-in: it is one number per second per @@ -222,7 +226,17 @@ def get_gallery_metadata(client, name, envelope=False, workspace=None): workspace=workspace, ) media = body.get("media") - if media and media.get("kind") in ("audio", "video"): + if media and body.get("source") == "asset": + body["next"] = ( + "These are the numbers a workflow's arguments have to match " + "before the run, not after: frame_count and fps decide a cut's " + "'total_frames', sample_rate decides what its audio is mixed " + "at, and duration_seconds says whether a score reaches the " + "length of the film it goes under - a score shorter than the " + "cut is padded with digital silence rather than refused, so " + "make a longer bed with the 'loop_audio' task instead." + ) + elif media and media.get("kind") in ("audio", "video"): body["next"] = ( "Check duration_seconds against what was asked for: a Music 3 " "track that lands within 0.2 s of its audio_duration ceiling was " diff --git a/dw_mcp/server.py b/dw_mcp/server.py index f65ae779..536fb193 100644 --- a/dw_mcp/server.py +++ b/dw_mcp/server.py @@ -381,6 +381,16 @@ def get_gallery_metadata( Leave it off unless you are asking a question about a position in the track - a long track is a long list. + `name` may be an "asset:" reference instead of a gallery name, and + then it describes that input asset. This is how you learn what an + asset you are about to pass to a workflow actually holds - how many + frames a shot is, whether two shots share an fps, whether a score + reaches the length of the cut you are about to lay it under. Do + that before running rather than after: a workflow's frame counts + and rates are arguments the caller supplies, and getting one wrong + is discovered as a failed job or, worse, as silence padded onto the + end of a track. + `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace is reachable from @@ -544,7 +554,10 @@ def download_output( def list_assets() -> dict: """List the input media on the server, each with the "asset:" reference a workflow argument carries. Look here before asking for - a file: what a workflow needs may already be there.""" + a file: what a workflow needs may already be there. Entries carry + name, kind, size and origin only - for one asset's duration, frame + count, fps, sample rate or channels, pass its reference to + `get_gallery_metadata`, which reads inputs as well as outputs.""" return assets.list_assets(client) def upload_asset( diff --git a/tests/test_audio_utils.py b/tests/test_audio_utils.py index 997c1b87..2fb36315 100644 --- a/tests/test_audio_utils.py +++ b/tests/test_audio_utils.py @@ -642,3 +642,120 @@ def test_an_empty_source_is_refused(self): duration_seconds=1.0, sample_rate=100, ) + + +class TestSlicingPastTheEndOfATrack: + """#126: a slice reaching past the end of its source is zero-padded, and + said nothing about it. In `templates/assemble-and-score` that is the + default path - `total_frames` is the length of the cut and the score is a + separate asset with its own length - so a score shorter than the film + left the film unscored for the rest of its length with `warnings: []`. + The padding stays (a few frames of tail pad is a legitimate thing to + want); what it no longer does is happen in silence. + """ + + def events_from(self, call): + from dw.events import RunContext, activate_context, deactivate_context + + events = [] + token = activate_context(RunContext(on_event=events.append)) + try: + call() + finally: + deactivate_context(token) + return [e for e in events if e.get("kind") == "slice_past_end"] + + def tone(self, samples=330, rate=100, channels=1): + return numpy.full((channels, samples), 0.5, dtype=numpy.float32) + + def test_a_slice_past_the_end_is_reported(self): + from dw.tasks.audio_utils import slice_audio + + warnings = self.events_from( + lambda: slice_audio( + self.tone(), + start_frame=0, + num_frames=372, + fps=24, + sample_rate=100, + ) + ) + + assert len(warnings) == 1 + warning = warnings[0] + assert warning["command"] == "slice_audio" + assert warning["source_seconds"] == pytest.approx(3.3) + assert warning["requested_seconds"] == pytest.approx(15.5) + assert warning["padded_seconds"] == pytest.approx(12.2) + # The remedy is a task that already exists and is not mentioned + # anywhere near slice_audio + assert "loop_audio" in warning["message"] + assert "silence" in warning["message"] + + def test_the_padding_itself_is_unchanged(self): + from dw.tasks.audio_utils import slice_audio + + sliced = slice_audio( + self.tone(), start_seconds=0, duration_seconds=10.0, sample_rate=100 + ) + + assert samples(sliced).shape == (1000, 1) + assert numpy.all(numpy.asarray(sliced.audio)[:, 330:] == 0) + + def test_a_slice_inside_the_track_says_nothing(self): + from dw.tasks.audio_utils import slice_audio + + assert ( + self.events_from( + lambda: slice_audio( + self.tone(), + start_seconds=0, + duration_seconds=3.3, + sample_rate=100, + ) + ) + == [] + ) + + def test_slicing_to_the_end_of_the_track_says_nothing(self): + """No duration means 'to the end', which cannot overrun.""" + from dw.tasks.audio_utils import slice_audio + + assert ( + self.events_from( + lambda: slice_audio(self.tone(), start_seconds=1.0, sample_rate=100) + ) + == [] + ) + + def test_a_few_samples_of_rounding_are_not_a_warning(self): + """Frame-aligned slicing lands a sample or two past the end all the + time; a warning fired on that is noise nobody can act on.""" + from dw.tasks.audio_utils import slice_audio + + assert ( + self.events_from( + lambda: slice_audio( + self.tone(samples=1000), + start_seconds=0, + duration_seconds=10.002, + sample_rate=100, + ) + ) + == [] + ) + + def test_a_start_beyond_the_end_is_reported_as_all_padding(self): + from dw.tasks.audio_utils import slice_audio + + warnings = self.events_from( + lambda: slice_audio( + self.tone(), + start_seconds=10.0, + duration_seconds=2.0, + sample_rate=100, + ) + ) + + assert len(warnings) == 1 + assert warnings[0]["padded_seconds"] == pytest.approx(2.0) diff --git a/tests/test_mcp_catalog.py b/tests/test_mcp_catalog.py index f576abff..c9354202 100644 --- a/tests/test_mcp_catalog.py +++ b/tests/test_mcp_catalog.py @@ -231,3 +231,29 @@ def test_gallery_metadata_passes_the_media_block_through_and_says_how_to_read_it assert result["media"]["duration_seconds"] == 45.05 assert "audio_duration" in result["next"] + + +def test_gallery_metadata_reads_an_asset_and_says_the_numbers_are_inputs(): + """#127: the same tool answers for an input asset, and the hint it + carries is the one that matters before a run rather than after.""" + body = { + "name": "asset:uploads/room-bed.wav", + "source": "asset", + "metadata": None, + "job": None, + "media": {"kind": "audio", "duration_seconds": 3.3, "sample_rate": 32000}, + } + client, _ = scripted( + { + ( + "GET", + "/api/gallery/asset:uploads/room-bed.wav/metadata", + ): (200, body) + } + ) + + result = catalog.get_gallery_metadata(client, "asset:uploads/room-bed.wav") + + assert result["media"]["duration_seconds"] == 3.3 + assert "loop_audio" in result["next"] + assert "audio_duration" not in result["next"] diff --git a/tests/test_server.py b/tests/test_server.py index c32d2cdb..3f3bc917 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4187,3 +4187,105 @@ def test_an_unknown_job_is_still_404(self, server): json={"acknowledged_cost": {"fingerprint": "sha256:0"}}, ) assert response.status_code == 404 + + +def test_gallery_metadata_reads_an_asset_reference(asset_server, tmp_path): + """#127: an input asset's duration, fps, sample rate and channel count + were unreadable over the API - the one tool that reports them resolved + its name against the outputs root only, so the numbers that decide + whether a call will work were obtainable for a file the caller had + already generated and not for one it was about to consume. Reading them + took a job that copied the asset into outputs.""" + from PIL import Image + from tests.test_media_info import write_mp4, write_wav + + with asset_server(success_script) as client: + assets = tmp_path / "assets" + (assets / "uploads" / "qa-cast").mkdir(parents=True) + write_wav(assets / "uploads" / "qa-cast" / "room-bed.wav", seconds=3.3) + write_mp4(assets / "shot.mp4", frames=12, fps=6) + Image.new("RGB", (4, 4)).save(assets / "still.png") + + # percent-encoded exactly as the MCP client sends it (api_path + # quotes every segment, slashes included), which is the form the + # tester's calls arrive in + bed = client.get( + "/api/gallery/asset%3Auploads%2Fqa-cast%2Froom-bed.wav/metadata" + ).json() + assert bed["source"] == "asset" + assert bed["name"] == "asset:uploads/qa-cast/room-bed.wav" + assert bed["media"]["kind"] == "audio" + assert bed["media"]["duration_seconds"] == pytest.approx(3.3, abs=0.02) + # an asset was not produced by a job of this server's, and saying so + # is honest rather than an error + assert bed["job"] is None + + shot = client.get("/api/gallery/asset:shot.mp4/metadata").json() + assert shot["media"]["frame_count"] == 12 + assert shot["media"]["fps"] == pytest.approx(6.0, abs=0.01) + + # an image asset carries no media block, the same as an image output + still = client.get("/api/gallery/asset:still.png/metadata").json() + assert still["source"] == "asset" + assert still["media"] is None + + # and an output is unchanged - it still says where it came from + write_wav(tmp_path / "outputs" / "score-gen.0-0.0.wav", seconds=1.0) + output = client.get("/api/gallery/score-gen.0-0.0.wav/metadata").json() + assert output["source"] == "output" + assert output["media"]["duration_seconds"] == pytest.approx(1.0, abs=0.02) + + +def test_gallery_metadata_takes_an_envelope_of_an_asset(asset_server, tmp_path): + with asset_server(success_script) as client: + from tests.test_media_info import write_wav + + write_wav(tmp_path / "assets" / "bed.wav", seconds=3.0) + + body = client.get( + "/api/gallery/asset:bed.wav/metadata", params={"envelope": "true"} + ).json() + assert len(body["media"]["envelope"]["rms_dbfs"]) == 3 + + +def test_gallery_metadata_refuses_an_asset_that_escapes_the_library( + asset_server, tmp_path +): + (tmp_path / "secret.wav").write_bytes(b"x") + + with asset_server(success_script) as client: + assert ( + client.get("/api/gallery/asset:..%2Fsecret.wav/metadata").status_code == 404 + ) + missing = client.get("/api/gallery/asset:nothing.wav/metadata") + assert missing.status_code == 404 + assert "asset library" in missing.json()["detail"] + + +def test_gallery_metadata_finds_an_asset_an_examples_tree_brought(tmp_path): + """Assets are looked for down the same search path 'asset:' resolves in, + so an example's own media is readable too.""" + from tests.test_media_info import write_wav + + workflows = tmp_path / "workflows" + workflows.mkdir() + examples = tmp_path / "examples" + (examples / "assets").mkdir(parents=True) + write_wav(examples / "assets" / "example-bed.wav", seconds=1.5) + + manager = JobManager( + str(tmp_path / "outputs"), + worker_manager=ScriptedWorkerManager(success_script), + history_path=str(tmp_path / "jobs.sqlite"), + workflow_dir=str(workflows), + ) + app = create_app( + workflow_dir=str(workflows), + output_dir=str(tmp_path / "outputs"), + job_manager=manager, + asset_dir=str(tmp_path / "assets"), + examples_dirs=[str(examples)], + ) + with TestClient(app, base_url="http://localhost") as client: + body = client.get("/api/gallery/asset:example-bed.wav/metadata").json() + assert body["media"]["duration_seconds"] == pytest.approx(1.5, abs=0.02) diff --git a/workflows/templates/assemble-and-score.json b/workflows/templates/assemble-and-score.json index 932eca2c..fa15cfd5 100644 --- a/workflows/templates/assemble-and-score.json +++ b/workflows/templates/assemble-and-score.json @@ -1,6 +1,6 @@ { "id": "assemble-and-score", - "description": "Cuts shots that already exist into one film with a score under them, generating nothing in the run. It is the pass you re-run while cutting, when re-generating the footage each time would cost GPU hours to change a fade. The shots go straight into concat_videos as hard cuts - nothing is stabilized or rescaled on the way, so a deliberate camera move survives the edit; they must already share one size and frame rate, and the task refuses a set that does not. 'shots' is a list, so a diptych is two entries and a reel is however many the cut needs. The shots' own recorded sound is carried up to the score's sample rate and mixed underneath it, so the room tone of each world survives the edit instead of being replaced by music. Supply the shots and the score as asset: references - 'shot_1.mp4' and friends, uploaded with upload_asset or promoted from a generated run with keep_output. Shots generated independently also drift in loudness - 10 dB between two shots of one scene is ordinary - and no seam control can hide a level jump, because it is either side of the cut rather than at it; 'match_levels' ('rms' for perceived level, 'peak' for the loudest sample) evens the shots out before they are joined, and left null, as it is by default, a wide spread is warned about in the log rather than passing in silence.", + "description": "Cuts shots that already exist into one film with a score under them, generating nothing in the run. It is the pass you re-run while cutting, when re-generating the footage each time would cost GPU hours to change a fade. The shots go straight into concat_videos as hard cuts - nothing is stabilized or rescaled on the way, so a deliberate camera move survives the edit; they must already share one size and frame rate, and the task refuses a set that does not. 'shots' is a list, so a diptych is two entries and a reel is however many the cut needs. The shots' own recorded sound is carried up to the score's sample rate and mixed underneath it, so the room tone of each world survives the edit instead of being replaced by music. Supply the shots and the score as asset: references - 'shot_1.mp4' and friends, uploaded with upload_asset or promoted from a generated run with keep_output. Shots generated independently also drift in loudness - 10 dB between two shots of one scene is ordinary - and no seam control can hide a level jump, because it is either side of the cut rather than at it; 'match_levels' ('rms' for perceived level, 'peak' for the loudest sample) evens the shots out before they are joined, and left null, as it is by default, a wide spread is warned about in the log rather than passing in silence. 'total_frames' is the length of the cut in frames and 'score' is a separate asset with its own length: the score is sliced to 'total_frames' from 'score_start_frame', and a score that does not reach that far is padded to it with digital silence, which leaves the rest of the film unscored under the shots' own sound (the run says so as a 'slice_past_end' warning, but the film itself sounds plausible). A score must therefore be at least as long as the cut; to stretch a short bed to reach, make a longer one with the 'loop_audio' task first and pass that as 'score'.", "cost": [ {"device": "cuda", "name": "RTX 3090", "vram_gb": 24, "minutes": 0.2} ], From 931b169ddc97e582bf4053e52f1ef0ec54e9a537 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 17:15:09 -0500 Subject: [PATCH 08/17] feat(ui): for_each entries drawn inset in the flow view A list-driven step showed as one box on the job page's flow view while its members ran one by one in the Progress list. The group box now grows and renders one inset chip per entry, beneath the header the ordinary box's three lines occupy: chips named as the workflow wrote them (an entry's name, else its index - _entry_keys' rule) with the engine's group@entry name on hover, a chip greening as its step_end arrives and the running one pulsing amber. The group box keeps its existing all-members-done rule, and the arrows and fan-in labels still attach to it as a whole; column layout is height-aware so a tall box no longer overlaps the node beneath it. The member list is read from the definition - a literal for_each list, or a variable: reference resolved against variables, which the realized workflow carries with the run's actual list folded in (dw/realize.py) - so historical jobs show their members too, with no server change. A list that cannot be read statically shows a muted for_each stand-in. Between one entry finishing and the next starting, activeMember now reports nothing: the run is on neither, and a chip still amber there would lie about progress. Co-Authored-By: Claude --- ui/src/lib/editor/FlowView.svelte | 143 ++++++++++++++++++++++++++--- ui/src/lib/editor/FlowView.test.ts | 95 ++++++++++++++++++- ui/src/lib/flow.test.ts | 62 +++++++++++++ ui/src/lib/flow.ts | 41 +++++++++ ui/src/lib/pages/JobPage.svelte | 15 ++- ui/src/lib/pages/JobPage.test.ts | 41 +++++++++ ui/src/lib/runstate.test.ts | 62 ++++++++++++- ui/src/lib/runstate.ts | 30 ++++++ 8 files changed, 474 insertions(+), 15 deletions(-) diff --git a/ui/src/lib/editor/FlowView.svelte b/ui/src/lib/editor/FlowView.svelte index 0c7d18cb..2a87288c 100644 --- a/ui/src/lib/editor/FlowView.svelte +++ b/ui/src/lib/editor/FlowView.svelte @@ -6,6 +6,8 @@ onselect = undefined, activeStep = undefined, doneSteps = [], + activeMember = undefined, + doneMembers = [], }: { workflow: Record onselect?: (stepName: string) => void @@ -14,12 +16,24 @@ activeStep?: string /** Steps that run has already finished. */ doneSteps?: string[] + /** The for_each member (`group@entry`) the run is on right now - a + * finer grain than activeStep, which names the group. */ + activeMember?: string + /** The for_each members that run has already finished, engine names. */ + doneMembers?: string[] } = $props() const showsRun = $derived(activeStep !== undefined || doneSteps.length > 0) const stateOf = $derived((name: string) => name === activeStep ? 'active' : doneSteps.includes(name) ? 'done' : '', ) + const memberStateOf = $derived((full: string) => + full === activeMember + ? 'active' + : doneMembers.includes(full) + ? 'done' + : '', + ) const graph = $derived(dataFlowGraph(workflow)) @@ -66,6 +80,24 @@ const COL_W = 232 const ROW_H = 92 const PAD = 28 + // Member chips: a list-driven step's box grows to hold one inset chip + // per entry, beneath the header the ordinary box's three lines occupy + const CHIP_H = 15 + const CHIP_STEP = 19 + const CHIP_CHARS = 26 + const CHIP_TOP = BOX_H - 2 + // The gap ROW_H left between fixed-height boxes, kept for the + // height-aware stacking below + const ROW_GAP = ROW_H - BOX_H + + /** The box's height: the standard header, plus a chip row per entry for + * a list-driven step - or one empty slot when the step carries + * for_each but its list cannot be read from the definition. */ + function heightOf(node: FlowNode): number { + const count = node.members?.length ?? 0 + if (count) return CHIP_TOP + (count - 1) * CHIP_STEP + CHIP_H + 6 + return node.forEach ? BOX_H + 26 : BOX_H + } const layout = $derived.by(() => { const { nodes, edges } = graph @@ -97,25 +129,37 @@ columns[l] = [...(columns[l] ?? []), n] } + // Boxes in a column stack by their own height now - a for_each box + // holding many chips must not overlap the node beneath it const positions: Record = {} + const heightOfName: Record = {} + const bottoms: number[] = [] columns.forEach((col, c) => { - col.forEach((n, r) => { - positions[n.name] = { x: PAD + c * COL_W, y: PAD + r * ROW_H } + let y = PAD + col.forEach((n) => { + positions[n.name] = { x: PAD + c * COL_W, y } + heightOfName[n.name] = heightOf(n) + y += heightOf(n) + ROW_GAP }) + bottoms.push(y - ROW_GAP) }) - const maxRows = Math.max(1, ...columns.map((c) => c.length)) const width = PAD * 2 + BOX_W + Math.max(0, columns.length - 1) * COL_W - const height = PAD * 2 + BOX_H + (maxRows - 1) * ROW_H + const height = Math.max(...bottoms, PAD * 2 + BOX_H) + // One clip box per distinct member-box height; the standard box keeps + // the shared clipPath below + const tallHeights = [ + ...new Set(nodes.map((n) => heightOf(n)).filter((h) => h > BOX_H)), + ] const edgeLines = edges.map((e) => { const from = positions[e.from] const to = positions[e.to] if (!from || !to) return null const x1 = from.x + BOX_W - const y1 = from.y + BOX_H / 2 + const y1 = from.y + (heightOfName[e.from] ?? BOX_H) / 2 const x2 = to.x - const y2 = to.y + BOX_H / 2 + const y2 = to.y + (heightOfName[e.to] ?? BOX_H) / 2 // A gentle horizontal-first curve keeps lines readable when an // edge skips columns or two edges share a target row. const dx = Math.max(40, (x2 - x1) / 2) @@ -123,7 +167,7 @@ return { ...e, path, labelX: (x1 + x2) / 2, labelY: (y1 + y2) / 2 } }) - return { positions, width, height, edgeLines } + return { positions, width, height, tallHeights, edgeLines } }) function kindLabel(kind: string): string { @@ -166,7 +210,8 @@ previous_result references labeled with the argument they feed. A step with more than one incoming arrow multiplies its inputs together (CLAUDE.md's cartesian-product gotcha) - its border is - highlighted and the multiplier is noted.{#if showsRun} + highlighted and the multiplier is noted. A step carrying + for_each shows the entries it runs inset.{#if showsRun} A finished step is outlined in green, the one running now in amber colour.{/if}{#if onselect} Click a step to jump to it in the form view.{/if} @@ -194,6 +239,11 @@ + {#each layout.tallHeights as h (h)} + + + + {/each} {#each layout.edgeLines ?? [] as edge, i (i)} @@ -209,6 +259,7 @@ {@const pos = layout.positions[node.name]} {#if pos} {@const fanIn = graph.fanIn.get(node.name)} + {@const boxH = heightOf(node)} BOX_H + ? `url(#flow-nodebox-${boxH})` + : 'url(#flow-nodebox)'} + aria-label={`step ${node.name}, ${kindLabel(node.kind)}${stateOf(node.name) ? ', ' + stateOf(node.name) : ''}${node.isEntryPoint ? ', entry point' : ''}${fanIn ? ', fan-in: ' + fanIn.label : ''}${node.forEach ? (node.members?.length ? `, for_each with ${node.members.length} entries` : ', for_each') : ''}`} {...nodeAttributes(node.name)} > {#if overflowTitle(node)} {overflowTitle(node)} {/if} - + {fit( node.name, @@ -243,11 +296,45 @@ >entry {/if} + {#if node.members?.length} + + {#each node.members as key, i (i)} + {@const full = `${node.name}@${key}`} + {@const state = memberStateOf(full)} + + {full} + + {fit(key, CHIP_CHARS, 'head')} + + {/each} + {:else if node.forEach} + for_each + {/if} {#if fanIn} × {fanIn.label} @@ -354,4 +441,36 @@ fill: var(--warn); font-weight: 600; } + /* The entries a for_each step runs, inset beneath its header. Machine + state on their edges, as on the nodes: done green, running the + safelight amber. */ + .member .chip { + fill: var(--panel-2); + stroke: var(--line); + stroke-width: 1; + } + .member.done .chip { + stroke: var(--good); + stroke-width: 1.5; + } + .member.active .chip { + stroke: var(--live); + stroke-width: 1.5; + animation: dw-pulse 1.6s ease-in-out infinite; + } + @media (prefers-reduced-motion: reduce) { + .member.active .chip { + animation: none; + } + } + .chiplabel { + font-size: 9px; + fill: var(--ink); + font-family: var(--font-mono); + } + .membersunknown { + font-size: 9px; + fill: var(--muted); + font-family: var(--font-mono); + } diff --git a/ui/src/lib/editor/FlowView.test.ts b/ui/src/lib/editor/FlowView.test.ts index 7a5dc445..6ad0efad 100644 --- a/ui/src/lib/editor/FlowView.test.ts +++ b/ui/src/lib/editor/FlowView.test.ts @@ -1,5 +1,5 @@ import { render } from '@testing-library/svelte' -import { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import FlowView from './FlowView.svelte' const workflow = { @@ -102,3 +102,96 @@ it('shortens a long step name from the end, leaving room for the entry tag', () 'reference_to_video_audio_with_lipsync', ) }) + +describe('for_each members', () => { + const listWorkflow = { + variables: { shots: [{ name: 'open' }, { name: 'reveal' }] }, + steps: [ + { + name: 'shot', + for_each: 'variable:shots', + pipeline: { configuration: { component_type: 'Fake' } }, + }, + { name: 'episode', task: { command: 'mux' } }, + ], + } + + function memberFor(container: HTMLElement, full: string) { + return [...container.querySelectorAll('g.member')].find((m) => + m.getAttribute('aria-label')?.startsWith(`member ${full},`), + )! + } + + it("renders a list step's entries as inset chips in run order, and plain steps without any", () => { + const { container } = render(FlowView, { workflow: listWorkflow }) + const shot = nodeFor(container, 'shot') + expect(shot.getAttribute('aria-label')).toContain('for_each with 2 entries') + const chips = [...shot.querySelectorAll('g.member')] + expect(chips.map((c) => c.querySelector('text')?.textContent)).toEqual([ + 'open', + 'reveal', + ]) + // The whole engine name is on the chip, as hover text + expect( + chips[0].querySelector('title')?.textContent, + ).toBe('shot@open') + expect(nodeFor(container, 'episode').querySelectorAll('g.member')).toHaveLength(0) + expect( + nodeFor(container, 'episode').getAttribute('aria-label'), + ).not.toContain('for_each') + }) + + it('colors the chips a run reports done and running', () => { + const { container } = render(FlowView, { + workflow: listWorkflow, + doneMembers: ['shot@open'], + activeMember: 'shot@reveal', + }) + expect(memberFor(container, 'shot@open').classList.contains('done')).toBe( + true, + ) + expect( + memberFor(container, 'shot@reveal').classList.contains('active'), + ).toBe(true) + // and announces the state, as the group node does + expect(memberFor(container, 'shot@reveal').getAttribute('aria-label')).toBe( + 'member shot@reveal, active', + ) + }) + + it('leaves chips unstyled when no run state is supplied', () => { + const { container } = render(FlowView, { workflow: listWorkflow }) + for (const chip of container.querySelectorAll('g.member')) { + expect(chip.classList.contains('done')).toBe(false) + expect(chip.classList.contains('active')).toBe(false) + } + }) + + it('grows the box to hold its chips, and keeps a plain box for the rest', () => { + const { container } = render(FlowView, { workflow: listWorkflow }) + const height = (name: string) => + Number( + nodeFor(container, name) + .querySelector('rect.box')! + .getAttribute('height'), + ) + expect(height('shot')).toBeGreaterThan(height('episode')) + }) + + it('says for_each rather than nothing when the list cannot be read', () => { + const unknown = { + steps: [ + { + name: 'shot', + for_each: 'variable:missing', + task: { command: 'render' }, + }, + ], + } + const { container } = render(FlowView, { workflow: unknown }) + const shot = nodeFor(container, 'shot') + expect(shot.getAttribute('aria-label')).toContain('for_each') + expect(shot.querySelectorAll('g.member')).toHaveLength(0) + expect(shot.querySelector('.membersunknown')?.textContent).toBe('for_each') + }) +}) diff --git a/ui/src/lib/flow.test.ts b/ui/src/lib/flow.test.ts index 83fed24e..46868f61 100644 --- a/ui/src/lib/flow.test.ts +++ b/ui/src/lib/flow.test.ts @@ -279,3 +279,65 @@ describe('for_each references', () => { ]) }) }) + +describe('for_each members', () => { + it("lists a declared variable list's entry names as the step's members", () => { + // What a realized workflow carries: for_each still names the variable, + // and the run's actual list sits in variables (dw/realize.py) + const wf = { + variables: { shots: [{ name: 'open' }, { name: 'reveal' }] }, + steps: [ + { + name: 'shot', + for_each: 'variable:shots', + task: { command: 'render' }, + }, + ], + } + const node = dataFlowGraph(wf).nodes[0] + expect(node.forEach).toBe(true) + expect(node.members).toEqual(['open', 'reveal']) + }) + + it('keys unnamed entries by index, the way the engine names members', () => { + const wf = { + variables: { items: ['a', 'b'] }, + steps: [ + { name: 'run', for_each: 'variable:items', task: { command: 'x' } }, + ], + } + expect(dataFlowGraph(wf).nodes[0].members).toEqual(['0', '1']) + }) + + it('reads a literal for_each list written on the step itself', () => { + const wf = { + steps: [ + { + name: 'run', + for_each: [{ name: 'a' }, { name: 'b' }], + task: { command: 'x' }, + }, + ], + } + expect(dataFlowGraph(wf).nodes[0].members).toEqual(['a', 'b']) + }) + + it('marks a list-driven step whose list cannot be read, and leaves plain steps alone', () => { + const wf = { + variables: { other: 3 }, + steps: [ + { + name: 'run', + for_each: 'variable:missing', + task: { command: 'x' }, + }, + step('plain', {}), + ], + } + const graph = dataFlowGraph(wf) + expect(graph.nodes[0].forEach).toBe(true) + expect(graph.nodes[0].members).toBeNull() + expect(graph.nodes[1].forEach).toBe(false) + expect(graph.nodes[1].members).toBeNull() + }) +}) diff --git a/ui/src/lib/flow.ts b/ui/src/lib/flow.ts index c4709771..32120d24 100644 --- a/ui/src/lib/flow.ts +++ b/ui/src/lib/flow.ts @@ -109,6 +109,14 @@ export interface FlowNode { * JSON says so directly (currently just a literal * `num_images_per_prompt`). Null means "unknown, assume 1". */ producedCount: number | null + /** True when the step carries a `for_each` - list-driven, so a run + * expands it into one step per entry. */ + forEach: boolean + /** The step's entry keys when its `for_each` list is readable from the + * definition (a literal, or a declared variable holding one) - the + * names the run's members carry after the `@`. Null when there is no + * for_each or the list cannot be read statically. */ + members: string[] | null } export interface FlowEdge { @@ -149,6 +157,37 @@ function producedCount(step: Record): number | null { return typeof n === 'number' ? n : null } +const FOR_EACH_KEY = 'for_each' +const VARIABLE_PREFIX = 'variable:' + +/** A for_each step's entry keys, when the list can be read statically: a + * literal list written on the step, or a `variable:` naming a declared + * variable that holds one - which a realized workflow always does, since + * the run's actual list is folded into `variables` (dw/realize.py). The + * keys are what the engine appends to the step name: an entry's `name`, + * else its index (`_entry_keys`, dw/for_each.py). */ +function forEachMembers( + workflow: Record, + step: Record, +): string[] | null { + const value = step[FOR_EACH_KEY] + let entries: unknown[] | null = null + if (Array.isArray(value)) { + entries = value + } else if (typeof value === 'string' && value.startsWith(VARIABLE_PREFIX)) { + const declared = workflow.variables?.[value.slice(VARIABLE_PREFIX.length)] + if (Array.isArray(declared)) entries = declared + } + if (!entries?.length) return null + return entries.map((entry, index) => + entry !== null && + typeof entry === 'object' && + typeof (entry as Record).name === 'string' + ? (entry as Record).name + : String(index), + ) +} + /** The read-only data-flow view's graph: one node per step, one edge per * `previous_result:` reference (labeled with the attribute that * carries it), entry points flagged, and fan-in points - steps combining @@ -166,6 +205,8 @@ export function dataFlowGraph(workflow: Record): DataFlowGraph { detail, isEntryPoint: true, producedCount: producedCount(step), + forEach: FOR_EACH_KEY in step, + members: forEachMembers(workflow, step), } }) const edges: FlowEdge[] = [] diff --git a/ui/src/lib/pages/JobPage.svelte b/ui/src/lib/pages/JobPage.svelte index 3544746d..68e9e588 100644 --- a/ui/src/lib/pages/JobPage.svelte +++ b/ui/src/lib/pages/JobPage.svelte @@ -15,7 +15,12 @@ sectionBySubfolder, unsavedSteps, } from '../results' - import { finishedNodes, flowNodeName } from '../runstate' + import { + activeMember, + finishedMembers, + finishedNodes, + flowNodeName, + } from '../runstate' import { stepProgress } from '../progress' import FlowView from '../editor/FlowView.svelte' import CopyButton from '../CopyButton.svelte' @@ -271,6 +276,12 @@ unsavedSteps(job?.manifest, events as JobEvent[], definition), ) const running = $derived(job !== null && !TERMINAL.includes(job.status)) + // One grain finer than the group: which entries of a for_each step have + // finished and which is running, in the engine's own `group@entry` names + const finishedMemberSteps = $derived(finishedMembers(events as JobEvent[])) + const activeMemberStep = $derived( + running ? activeMember(events as JobEvent[]) : undefined, + ) // A cancel requested while loading a model or running a task step has no // checkpoint to catch it until that phase finishes - without this the UI // goes silent for however long that takes, and looks hung rather than @@ -434,6 +445,8 @@ workflow={definition} activeStep={running ? activeNode : undefined} doneSteps={finishedSteps} + activeMember={activeMemberStep} + doneMembers={finishedMemberSteps} /> {/if} diff --git a/ui/src/lib/pages/JobPage.test.ts b/ui/src/lib/pages/JobPage.test.ts index 78ebf910..810d64a9 100644 --- a/ui/src/lib/pages/JobPage.test.ts +++ b/ui/src/lib/pages/JobPage.test.ts @@ -210,6 +210,47 @@ it('lights the for_each step in the flow chart while one of its members runs', a expect(nodeFor(container, 'episode').classList.contains('active')).toBe(true) }) +it('marks the entries a for_each step runs as chips inside its box', async () => { + // The realized workflow keeps for_each and holds the run's actual list + // in its variables, so the flow view can name the members + ran.definition = { + variables: { shots: [{ name: 'open' }, { name: 'reveal' }] }, + steps: [ + { + name: 'shot', + for_each: 'variable:shots', + pipeline: { configuration: { component_type: 'Fake' } }, + }, + ], + } + detail.job = { ...job([]), status: 'running', finished_at: null } + const { container } = render(JobPage, { jobId: 'j1' }) + await waitFor(() => expect(stream.onEvent).not.toBeNull()) + stream.onEvent!({ + seq: 1, + event: 'workflow_start', + steps: ['shot@open', 'shot@reveal'], + }) + stream.onEvent!({ seq: 2, event: 'step_start', step: 'shot@open' }) + const chip = (key: string) => + [...nodeFor(container, 'shot').querySelectorAll('g.member')].find( + // the label is "member shot@open" plus ", done"/", active" when styled + (m) => m.getAttribute('aria-label')?.split(',')[0] === `member shot@${key}`, + )! + await waitFor(() => + expect(chip('open').classList.contains('active')).toBe(true), + ) + + // One entry down, one to go: the finished chip greens while the group + // box itself stays amber + stream.onEvent!({ seq: 3, event: 'step_end', step: 'shot@open', files: [] }) + await waitFor(() => + expect(chip('open').classList.contains('done')).toBe(true), + ) + expect(chip('reveal').classList.contains('done')).toBe(false) + expect(nodeFor(container, 'shot').classList.contains('active')).toBe(true) +}) + it('keeps the Progress list on the composed step while its child runs', async () => { ran.definition = { steps: [ diff --git a/ui/src/lib/runstate.test.ts b/ui/src/lib/runstate.test.ts index 43990901..9d7d61c6 100644 --- a/ui/src/lib/runstate.test.ts +++ b/ui/src/lib/runstate.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { finishedNodes, flowNodeName } from './runstate' +import { + activeMember, + finishedMembers, + finishedNodes, + flowNodeName, +} from './runstate' import type { JobEvent } from './types' const ended = (step: string, parent_step?: string): JobEvent => @@ -52,3 +57,58 @@ describe('finishedNodes', () => { expect(finishedNodes([], members)).toEqual([]) }) }) + +describe('finishedMembers', () => { + it('lists the for_each members that have ended, engine names and all', () => { + const events = [ended('base@open'), ended('film'), ended('base@reveal')] + expect(finishedMembers(events)).toEqual(['base@open', 'base@reveal']) + }) + + it("leaves out a sub-workflow's inner members - their chips are not this graph's", () => { + expect(finishedMembers([ended('shot@reveal', 'cut')])).toEqual([]) + }) + + it('has nothing for a run that has not ended anything', () => { + expect(finishedMembers([])).toEqual([]) + }) +}) + +describe('activeMember', () => { + it('is the member the run is on, engine name and all', () => { + const events: JobEvent[] = [ + { seq: 0, event: 'step_start', step: 'base@open' }, + { seq: 1, event: 'step_end', step: 'base@open' }, + { seq: 2, event: 'step_start', step: 'base@reveal' }, + ] + expect(activeMember(events)).toBe('base@reveal') + }) + + it('is nothing in the gap after a member ended, before the next one starts', () => { + const events: JobEvent[] = [ + { seq: 0, event: 'step_start', step: 'base@open' }, + { seq: 1, event: 'step_end', step: 'base@open' }, + ] + expect(activeMember(events)).toBeUndefined() + }) + + it('is nothing once the run has moved on to a plain step', () => { + const events: JobEvent[] = [ + { seq: 0, event: 'step_start', step: 'base@open' }, + { seq: 1, event: 'step_end', step: 'base@open' }, + { seq: 2, event: 'step_start', step: 'episode' }, + ] + expect(activeMember(events)).toBeUndefined() + }) + + it("ignores a sub-workflow's inner steps, member-named or not", () => { + const events: JobEvent[] = [ + { seq: 0, event: 'step_start', step: 'base@open', parent_step: 'cut' }, + { seq: 1, event: 'step_start', step: 'inner', parent_step: 'shot1' }, + ] + expect(activeMember(events)).toBeUndefined() + }) + + it('has nothing before the run has started anything', () => { + expect(activeMember([])).toBeUndefined() + }) +}) diff --git a/ui/src/lib/runstate.ts b/ui/src/lib/runstate.ts index 8bd0fc9c..aeaada41 100644 --- a/ui/src/lib/runstate.ts +++ b/ui/src/lib/runstate.ts @@ -59,3 +59,33 @@ export function finishedNodes( .filter(([, names]) => names.every((name) => ended.has(name))) .map(([node]) => node) } + +/** The for_each members a run has finished, in the engine's own + * `group@entry` spelling - the names the flow view's member chips carry. + * Only top-level ends count: a sub-workflow's inner members, whose + * `step_end` carries the composed step as `parent_step`, belong to no + * chip this graph draws. */ +export function finishedMembers(events: JobEvent[]): string[] { + return events + .filter((event) => event.event === 'step_end' && !event.parent_step) + .map((event) => event.step as string) + .filter((step) => step.includes(MEMBER_SEPARATOR)) +} + +/** The for_each member the run is on right now, or undefined when the + * last step it started is not a member - or is a member that has already + * ended, since between one entry finishing and the next starting the run + * is on neither, and a chip still amber there would lie about progress. A + * sub-workflow's inner steps do not count, member-named or not, for the + * same reason as above. */ +export function activeMember(events: JobEvent[]): string | undefined { + const ended = new Set(finishedMembers(events)) + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i] + if (event.event !== 'step_start' || event.parent_step) continue + const step = event.step as string + if (!step.includes(MEMBER_SEPARATOR) || ended.has(step)) return undefined + return step + } + return undefined +} From 5dbbd7fdc66b1c0e61343d1f9a82c320c065bc95 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 17:21:00 -0500 Subject: [PATCH 09/17] format --- ui/src/lib/editor/FlowView.svelte | 6 +----- ui/src/lib/editor/FlowView.test.ts | 6 +++--- ui/src/lib/pages/JobPage.test.ts | 3 ++- ui/src/lib/runstate.test.ts | 2 +- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/ui/src/lib/editor/FlowView.svelte b/ui/src/lib/editor/FlowView.svelte index 2a87288c..6f789d73 100644 --- a/ui/src/lib/editor/FlowView.svelte +++ b/ui/src/lib/editor/FlowView.svelte @@ -28,11 +28,7 @@ name === activeStep ? 'active' : doneSteps.includes(name) ? 'done' : '', ) const memberStateOf = $derived((full: string) => - full === activeMember - ? 'active' - : doneMembers.includes(full) - ? 'done' - : '', + full === activeMember ? 'active' : doneMembers.includes(full) ? 'done' : '', ) const graph = $derived(dataFlowGraph(workflow)) diff --git a/ui/src/lib/editor/FlowView.test.ts b/ui/src/lib/editor/FlowView.test.ts index 6ad0efad..7376c9ea 100644 --- a/ui/src/lib/editor/FlowView.test.ts +++ b/ui/src/lib/editor/FlowView.test.ts @@ -132,10 +132,10 @@ describe('for_each members', () => { 'reveal', ]) // The whole engine name is on the chip, as hover text + expect(chips[0].querySelector('title')?.textContent).toBe('shot@open') expect( - chips[0].querySelector('title')?.textContent, - ).toBe('shot@open') - expect(nodeFor(container, 'episode').querySelectorAll('g.member')).toHaveLength(0) + nodeFor(container, 'episode').querySelectorAll('g.member'), + ).toHaveLength(0) expect( nodeFor(container, 'episode').getAttribute('aria-label'), ).not.toContain('for_each') diff --git a/ui/src/lib/pages/JobPage.test.ts b/ui/src/lib/pages/JobPage.test.ts index 810d64a9..eb0d6545 100644 --- a/ui/src/lib/pages/JobPage.test.ts +++ b/ui/src/lib/pages/JobPage.test.ts @@ -235,7 +235,8 @@ it('marks the entries a for_each step runs as chips inside its box', async () => const chip = (key: string) => [...nodeFor(container, 'shot').querySelectorAll('g.member')].find( // the label is "member shot@open" plus ", done"/", active" when styled - (m) => m.getAttribute('aria-label')?.split(',')[0] === `member shot@${key}`, + (m) => + m.getAttribute('aria-label')?.split(',')[0] === `member shot@${key}`, )! await waitFor(() => expect(chip('open').classList.contains('active')).toBe(true), diff --git a/ui/src/lib/runstate.test.ts b/ui/src/lib/runstate.test.ts index 9d7d61c6..3b60d0de 100644 --- a/ui/src/lib/runstate.test.ts +++ b/ui/src/lib/runstate.test.ts @@ -103,7 +103,7 @@ describe('activeMember', () => { it("ignores a sub-workflow's inner steps, member-named or not", () => { const events: JobEvent[] = [ { seq: 0, event: 'step_start', step: 'base@open', parent_step: 'cut' }, - { seq: 1, event: 'step_start', step: 'inner', parent_step: 'shot1' }, + { seq: 1, event: 'step_start', step: 'inner', parent_step: 'shot1' }, ] expect(activeMember(events)).toBeUndefined() }) From e1e2cd9b090f0b9dfbd64f10bcab3a83b2518c27 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 17:34:35 -0500 Subject: [PATCH 10/17] feat(api): update workflow handling to separate definition from transport metadata --- ui/src/lib/api.test.ts | 23 +++++++++++++++++++++++ ui/src/lib/api.ts | 7 +++++-- ui/src/lib/editor/StepEditor.svelte | 14 +++++++------- ui/src/lib/pages/EditorPage.svelte | 6 +++--- ui/src/lib/pages/EditorPage.test.ts | 25 +++++++++++++++++++++++++ ui/src/lib/pages/WorkflowPage.svelte | 8 ++++---- ui/src/lib/types.ts | 9 +++++++-- 7 files changed, 74 insertions(+), 18 deletions(-) diff --git a/ui/src/lib/api.test.ts b/ui/src/lib/api.test.ts index 0b815120..e712e07d 100644 --- a/ui/src/lib/api.test.ts +++ b/ui/src/lib/api.test.ts @@ -118,6 +118,29 @@ describe('name encoding', () => { }) }) +describe('workflow definition fetch', () => { + it('keeps the definition exactly as served, with origin and writable beside it', async () => { + const body = { id: 'z-image', steps: [] } + stubFetch({ + ok: true, + body, + headers: { + 'X-Workflow-Origin': 'workspace', + 'X-Workflow-Writable': 'true', + }, + }) + const result = await api.getWorkflow('models/z-image') + expect(result.definition).toEqual(body) + // The transport metadata rides beside the definition, not inside it - + // a workflow opened by name must validate and save as the file it + // came from, and the schema refuses unknown root keys + expect(result.definition).not.toHaveProperty('origin') + expect(result.definition).not.toHaveProperty('writable') + expect(result.origin).toBe('workspace') + expect(result.writable).toBe(true) + }) +}) + describe('gallery listing and thumbnails', () => { it('fetches the whole listing in one request', async () => { const calls = stubFetch({ ok: true, body: {} }) diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 34c7bf15..f4543feb 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -273,11 +273,14 @@ export const api = { > }>('/api/workflows'), /** The workflow plus where it came from, read off the response headers - * rather than a separate `listWorkflows` lookup. */ + * rather than a separate `listWorkflows` lookup. Beside the definition, + * the way `getPrompt` keeps a prompt's: the object the caller holds is + * what validate, save and run send back, and the schema refuses unknown + * root keys, so the transport metadata must not ride inside it. */ getWorkflow: (name: string) => fetchJson(`/api/workflows/${encodePath(name)}`).then( ({ body, response }): WorkflowWithOrigin => ({ - ...body, + definition: body, origin: response.headers.get('X-Workflow-Origin') ?? '', writable: response.headers.get('X-Workflow-Writable') !== 'false', }), diff --git a/ui/src/lib/editor/StepEditor.svelte b/ui/src/lib/editor/StepEditor.svelte index 1d3ebf2c..07affa5d 100644 --- a/ui/src/lib/editor/StepEditor.svelte +++ b/ui/src/lib/editor/StepEditor.svelte @@ -138,13 +138,13 @@ const timer = setTimeout(() => { api .getWorkflow(resolved.slice(0, -'.json'.length)) - .then((definition) => { - workflowVariables = Object.entries(definition.variables ?? {}).map( - ([name, value]) => ({ - name, - hint: typeof value === 'string' ? value : JSON.stringify(value), - }), - ) + .then((fetched) => { + workflowVariables = Object.entries( + fetched.definition.variables ?? {}, + ).map(([name, value]) => ({ + name, + hint: typeof value === 'string' ? value : JSON.stringify(value), + })) }) .catch(() => {}) }, 300) diff --git a/ui/src/lib/pages/EditorPage.svelte b/ui/src/lib/pages/EditorPage.svelte index a2cdc596..77feeeaf 100644 --- a/ui/src/lib/pages/EditorPage.svelte +++ b/ui/src/lib/pages/EditorPage.svelte @@ -211,9 +211,9 @@ fileOpen = false api .getWorkflow(name) - .then((definition) => { - workflow = definition as WorkflowDefinition - baseline = JSON.stringify(definition) + .then((fetched) => { + workflow = fetched.definition as WorkflowDefinition + baseline = JSON.stringify(fetched.definition) stepModes = storageGet(modesKey, {}) }) .catch((e) => notify.error(e.message)) diff --git a/ui/src/lib/pages/EditorPage.test.ts b/ui/src/lib/pages/EditorPage.test.ts index 1962a8b7..7b689ce2 100644 --- a/ui/src/lib/pages/EditorPage.test.ts +++ b/ui/src/lib/pages/EditorPage.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen, waitFor } from '@testing-library/svelte' import EditorPage from './EditorPage.svelte' +import { api } from '../api' // EditorPage talks to the server on mount (pipeline/class/task catalogs, // the workflow listing, the prompt library) purely to feed forms the flow @@ -19,6 +20,11 @@ vi.mock('../api', () => ({ listWorkflows: vi .fn() .mockResolvedValue({ workflows: [], workflow_dir: 'workflows' }), + getWorkflow: vi.fn().mockResolvedValue({ + definition: { id: 'ZImage', steps: [{ name: 'generate', pipeline: {} }] }, + origin: 'workspace', + writable: true, + }), listPrompts: vi.fn().mockResolvedValue({ prompts: [], details: {} }), validate: vi.fn().mockResolvedValue({ valid: true, @@ -78,6 +84,25 @@ describe('EditorPage view switch', () => { }) describe('EditorPage validation plan', () => { + it('validates the definition it opened, without the transport metadata', async () => { + render(EditorPage, { name: 'models/z-image' }) + await waitFor(() => + expect(screen.getByLabelText('workflow id')).toBeTruthy(), + ) + await screen.getByRole('button', { name: /validate/i }).click() + await waitFor(() => + expect(vi.mocked(api.validate).mock.calls).toHaveLength(1), + ) + const payload = vi.mocked(api.validate).mock.calls[0][0] as Record< + string, + unknown + > + // origin and writable are how the fetch says where a file came from - + // they are not the file's, and the schema refuses unknown root keys + expect(payload).not.toHaveProperty('origin') + expect(payload).not.toHaveProperty('writable') + }) + it('shows what a run will do under a valid verdict', async () => { render(EditorPage, { name: '' }) await waitFor(() => diff --git a/ui/src/lib/pages/WorkflowPage.svelte b/ui/src/lib/pages/WorkflowPage.svelte index cd6f92e8..3e70b6f0 100644 --- a/ui/src/lib/pages/WorkflowPage.svelte +++ b/ui/src/lib/pages/WorkflowPage.svelte @@ -41,10 +41,10 @@ loadPromptLibrary() api .getWorkflow(name) - .then((definition) => { - workflow = definition - origin = definition.origin - writable = definition.writable + .then((fetched) => { + workflow = fetched.definition + origin = fetched.origin + writable = fetched.writable }) .catch((e) => (error = e.message)) }) diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index a11be174..f962141e 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -152,8 +152,13 @@ export interface WorkflowDefinition { } /** A workflow plus where it came from - `getWorkflow` reads these off the - * `X-Workflow-Origin` / `X-Workflow-Writable` response headers. */ -export interface WorkflowWithOrigin extends WorkflowDefinition { + * `X-Workflow-Origin` / `X-Workflow-Writable` response headers. Beside the + * definition rather than spread into it, as for a prompt: a workflow is + * validated and saved back exactly as it was read, and a stray root field + * fails the schema - the engine refuses unknown root keys rather than + * ignoring them. */ +export interface WorkflowWithOrigin { + definition: WorkflowDefinition /** 'workspace' | 'examples' | 'builtin'. */ origin: string writable: boolean From 40e47b5d4be8d7a76c9b0d9b675952b33e79a5a3 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 17:56:16 -0500 Subject: [PATCH 11/17] feat(ui): job page can show the workflow it ran as JSON The job page already fetched the workflow a job ran for the flow graph - that response is the realized copy when the run wrote one, and carried a `realized` flag the UI dropped. The Workflow section now names which copy it is (realized / as submitted) and offers a show/hide JSON toggle that renders the definition in a readonly editor, mounted only while open. Tests stub JsonEditor so the suite never boots Monaco in jsdom. Co-Authored-By: Claude --- ui/src/lib/pages/JobPage.svelte | 56 +++++++++++++++++++++++++- ui/src/lib/pages/JobPage.test.ts | 39 ++++++++++++++++-- ui/src/lib/pages/JsonEditorStub.svelte | 8 ++++ 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 ui/src/lib/pages/JsonEditorStub.svelte diff --git a/ui/src/lib/pages/JobPage.svelte b/ui/src/lib/pages/JobPage.svelte index 68e9e588..8c4f9ebf 100644 --- a/ui/src/lib/pages/JobPage.svelte +++ b/ui/src/lib/pages/JobPage.svelte @@ -23,6 +23,7 @@ } from '../runstate' import { stepProgress } from '../progress' import FlowView from '../editor/FlowView.svelte' + import JsonEditor from '../editor/JsonEditor.svelte' import CopyButton from '../CopyButton.svelte' import DownloadLink from '../DownloadLink.svelte' import { notify } from '../toast' @@ -39,6 +40,13 @@ // a workflow that pins its seed to a literal or names none at all - // neither can be handed a different one, so the button stays away. let seedVariable = $state(null) + // Whether `definition` is the realized copy the run itself wrote (every + // mutable input pinned) or the definition as submitted - the run predates + // run tracking, or its run directory is gone. Named beside the JSON view. + let realized = $state(false) + // The JSON view of that definition - off until asked, since the flow graph + // already answers "what did this run do" for most readers + let showJson = $state(false) let events = $state([]) let error = $state('') // arrival clocks for pipeline_step events, for the ETA estimate @@ -51,6 +59,8 @@ events = [] definition = null seedVariable = null + realized = false + showJson = false // Under the flat output layout two runs write the same file names, so // a map keyed by name would show the last job's recipe for this one fileMeta = {} @@ -64,6 +74,7 @@ if (stopped) return definition = result.definition seedVariable = result.seed_variable + realized = result.realized }) .catch(() => { /* no definition on file - the graph just does not appear */ @@ -440,7 +451,27 @@ {#if definition}
-

Workflow

+
+

Workflow

+ {realized ? 'realized' : 'as submitted'} + + +
+ {#if showJson} +
+ +
+ {/if}
{/if} @@ -618,9 +658,21 @@ .flowsection { margin-bottom: 1rem; } - .flowsection h2 { + .flowhead { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 1rem; margin-bottom: var(--space-2); } + .flowhead h2 { + margin: 0; + } + /* Which copy the definition is - what the engine resolves, so mono */ + .jsonmark { + font-family: var(--font-mono); + font-size: var(--t-xs); + } .warnings { color: var(--warn); } diff --git a/ui/src/lib/pages/JobPage.test.ts b/ui/src/lib/pages/JobPage.test.ts index eb0d6545..fe399042 100644 --- a/ui/src/lib/pages/JobPage.test.ts +++ b/ui/src/lib/pages/JobPage.test.ts @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/svelte' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte' import { afterEach, expect, it, vi } from 'vitest' import JobPage from './JobPage.svelte' import { api } from '../api' @@ -13,9 +13,17 @@ const stream = vi.hoisted(() => ({ const metadata = vi.hoisted(() => ({ byFile: {} as Record>, })) -// The definition the job ran, for the flow view and the unsaved reasons +// The definition the job ran, for the flow view and the unsaved reasons, +// and whether it is the realized copy or the definition as submitted const ran = vi.hoisted(() => ({ definition: null as Record | null, + realized: true, +})) + +// Monaco cannot boot in jsdom; the page's JSON view is tested through this +// stub, which renders the value it was handed as plain text +vi.mock('../editor/JsonEditor.svelte', async () => ({ + default: (await import('./JsonEditorStub.svelte')).default, })) vi.mock('../api', () => ({ @@ -23,7 +31,11 @@ vi.mock('../api', () => ({ api: { getJob: vi.fn(() => Promise.resolve(detail.job)), getJobWorkflow: vi.fn(() => - Promise.resolve({ definition: ran.definition, seed_variable: null }), + Promise.resolve({ + definition: ran.definition, + realized: ran.realized, + seed_variable: null, + }), ), galleryMetadata: vi.fn((name: string) => Promise.resolve({ @@ -67,6 +79,7 @@ afterEach(() => { stream.onEvent = null metadata.byFile = {} ran.definition = null + ran.realized = true vi.mocked(api.galleryMetadata).mockClear() }) @@ -351,3 +364,23 @@ it('says nothing about an acknowledgement a run did not carry', async () => { await waitFor(() => expect(screen.getByText('j1')).toBeTruthy()) expect(screen.queryByText(/acknowledged/)).toBeNull() }) + +it('offers the workflow JSON behind a toggle, labelled as the realized copy', async () => { + ran.definition = { steps: [{ name: 'base', pipeline: {} }] } + render(JobPage, { jobId: 'j1' }) + await waitFor(() => expect(screen.getByText('realized')).toBeTruthy()) + // The JSON is for the curious, not the default view + expect(screen.queryByTestId('json-editor')).toBeNull() + await fireEvent.click(screen.getByRole('button', { name: 'show JSON' })) + const shown = await screen.findByTestId('json-editor') + // What the viewer is handed is the definition the page fetched + expect(shown.textContent).toContain('"name": "base"') + expect(screen.getByRole('button', { name: 'hide JSON' })).toBeTruthy() +}) + +it('labels a definition with no realized copy on file as submitted', async () => { + ran.definition = { steps: [{ name: 'base', pipeline: {} }] } + ran.realized = false + render(JobPage, { jobId: 'j1' }) + await waitFor(() => expect(screen.getByText('as submitted')).toBeTruthy()) +}) diff --git a/ui/src/lib/pages/JsonEditorStub.svelte b/ui/src/lib/pages/JsonEditorStub.svelte new file mode 100644 index 00000000..b023d69d --- /dev/null +++ b/ui/src/lib/pages/JsonEditorStub.svelte @@ -0,0 +1,8 @@ + + +
{value}
\ No newline at end of file From 8b6b7835853c814d664957a1ece30b9ba2e8cdcb Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 18:04:21 -0500 Subject: [PATCH 12/17] fix(ui): JsonEditor soft-wraps long lines Monaco's default is no wrap, so a long prompt string in a JSON view forced a horizontal scroll. wordWrap: 'on' wraps at the container width for every view that uses the editor - the job page's realized JSON, the workflow page's definition, and the editable split views alike. Co-Authored-By: Claude --- ui/src/lib/editor/JsonEditor.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/lib/editor/JsonEditor.svelte b/ui/src/lib/editor/JsonEditor.svelte index 6ac0609c..9ed833c5 100644 --- a/ui/src/lib/editor/JsonEditor.svelte +++ b/ui/src/lib/editor/JsonEditor.svelte @@ -60,6 +60,7 @@ guides: { indentation: false }, automaticLayout: true, scrollBeyondLastLine: false, + wordWrap: 'on', fontSize: 13, tabSize: 2, fixedOverflowWidgets: true, From 7083d9f9c027845a4ce16783066cf0ea45f4ae29 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 18:08:38 -0500 Subject: [PATCH 13/17] format --- ui/src/lib/pages/JobPage.test.ts | 8 +++++++- ui/src/lib/pages/JsonEditorStub.svelte | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ui/src/lib/pages/JobPage.test.ts b/ui/src/lib/pages/JobPage.test.ts index fe399042..46b4f5cd 100644 --- a/ui/src/lib/pages/JobPage.test.ts +++ b/ui/src/lib/pages/JobPage.test.ts @@ -1,4 +1,10 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/svelte' import { afterEach, expect, it, vi } from 'vitest' import JobPage from './JobPage.svelte' import { api } from '../api' diff --git a/ui/src/lib/pages/JsonEditorStub.svelte b/ui/src/lib/pages/JsonEditorStub.svelte index b023d69d..1d594c32 100644 --- a/ui/src/lib/pages/JsonEditorStub.svelte +++ b/ui/src/lib/pages/JsonEditorStub.svelte @@ -5,4 +5,4 @@ let { value }: { value: string } = $props() -
{value}
\ No newline at end of file +
{value}
From 5e8208277d2d38bb681ff295b7e5b36669f6e148 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 19:38:52 -0500 Subject: [PATCH 14/17] fix: #128 #129 #133 #134 #136 #137 #138 - templates, warnings, cleanup, validation, two security orderings - #128/#129: templates/dissolve-between-shots declares match_levels / match_levels_dbfs and passes them to dissolve_videos, and its description carries #126's score-length paragraph plus the dissolve arithmetic (n*f - (n-1)*d) that assemble-and-score's total_frames does not need. - #133: a safety-checker blanking now reaches the run as a warning event (emit_warning), not just the server log - a consumer of a 'succeeded' job could not tell a black frame from a render. templates/text-to-image loads with 'safety_checker': null, since the reference template must not have a silent content filter in the path. - #134: delete_output sweeps the run directory once its last media file is gone, sidecars included, and accepts a '/' name to clear a run that failed before writing any media. - #136: validation refuses a reference set the pipeline would refuse (dw/reference_limits.py) - per-kind and total counts, and H3's audio-may- not-stand-alone rule - reading every limit off the diffusers block that enforces it rather than restating it. - #137: Pipeline.check_trusted() runs the trust gates over the definition before the 'loading' phase event is emitted, so job events distinguish 'refused before load' from 'loaded, then refused' again. - #138: upload_asset confines file_path to the server's own directories over a mounted dw.serve endpoint, ahead of the existence and extension checks so it cannot be used as a path-existence oracle. Co-Authored-By: Claude Opus 5 --- docs/MCP.md | 9 +- docs/SECURITY.md | 23 +- docs/SERVER.md | 7 +- dw/pipeline_processors/pipeline.py | 57 ++++- dw/reference_limits.py | 210 ++++++++++++++++++ dw/server/app.py | 110 ++++++++- dw/workflow.py | 9 + dw_mcp/assets.py | 72 ++++++ dw_mcp/media.py | 6 +- dw_mcp/server.py | 12 +- tests/test_mcp_assets.py | 87 ++++++++ tests/test_pipeline_components.py | 22 ++ tests/test_reference_limits.py | 138 ++++++++++++ tests/test_server.py | 85 +++++++ tests/test_workflow_trust.py | 108 +++++++++ .../templates/dissolve-between-shots.json | 8 +- workflows/templates/text-to-image.json | 5 +- 17 files changed, 942 insertions(+), 26 deletions(-) create mode 100644 dw/reference_limits.py create mode 100644 tests/test_reference_limits.py diff --git a/docs/MCP.md b/docs/MCP.md index 6ba3e3d4..6fbf1d87 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -249,7 +249,7 @@ The session starts in `default` and stays there unless it is told otherwise. | Tool | Arguments | Purpose | | --- | --- | --- | -| `validate_workflow(workflow=None, name=None, workspace=None, arguments=None)` | exactly one of `workflow` (inline definition) or `name` (a stored workflow, as `list_workflows` reports it), optional `workspace`, optional `arguments` | Check a workflow against the schema and against real pipeline signatures. Free and instant. Validating by name uses the workflow file's own directory as the base directory, so it sees what a run would. Returns every schema violation in `errors`, each with the JSON path it sits at, so a draft is fixed in one pass, and a `previous_result:` that names no earlier step is one of them. `warnings` covers what still runs but is probably wrong - a signature mismatch, and, for a list-driven variable, an entry key no step reads, at the entry's path. `workspace` names the workspace for this one call without switching the session to it - use it to pin a job whose `output:` or `asset:` references live in a workspace other than the session's. Pass the same `arguments` you will pass to `run_workflow` and they are checked too - an undeclared or renamed variable name, a value that will not coerce to the declared type, and an `asset:`, `prompt:` or `output:` reference that names nothing this workspace can reach, each reported at `arguments.`. `checked_arguments` lists what was covered, so a `valid: true` about the stored defaults cannot be mistaken for one about your values. `run_workflow` makes the same check and refuses a bad argument rather than queuing a job that fails on its first step. A valid answer carries `plan` - the fingerprint, step count, list lengths, `downloads_required` and `estimate` (with `basis`) for the arguments given; quote from it | +| `validate_workflow(workflow=None, name=None, workspace=None, arguments=None)` | exactly one of `workflow` (inline definition) or `name` (a stored workflow, as `list_workflows` reports it), optional `workspace`, optional `arguments` | Check a workflow against the schema and against real pipeline signatures. Free and instant. Validating by name uses the workflow file's own directory as the base directory, so it sees what a run would. Returns every schema violation in `errors`, each with the JSON path it sits at, so a draft is fixed in one pass, and a `previous_result:` that names no earlier step is one of them. `warnings` covers what still runs but is probably wrong - a signature mismatch, and, for a list-driven variable, an entry key no step reads, at the entry's path. `workspace` names the workspace for this one call without switching the session to it - use it to pin a job whose `output:` or `asset:` references live in a workspace other than the session's. Pass the same `arguments` you will pass to `run_workflow` and they are checked too - an undeclared or renamed variable name, a value that will not coerce to the declared type, and an `asset:`, `prompt:` or `output:` reference that names nothing this workspace can reach, each reported at `arguments.`. `checked_arguments` lists what was covered, so a `valid: true` about the stored defaults cannot be mistaken for one about your values. A reference set the model would refuse - too many images, videos or audio clips, or, for MiniMax-H3, audio as the only reference - is an error here too, rather than a failure minutes into a run you acknowledged. `run_workflow` makes the same check and refuses a bad argument rather than queuing a job that fails on its first step. A valid answer carries `plan` - the fingerprint, step count, list lengths, `downloads_required` and `estimate` (with `basis`) for the arguments given; quote from it | | `list_workspaces()` | — | The server's workspaces and which one this session is using. Each has its own workflows, assets and outputs; the prompt library is shared by all of them | | `use_workspace(name)` | `name` | Work in that workspace for the rest of the session - every later call reads and writes there. This is how to keep your work out of another agent's namespace rather than sharing the default one. Checked against the server, so a typo fails here rather than scoping every later call to nothing | | `create_workspace(name, use=False)` | `name`, `use` | Create a workspace. Pass use=true to switch this session to it as well; otherwise the session stays where it was and the result says so | @@ -459,8 +459,13 @@ default) for any server an MCP client can reach. runs on. Over `dw.serve --mcp` that is the GPU box, so a file sitting on the client's laptop is not reachable that way - put it on the server, or give the workflow a URL (the arguments that take a path take a URL too). + On a `--mcp` endpoint `file_path` is also *confined* to the directories the + server works in (its workspace, workflows, assets, outputs and prompts), and + the refusal comes before the file is looked for, so the tool cannot be used + to probe which paths exist on the box (#138). `download_output` has the same asymmetry in the other direction: on a - `--mcp` endpoint it writes on the GPU box, not the client's machine. + `--mcp` endpoint it writes on the GPU box, not the client's machine, and is + confined to the workspace there. - **Prompts are not per-workspace.** Switching workspaces changes which workflows, assets and outputs the session sees; the prompt library is one library shared by all of them, because `prompt:` is shared by reference. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0dcbc911..9c109781 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -177,14 +177,21 @@ validated there, exactly as it would be for a browser request from the web UI. A remote `dw.serve` is allowed only with a token — see [MCP Server](MCP.md#security) and [REMOTE.md](REMOTE.md). -The one exception is `download_output`, which writes a local file for the -MCP client rather than only reading through the API. It may write anywhere -the client's own filesystem permissions allow (the machine running the MCP -server — the GPU box when served by `dw.serve --mcp`) — a full path, a -directory, or the current working directory by default, `~` expanded — since -it acts for the local user the same way a shell redirect would; a `..` path segment in -`destination` is refused regardless, and an existing file at the resolved -path is left alone unless the caller passes `overwrite=True`. +The two exceptions are `download_output`, which writes a local file for the +MCP client, and `upload_asset(file_path=...)`, which reads one — neither goes +through the API for that half of its work. Both turn on whose machine "local" +is. Over a **stdio `dw-mcp`** it is the user's own, so both act for the local +user the way a shell redirect would: `download_output` writes anywhere the +process may (a full path, a directory, or the working directory by default, +`~` expanded) and `upload_asset` reads anything it may. Over **`dw.serve +--mcp`** it is the operator's box, which the caller never chose, so both are +confined there: `download_output`'s `destination` to the workspace (#113) and +`upload_asset`'s `file_path` to the directories the server works in — its +workspace, workflows, assets, outputs and prompts (#138). `upload_asset`'s +refusal is ordered ahead of the existence and extension checks so it cannot +be used as a path-existence oracle. A `..` path segment in `destination` is +refused regardless, and an existing file at the resolved path is left alone +unless the caller passes `overwrite=True`. ## Exception Hierarchy diff --git a/docs/SERVER.md b/docs/SERVER.md index eb90c2a0..ec95c501 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -277,7 +277,12 @@ The editor's forms come from these; they are just as usable from scripts: makes the same check and answers 400 rather than queuing a job that would fail on its first step; `checked_arguments` on a valid answer names what was covered, since without arguments the verdict is about the stored - defaults only. + defaults only. A reference set a pipeline would refuse is an error here + too - too many images, videos or audio clips for the family, or, for + MiniMax-H3, audio as the only reference - because the pipeline enforces + those only once its checkpoint is loaded, minutes into an acknowledged + run (`dw/reference_limits.py`, which reads each limit off the diffusers + block that enforces it rather than restating it). A valid answer also carries `plan`, what the run will execute for those arguments: `fingerprint` (`sha256:…` over the realized, expanded diff --git a/dw/pipeline_processors/pipeline.py b/dw/pipeline_processors/pipeline.py index aa03f430..a495fe11 100644 --- a/dw/pipeline_processors/pipeline.py +++ b/dw/pipeline_processors/pipeline.py @@ -26,7 +26,7 @@ # dw.prompt_weighting (transformers) and diffusers.hooks (peft, bitsandbytes) are # imported where they are used - at module scope they add seconds to every startup -from ..events import WorkflowCancelled, emit_phase, get_context +from ..events import WorkflowCancelled, emit_phase, emit_warning, get_context from huggingface_hub.errors import HfHubHTTPError logger = logging.getLogger("dw") @@ -219,6 +219,44 @@ def populate_from_pretrained_arguments(self, device, shared_components): return from_pretrained_arguments + def check_trusted(self): + """Refuse an untrusted definition before anything says a load began. + + The gates themselves live inside load() and load_component(), which is + where they have to be - that is the last point before the bytes are + fetched. But `load()` is entered under a 'loading' phase event, so a + run refused by them emitted the same marker as one that loaded a model + and then failed, and job events could no longer tell the two apart + (#137). This runs the same checks over the definition first, so the + caller emits 'loading' only once a load can actually begin. It is a + pre-flight, not the boundary: the in-load checks stay. + + Raises: + UntrustedWorkflowError: If untrusted and the definition reaches + for remote code + """ + require_trusted_pre_load_modules(self.configuration.get("pre_load_modules", [])) + # Walk the whole definition rather than the component names this class + # knows: a gate that only covers what it remembers to enumerate stops + # covering a block added later + self._check_trusted_block(self.pipeline_definition, "pipeline") + + @staticmethod + def _check_trusted_block(block, what): + if not isinstance(block, dict): + return + require_trusted_from_pretrained_arguments( + block.get("from_pretrained_arguments"), what + ) + for key, value in block.items(): + if key == "from_pretrained_arguments": + continue + if isinstance(value, dict): + Pipeline._check_trusted_block(value, key) + elif isinstance(value, list): + for entry in value: + Pipeline._check_trusted_block(entry, key) + def load(self, shared_components): """ Load and configure the pipeline with all components. @@ -1121,8 +1159,9 @@ def warn_if_safety_checker_blanked(output): Stable Diffusion 1.5's checker false-positives readily, and it returns a solid black image rather than an error. Run to run that reads as the seed - having no effect - the same result every time - so the reason belongs in - the log where the identical images do. + having no effect - the same result every time - so the reason belongs + where the identical images do: the run's warnings, which is the only + place a consumer of a `succeeded` job would ever see it. Args: output: The pipeline output, which may carry nsfw_content_detected @@ -1133,11 +1172,19 @@ def warn_if_safety_checker_blanked(output): blanked = sum(1 for flag in flags if flag) if blanked: - logger.warning( + # emit_warning rather than logger.warning: a blanked image is a + # succeeded job whose file is solid black, and a consumer over the + # API or MCP sees the job's warnings list and nothing else - the log + # line never reaches the one party that cannot tell the picture apart + # from a rendered one (#133) + emit_warning( f"The safety checker blanked {blanked} of {len(flags)} generated " "images - they are solid black, and no seed will change that. " "Pass 'safety_checker': null in from_pretrained_arguments to " - "load the pipeline without it." + "load the pipeline without it.", + kind="safety_checker_blanked", + blanked=blanked, + images=len(flags), ) diff --git a/dw/reference_limits.py b/dw/reference_limits.py new file mode 100644 index 00000000..580cf2a7 --- /dev/null +++ b/dw/reference_limits.py @@ -0,0 +1,210 @@ +"""How many references of each kind a pipeline's request may carry. + +A pipeline that conditions on reference media - MiniMax-H3's `ref2va` is the +one in the catalog - bounds what it accepts: so many images, so many videos, +so many audio clips, so many in total, and for H3 an audio reference may never +be the only one. Those bounds are enforced by the pipeline itself, which means +they are enforced *after* the checkpoint is loaded: a caller who ran the free +`validate_workflow`, was quoted eight minutes and acknowledged the cost found +out minutes in, from a failed job, what a millisecond of arithmetic could have +told them (#136). + +The numbers are not written here. They live on the diffusers block that +enforces them, as its constructor's defaults, and this module reads them off +that block - so a diffusers release that raises a limit raises it here too, +and the engine holds no model knowledge but the name of the class that +declares the limits (see REFERENCE_LIMIT_BLOCKS). A family diffusers has no +such block for is simply not checked: this pass only ever refuses a request +the pipeline itself would refuse. +""" + +import importlib +import inspect +import logging + +from .for_each import MEMBER_SEPARATOR, render_path + +logger = logging.getLogger("dw") + +# The module a family's reference classes live in -> the block whose +# __init__ defaults declare that family's limits. A pointer, not a number: +# what a limit *is* stays diffusers', which is the only place it can stay +# correct across a release +REFERENCE_LIMIT_BLOCKS = { + "diffusers.modular_pipelines.minimax_h3": ( + "diffusers.modular_pipelines.minimax_h3.before_encoder", + "MiniMaxH3Ref2VASetupStep", + ), +} + +# Families where an audio reference may not stand alone - H3 conditions a +# soundtrack on a picture, so audio by itself has nothing to speak over +AUDIO_NEEDS_A_PICTURE = frozenset(REFERENCE_LIMIT_BLOCKS) + +REFERENCE_TYPE_KEY = "reference_type" + +# Values substitution resolves before this pass runs; one still spelled out +# is another pass's complaint, not this one's +_UNRESOLVED_PREFIXES = ("variable:", "item:", "previous_result:", "gather:") + + +def _family(module_name): + """The REFERENCE_LIMIT_BLOCKS key a class's module belongs to, or None.""" + for family in REFERENCE_LIMIT_BLOCKS: + if module_name == family or module_name.startswith(family + "."): + return family + return None + + +def _limits(family): + """The (model name, per-kind limits, total) a family declares. + + Read from the block's constructor signature rather than from an instance: + constructing one is cheap but not free, and a default is exactly what the + signature holds. The model name is the block's own, so even the family's + name in the message is diffusers'. + """ + module_path, class_name = REFERENCE_LIMIT_BLOCKS[family] + try: + block = getattr(importlib.import_module(module_path), class_name) + parameters = inspect.signature(block.__init__).parameters + except Exception: + # A diffusers that renamed or dropped the block - checking nothing is + # the right failure here, since the pipeline still enforces its own + logger.debug(f"No reference limits available from {family}", exc_info=True) + return None, None, None + + per_kind = {} + total = None + for name, parameter in parameters.items(): + if parameter.default is inspect.Parameter.empty: + continue + if name == "max_references": + total = parameter.default + elif name.startswith("max_") and name.endswith("s"): + per_kind[name[len("max_") : -1]] = parameter.default + model_name = getattr(block, "model_name", family.rsplit(".", 1)[-1]) + return model_name, (per_kind or None), total + + +def _reference_class(value): + """The class a reference entry's '*_type' names, or None. + + Anything that does not resolve is left alone: realize_args reports a type + it cannot load, with a better message than this pass could give. + """ + if not isinstance(value, dict): + return None + name = value.get(REFERENCE_TYPE_KEY) + if not isinstance(name, str) or name.startswith(_UNRESOLVED_PREFIXES): + return None + module_name, _, class_name = name.rpartition(".") + if not module_name: + return None + try: + return getattr(importlib.import_module(module_name), class_name) + except Exception: + return None + + +def _kinds(entries): + """(family, [kind, ...]) for a list of reference entries, or None. + + A family is the one whose limits this pass knows how to read; an entry + whose class carries no 'kind', or a list mixing two families, is not a + reference set this pass understands. + """ + if not isinstance(entries, list) or not entries: + return None + found = None + kinds = [] + for entry in entries: + reference = _reference_class(entry) + kind = getattr(reference, "kind", None) + if not isinstance(kind, str): + return None + family = _family(getattr(reference, "__module__", "")) + if family is None: + return None + if found is None: + found = family + elif found != family: + return None + kinds.append(kind) + return (found, kinds) if found else None + + +def _errors_for(kinds, family): + """Every limit a set of reference kinds breaks, as messages.""" + model_name, per_kind, total = _limits(family) + if per_kind is None and total is None: + return [] + + messages = [] + for kind, limit in sorted((per_kind or {}).items()): + count = kinds.count(kind) + if count > limit: + messages.append( + f"{model_name} accepts at most {limit} " + f"{kind} reference{'s' if limit != 1 else ''}, got {count}." + ) + if total is not None and len(kinds) > total: + messages.append( + f"{model_name} accepts at most {total} references in total, " + f"got {len(kinds)}." + ) + if family in AUDIO_NEEDS_A_PICTURE and set(kinds) == {"audio"}: + messages.append( + "An audio reference has to be paired with at least one image or " + "video reference and cannot be used on its own." + ) + return messages + + +def reference_limit_errors(workflow_definition, source_indices=None): + """Every reference list a pipeline would refuse, as [{path, message}]. + + The definition handed here has already been substituted and expanded, so a + `for_each` member's own references are checked as they will run; + `source_indices` maps each expanded step back to the step the author + wrote, and the member is named in the message - the same convention + subfolder_errors uses. + """ + steps = workflow_definition.get("steps") + if not isinstance(steps, list): + return [] + + errors = [] + for index, step in enumerate(steps): + if not isinstance(step, dict): + continue + pipeline = step.get("pipeline") + arguments = pipeline.get("arguments") if isinstance(pipeline, dict) else None + if not isinstance(arguments, dict): + continue + source = ( + source_indices[index] + if source_indices is not None and index < len(source_indices) + else index + ) + name = step.get("name") + where = ( + f" in member '{name}'" + if isinstance(name, str) and MEMBER_SEPARATOR in name + else "" + ) + for key, value in arguments.items(): + found = _kinds(value) + if found is None: + continue + family, kinds = found + for message in _errors_for(kinds, family): + errors.append( + { + "path": render_path( + ("steps", source, "pipeline", "arguments", key) + ), + "message": f"{message.rstrip('.')}{where}.", + } + ) + return errors diff --git a/dw/server/app.py b/dw/server/app.py index 48a2b263..04ed07e5 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -72,7 +72,14 @@ from ..media_info import probe_media from ..hub_cache import scan_models, delete_model, DownloadManager from ..plan import build_plan, unseeded_cache_warnings -from ..runs import is_output_reference, resolve_output_reference, split_run_path +from ..runs import ( + MANIFEST_FILE_NAME, + REALIZED_FILE_NAME, + is_output_reference, + is_run_id, + resolve_output_reference, + split_run_path, +) from ..workspace import ( ASSETS_SUBDIR, DEFAULT_WORKSPACE_NAME, @@ -2502,13 +2509,110 @@ def archive_outputs( background=BackgroundTask(os.unlink, handle.name), ) + # What a run directory holds besides its media: the engine writes them to + # describe the run, and the gallery - which lists media - never shows them + RUN_SIDECARS = (MANIFEST_FILE_NAME, REALIZED_FILE_NAME) + + def _prune_empty_run_directory(name, root): + """Drop the run directory a just-deleted output belonged to, once no + media is left in it. + + A run writes `manifest.json` and `workflow.json` beside its files, and + nothing in the gallery addresses either one. Deleting every output of a + run therefore used to leave the directory behind forever: a consumer + that removed everything it made still could not put a workspace back + the way it found it, and nothing it could call would even show the + residue (#134). Tying the sidecars' lifetime to the outputs they + describe is what makes "delete what you made" true. + + Only the sidecars may remain - any other leftover file means something + is still there to describe, and the directory stays. + + Returns: + The run id swept, or None if nothing was + """ + identity, run_id, _ = split_run_path(name) + if not run_id: + # The flat layout writes no run directory and no sidecars + return None + relative = f"{identity}/{run_id}" if identity else run_id + try: + run_dir = validate_path( + os.path.join(root, relative), root, allow_create=False + ) + except SecurityError: + return None + if not os.path.isdir(run_dir): + return None + + for directory, _subdirectories, files in os.walk(run_dir): + for file_name in files: + if directory == run_dir and file_name in RUN_SIDECARS: + continue + return None + + shutil.rmtree(run_dir, ignore_errors=True) + # And the identity folders above it, while they are empty - a swept + # workspace should not keep one directory per workflow it once ran + parent = os.path.dirname(run_dir) + while os.path.normpath(parent) != os.path.normpath(root): + try: + os.rmdir(parent) + except OSError: + break + parent = os.path.dirname(parent) + logger.info(f"Swept empty run directory {relative}") + return run_id + + def _run_directory(name, root): + """The run directory `/` names, or None. + + A run that failed before it wrote anything still has a directory and a + manifest, and no gallery name addresses it - so the name of the + directory itself is the only handle there can be (#134). + """ + parts = [part for part in (name or "").split("/") if part] + if not parts or not is_run_id(parts[-1]): + return None + try: + path = validate_path( + os.path.join(root, "/".join(parts)), root, allow_create=False + ) + except SecurityError: + return None + return path if os.path.isdir(path) else None + @app.delete("/api/gallery/{name:path}") def delete_output(name: str, ws: Workspace = Depends(selected_workspace)): - """Remove one file from the output directory.""" + """Remove one file from the output directory. + + When that was the last media file of its run, the run directory goes + with it, sidecars included. `name` may also be a run directory + (`/`), which removes the whole run - the only handle + on a run that failed before it wrote any media (#134). + """ + run_dir = _run_directory(name, ws.outputs) + if run_dir is not None: + shutil.rmtree(run_dir, ignore_errors=True) + parent = os.path.dirname(run_dir) + while os.path.normpath(parent) != os.path.normpath(ws.outputs): + try: + os.rmdir(parent) + except OSError: + break + parent = os.path.dirname(parent) + logger.info(f"Deleted run directory {name}") + return { + "name": name, + "deleted": True, + "run_swept": os.path.basename(run_dir), + } + path = _output_file(name, ws.outputs) os.remove(path) logger.info(f"Deleted output file {name}") - return {"name": name, "deleted": True} + swept = _prune_empty_run_directory(name, ws.outputs) + return {"name": name, "deleted": True, "run_swept": swept} # ---------------------------------------------------------------- uploads diff --git a/dw/workflow.py b/dw/workflow.py index c81f5eb0..2ee70b46 100644 --- a/dw/workflow.py +++ b/dw/workflow.py @@ -28,6 +28,7 @@ previous_result_reference_errors, ) from .locations import location_errors +from .reference_limits import reference_limit_errors from .subfolders import step_subfolder, subfolder_errors from .step import Step from .step_cache import ( @@ -593,6 +594,9 @@ def validation_errors(self, arguments=None, composing=None): # A location policy refuses before a model load is spent on the # run rather than after it (dw/locations.py) + location_errors(expanded, source_indices, base_dir) + # A reference set the pipeline would refuse costs a checkpoint + # load to find out about otherwise (dw/reference_limits.py, #136) + + reference_limit_errors(expanded, source_indices) + self.sub_workflow_errors(expanded, source_indices, composing) ) @@ -1397,6 +1401,11 @@ def create_step_action( output_dir=self.step_output_dir(step_definition), file_prefix=self.step_file_prefix(step_name), ) + # Before the marker, not after it: a definition refused by the + # trust gate must not have announced a load it never began, or a + # consumer reading job events cannot tell 'refused before load' + # from 'loaded, then refused' (#137) + pipeline.check_trusted() # Loading is the longest silence in a run: weights, quantization, # adapters and placement all happen inside this call emit_phase("loading", detail=pipeline.name) diff --git a/dw_mcp/assets.py b/dw_mcp/assets.py index 5468f450..b2e254c0 100644 --- a/dw_mcp/assets.py +++ b/dw_mcp/assets.py @@ -41,6 +41,69 @@ ) +def _remote_roots(client): + """The directories a remote read is confined to, or None when local. + + The mirror of media.py's `_remote_root` (#113), for the other direction. + Only the mounted MCP surface is remote: there `upload_asset` runs inside + dw.serve, so `file_path` names a file on the operator's box rather than + on the calling agent's machine, and an unconfined read is an arbitrary + file read plus a path-existence oracle (#138). A stdio `dw-mcp` returns + None and keeps reading whatever the user can, because there "local file" + is genuinely their own. + """ + if not getattr(client, "mounted", False): + return None + + directories = (client.get_json("/api/server").get("directories")) or {} + roots = [] + for key in ("workspace", "workflows", "assets", "outputs", "prompts"): + value = directories.get(key) + if not value: + continue + resolved = os.path.normpath( + os.path.realpath(os.path.abspath(os.path.expanduser(str(value)))) + ) + if resolved not in roots: + roots.append(resolved) + if not roots: + raise DwApiError( + "This server cannot say which directories it works in, so it " + "will not read a file off its own disk for you. Upload the " + "bytes through the web UI's file picker, or keep a generated " + "file with keep_output." + ) + return roots + + +def _confine_source(path, roots, named): + """Refuse a source outside `roots`, before anything looks at the file. + + Ordered ahead of the existence and extension checks on purpose: a + refusal that depends on whether the file is there turns the tool into a + path-existence oracle for the whole box, which is the condition this + closes as much as the read itself (#138). Containment is on the resolved + real path, so a symlink cannot carry the read out. + """ + probe = path + while not os.path.exists(probe) and os.path.dirname(probe) != probe: + probe = os.path.dirname(probe) + resolved = os.path.normpath( + os.path.join(os.path.realpath(probe), os.path.relpath(path, probe)) + ) + if any(resolved == root or resolved.startswith(root + os.sep) for root in roots): + return + raise DwApiError( + f"Refusing to read {named} - this MCP endpoint is served by " + f"dw.serve, so the file would be read off the server, where a " + f"source is confined to the directories it works in " + f"({', '.join(roots)}). A file that is already there is reachable " + f"as an 'asset:' reference; to put a new one there, upload it " + f"through the web UI's file picker, or promote a generated file " + f"with keep_output." + ) + + def list_assets(client): """The input media on the server, each with the 'asset:' reference a workflow argument carries. @@ -100,6 +163,12 @@ def upload_asset(client, file_path, asset_name=None, shared=False): necessarily the machine dw.serve runs on - that is the point of the tool. + Over a `dw.serve --mcp` endpoint that machine *is* the server, so there + `file_path` is confined to the directories the server works in, and the + refusal comes before the file is looked for so it cannot be used to + probe which paths exist (#138). A stdio `dw-mcp` is unconfined, because + there the file really is the caller's own. + `asset_name` is the name it is stored under - 'cast/priya-voice.wav' rather than the random one an upload gets by default. A recurring cast referenced as 'asset:uploads/084eaecc....wav' in every workflow cannot @@ -113,6 +182,9 @@ def upload_asset(client, file_path, asset_name=None, shared=False): invisible from the workspace episode four was made in. """ path = os.path.abspath(os.path.expanduser(str(file_path))) + roots = _remote_roots(client) + if roots is not None: + _confine_source(path, roots, file_path) if not os.path.isfile(path): raise DwApiError(f"No such file: {file_path}") diff --git a/dw_mcp/media.py b/dw_mcp/media.py index e0387581..51b27906 100644 --- a/dw_mcp/media.py +++ b/dw_mcp/media.py @@ -137,7 +137,11 @@ def is_text(content_type): def delete_output(client, name, workspace=None): """Remove one file from the output directory. The gallery is the output - directory read back, so this is where a delete belongs.""" + directory read back, so this is where a delete belongs. + + The run directory goes too once its last media file is gone, sidecars + included, and a `/` name removes a whole run - what a + failed run, which has a manifest and nothing else, needs (#134).""" return client.delete_json(api_path("api", "gallery", name), workspace=workspace) diff --git a/dw_mcp/server.py b/dw_mcp/server.py index 536fb193..aaca5fe4 100644 --- a/dw_mcp/server.py +++ b/dw_mcp/server.py @@ -498,7 +498,13 @@ def delete_output(name: str, workspace: str | None = None) -> dict: """Permanently remove one generated file from the output directory. Not recoverable: rerunning the job that made it is the only way back, and any "output:" reference pointing at it stops resolving. - Prefer `keep_output` first if it is worth keeping. + Prefer `keep_output` first if it is worth keeping. When it was the + last media file of its run, the run directory goes with it - + `manifest.json` and `workflow.json` included - so deleting what you + made leaves the workspace as you found it. `name` may also be a run + directory ("/", the first two parts of a gallery + name), which removes the whole run: the only way to clear a run that + failed before it wrote any media. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` @@ -576,7 +582,9 @@ def upload_asset( the workflows that carry them. Pass `shared=true` to put it in the library every workspace shares rather than this session's own - where a recurring cast belongs, since a workspace's own assets are - invisible from the next workspace.""" + invisible from the next workspace. When this MCP surface is served + by dw.serve itself, "this machine" is the engine's own box, so + `file_path` is confined to the directories it works in.""" return assets.upload_asset( client, file_path, asset_name=asset_name, shared=shared ) diff --git a/tests/test_mcp_assets.py b/tests/test_mcp_assets.py index 7c9eedd6..9e8e4334 100644 --- a/tests/test_mcp_assets.py +++ b/tests/test_mcp_assets.py @@ -223,3 +223,90 @@ def handler(request): with pytest.raises(DwApiError, match="read-only"): delete_asset(client_over(handler), "iris.png") + + +class TestUploadContainmentOverAMountedEndpoint: + """Served by dw.serve, 'local file' means the operator's box - so an + unconfined file_path is an arbitrary read of the server's filesystem plus + a path-existence oracle for it (#138), the mirror of download_output's + write direction (#113). A stdio dw-mcp keeps reading the user's own disk.""" + + def _mounted_client(self, workspace, tmp_path): + def handler(request): + if request.url.path == "/api/server": + return httpx.Response( + 200, + json={ + "directories": { + "workspace": str(workspace), + "workflows": str(workspace / "workflows"), + "assets": str(workspace / "assets"), + "outputs": str(workspace / "outputs"), + "prompts": None, + } + }, + ) + return httpx.Response( + 201, + json={ + "path": "asset:uploads/deadbeef.png", + "url": "/inputs/uploads/deadbeef.png", + }, + ) + + client = client_over(handler) + client.mounted = True + return client + + def test_a_file_outside_the_roots_is_refused(self, tmp_path): + workspace = tmp_path / "workspace" + (workspace / "assets").mkdir(parents=True) + outside = tmp_path / "elsewhere" / "secret.png" + outside.parent.mkdir() + outside.write_bytes(b"png-bytes") + + client = self._mounted_client(workspace, tmp_path) + with pytest.raises(DwApiError, match="Refusing to read"): + upload_asset(client, str(outside)) + + def test_the_refusal_does_not_say_whether_the_file_exists(self, tmp_path): + """The containment check comes before the existence and extension + checks, so the tool cannot be used to probe the box for paths.""" + workspace = tmp_path / "workspace" + (workspace / "assets").mkdir(parents=True) + present = tmp_path / "elsewhere" / "there.png" + present.parent.mkdir() + present.write_bytes(b"png-bytes") + client = self._mounted_client(workspace, tmp_path) + + with pytest.raises(DwApiError) as there: + upload_asset(client, str(present)) + with pytest.raises(DwApiError) as not_there: + upload_asset(client, str(tmp_path / "elsewhere" / "missing.png")) + assert str(there.value).replace("there.png", "X") == str( + not_there.value + ).replace("missing.png", "X") + + # and a non-media extension outside the roots reads the same way, so + # the allowlist is not an oracle either + with pytest.raises(DwApiError, match="Refusing to read"): + upload_asset(client, "/etc/hostname") + + def test_a_file_inside_the_roots_still_uploads(self, tmp_path): + workspace = tmp_path / "workspace" + (workspace / "assets").mkdir(parents=True) + source = workspace / "assets" / "iris.png" + source.write_bytes(b"png-bytes") + + client = self._mounted_client(workspace, tmp_path) + result = upload_asset(client, str(source)) + assert result["reference"] == "asset:uploads/deadbeef.png" + + def test_a_stdio_client_is_unconfined(self, tmp_path): + """There 'local' is genuinely the caller's own machine.""" + source = tmp_path / "elsewhere" / "iris.png" + source.parent.mkdir() + source.write_bytes(b"png-bytes") + client, _seen = recording() + + assert upload_asset(client, str(source))["uploaded"] == "iris.png" diff --git a/tests/test_pipeline_components.py b/tests/test_pipeline_components.py index ba0a2ab1..d8d50d43 100644 --- a/tests/test_pipeline_components.py +++ b/tests/test_pipeline_components.py @@ -484,6 +484,28 @@ def test_silent_for_a_pipeline_without_a_safety_checker(self, caplog): assert caplog.text == "" + def test_it_reaches_the_run_as_a_warning_event(self): + """The job's status is 'succeeded' and its file is solid black, so a + consumer over the API or MCP - which sees the warnings list and + nothing else - is the one party that cannot tell the difference + (#133). The log alone never got there.""" + from dw.events import RunContext, activate_context, deactivate_context + + events = [] + token = activate_context(RunContext(on_event=events.append)) + try: + output = MagicMock() + output.nsfw_content_detected = [True] + warn_if_safety_checker_blanked(output) + finally: + deactivate_context(token) + + warnings = [e for e in events if e["event"] == "warning"] + assert len(warnings) == 1 + assert warnings[0]["kind"] == "safety_checker_blanked" + assert warnings[0]["blanked"] == 1 + assert "solid black" in warnings[0]["message"] + class TestAudiosSampleRate: """Audio-only pipelines put the waveform on `.audios` and the rate on a diff --git a/tests/test_reference_limits.py b/tests/test_reference_limits.py new file mode 100644 index 00000000..30d8bbf3 --- /dev/null +++ b/tests/test_reference_limits.py @@ -0,0 +1,138 @@ +"""Reference limits at validation time, not after a checkpoint load. + +MiniMax-H3's ref2va block refuses a reference set that breaks its limits - +but it does so once the model is up, so a caller who ran the free +validate_workflow, was quoted eight minutes and acknowledged it found out +minutes in, from a failed job (#136). The numbers here are never asserted +against literals of ours: they are read off the diffusers block that +enforces them, which is the only place they stay correct. +""" + +import json + +import pytest + +from dw.reference_limits import ( + REFERENCE_LIMIT_BLOCKS, + _limits, + reference_limit_errors, +) +from dw.workflow import Workflow + +H3 = "diffusers.modular_pipelines.minimax_h3" +IMAGE = f"{H3}.MiniMaxH3ImageReference" +VIDEO = f"{H3}.MiniMaxH3VideoReference" +AUDIO = f"{H3}.MiniMaxH3AudioReference" + + +def reference(reference_type, name="x"): + return {"reference_type": reference_type, "from_file": f"{name}.bin"} + + +def workflow_with(references): + return { + "id": "refs", + "steps": [ + { + "name": "shot", + "pipeline": { + "configuration": {"component_type": "ModularPipeline"}, + "from_pretrained_arguments": { + "model_name": "MiniMaxAI/MiniMax-H3", + "workflow": "ref2va", + }, + "arguments": {"prompt": "x", "references": references}, + }, + "result": {"content_type": "video/mp4"}, + } + ], + } + + +def messages(references): + return [e["message"] for e in reference_limit_errors(workflow_with(references))] + + +class TestLimitsComeFromDiffusers: + def test_the_block_declares_them(self): + model_name, per_kind, total = _limits(H3) + assert model_name + assert set(per_kind) == {"image", "video", "audio"} + assert total >= max(per_kind.values()) + + def test_an_unknown_family_is_not_checked(self): + assert H3 in REFERENCE_LIMIT_BLOCKS + assert messages([{"reference_type": "json.JSONDecoder"}]) == [] + + +class TestH3ReferenceSets: + def test_audio_cannot_be_the_only_reference(self): + found = messages([reference(AUDIO)]) + assert len(found) == 1 + assert "cannot be used on its own" in found[0] + + def test_audio_paired_with_a_picture_is_fine(self): + assert messages([reference(IMAGE), reference(AUDIO)]) == [] + + def test_too_many_of_one_kind_is_refused(self): + _model, per_kind, _total = _limits(H3) + over = per_kind["audio"] + 1 + found = messages( + [reference(IMAGE)] + [reference(AUDIO, f"a{i}") for i in range(over)] + ) + assert len(found) == 1 + assert f"at most {per_kind['audio']} audio references, got {over}" in found[0] + + def test_the_total_is_refused_too(self): + _model, per_kind, total = _limits(H3) + found = messages( + [reference(IMAGE, f"i{i}") for i in range(per_kind["image"])] + + [reference(VIDEO, f"v{i}") for i in range(per_kind["video"])] + + [reference(AUDIO, f"a{i}") for i in range(per_kind["audio"])] + ) + assert len(found) == 1 + assert f"at most {total} references in total" in found[0] + + def test_a_set_at_every_limit_passes(self): + _model, per_kind, total = _limits(H3) + references = [reference(IMAGE, f"i{i}") for i in range(per_kind["image"])] + references += [ + reference(VIDEO, f"v{i}") + for i in range(min(per_kind["video"], total - len(references))) + ] + assert messages(references) == [] + + def test_an_unresolved_reference_is_left_to_another_pass(self): + assert messages([{"reference_type": "variable:kind"}]) == [] + assert messages("variable:references") == [] + + +class TestThroughTheTemplate: + """dialogue-short's `shots` entries carry the references, so the error has + to name the member and point at the step the author wrote.""" + + def _template(self): + path = "workflows/templates/minimax/dialogue-short.json" + with open(path) as handle: + return Workflow(json.load(handle), "outputs", path) + + def test_the_template_itself_validates(self): + assert self._template().validation_errors() == [] + + def test_an_audio_only_shot_is_refused_before_the_run(self): + shots = [ + { + "name": "audio_only", + "num_frames": 124, + "prompt": "x", + "references": [reference(AUDIO)], + } + ] + errors = self._template().validation_errors({"shots": shots}) + assert len(errors) == 1 + assert errors[0]["path"].endswith("pipeline.arguments.references") + assert "shot@audio_only" in errors[0]["message"] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_server.py b/tests/test_server.py index 3f3bc917..db3456bb 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4289,3 +4289,88 @@ def test_gallery_metadata_finds_an_asset_an_examples_tree_brought(tmp_path): with TestClient(app, base_url="http://localhost") as client: body = client.get("/api/gallery/asset:example-bed.wav/metadata").json() assert body["media"]["duration_seconds"] == pytest.approx(1.5, abs=0.02) + + +def test_deleting_the_last_output_of_a_run_sweeps_its_run_directory(server, tmp_path): + """A run's manifest.json/workflow.json have no gallery name of their own, + so deleting every output of a run used to leave the directory behind for + good - a consumer that removed everything it made still could not put the + workspace back the way it found it (#134).""" + from PIL import Image + + with server(success_script) as client: + outputs = tmp_path / "outputs" + run = outputs / "t2i" / "20260913-120000-aabbccdd" + (run / "final").mkdir(parents=True) + Image.new("RGB", (2, 2)).save(run / "final" / "still-0.png") + Image.new("RGB", (2, 2)).save(run / "final" / "still-1.png") + (run / "manifest.json").write_text("{}") + (run / "workflow.json").write_text("{}") + + first = client.delete( + "/api/gallery/t2i/20260913-120000-aabbccdd/final/still-0.png" + ).json() + # one file left, so the run still describes something + assert first["run_swept"] is None + assert (run / "manifest.json").exists() + + last = client.delete( + "/api/gallery/t2i/20260913-120000-aabbccdd/final/still-1.png" + ).json() + assert last["run_swept"] == "20260913-120000-aabbccdd" + assert not run.exists() + # and the workflow folder above it, now that it holds no runs + assert not (outputs / "t2i").exists() + assert outputs.exists() + + +def test_a_run_with_other_files_left_is_not_swept(server, tmp_path): + """Only the sidecars may remain: anything else is still something the + manifest describes, so the directory stays.""" + from PIL import Image + + with server(success_script) as client: + outputs = tmp_path / "outputs" + run = outputs / "t2i" / "20260913-120000-aabbccdd" + run.mkdir(parents=True) + Image.new("RGB", (2, 2)).save(run / "still-0.png") + (run / "manifest.json").write_text("{}") + (run / "notes.txt").write_text("kept") + + body = client.delete( + "/api/gallery/t2i/20260913-120000-aabbccdd/still-0.png" + ).json() + assert body["run_swept"] is None + assert (run / "notes.txt").exists() + + +def test_deleting_a_run_directory_clears_a_run_that_wrote_no_media(server, tmp_path): + """A run that failed before writing anything has a manifest and no gallery + name at all - the directory's own name is the only handle (#134).""" + with server(success_script) as client: + outputs = tmp_path / "outputs" + run = outputs / "t2i" / "20260913-120000-deadbeef" + run.mkdir(parents=True) + (run / "manifest.json").write_text("{}") + + body = client.delete("/api/gallery/t2i/20260913-120000-deadbeef").json() + assert body["deleted"] is True + assert body["run_swept"] == "20260913-120000-deadbeef" + assert not run.exists() + + # a directory that is not a run is still a 404, not a recursive delete + (outputs / "keepme").mkdir() + assert client.delete("/api/gallery/keepme").status_code == 404 + assert (outputs / "keepme").exists() + + +def test_deleting_a_run_directory_stays_inside_the_output_root(server, tmp_path): + """The run-directory form takes the same containment as every other + gallery path: it cannot name a directory outside the workspace.""" + outside = tmp_path / "20260913-120000-cafebabe" + outside.mkdir() + + with server(success_script) as client: + response = client.delete("/api/gallery/..%2F20260913-120000-cafebabe") + assert response.status_code == 404 + assert outside.exists() diff --git a/tests/test_workflow_trust.py b/tests/test_workflow_trust.py index 85eacee2..3285decd 100644 --- a/tests/test_workflow_trust.py +++ b/tests/test_workflow_trust.py @@ -285,3 +285,111 @@ def test_load_component_refuses_before_touching_the_hub(self, monkeypatch): "cpu", ) component_type.from_pretrained.assert_not_called() + + +class TestTrustPreflightRunsBeforeTheLoadingMarker: + """The gates themselves are inside load()/load_component(), which is where + the boundary belongs - but load() is entered under a 'loading' phase + event, so a refused run emitted the same marker as one that loaded a model + and then failed, and job events could no longer tell 'refused before load' + from 'loaded, then refused' (#137).""" + + def _pipeline(self, definition): + return Pipeline(definition, 0, "cpu") + + def test_remote_code_refused_by_the_preflight(self, monkeypatch): + _untrust(monkeypatch) + pipeline = self._pipeline( + { + "configuration": {}, + "from_pretrained_arguments": { + "model_name": "a/b", + "trust_remote_code": True, + }, + "arguments": {}, + } + ) + with pytest.raises(UntrustedWorkflowError, match="trust_remote_code"): + pipeline.check_trusted() + + def test_a_component_block_is_covered_too(self, monkeypatch): + _untrust(monkeypatch) + pipeline = self._pipeline( + { + "configuration": {}, + "from_pretrained_arguments": {"model_name": "a/b"}, + "transformer": { + "configuration": {}, + "from_pretrained_arguments": { + "model_name": "c/d", + "custom_pipeline": "someone/repo", + }, + }, + "arguments": {}, + } + ) + with pytest.raises(UntrustedWorkflowError, match="custom_pipeline"): + pipeline.check_trusted() + + def test_pre_load_modules_are_covered_too(self, monkeypatch): + _untrust(monkeypatch) + pipeline = self._pipeline( + { + "configuration": {"pre_load_modules": ["json"]}, + "from_pretrained_arguments": {"model_name": "a/b"}, + "arguments": {}, + } + ) + with pytest.raises(UntrustedWorkflowError, match="pre_load_modules"): + pipeline.check_trusted() + + def test_an_ordinary_definition_passes(self, monkeypatch): + _untrust(monkeypatch) + pipeline = self._pipeline( + { + "configuration": {"component_type": "StableDiffusionPipeline"}, + "from_pretrained_arguments": {"model_name": "a/b"}, + "arguments": {"prompt": "an apple"}, + } + ) + pipeline.check_trusted() + + def test_a_refused_run_emits_no_loading_phase(self, monkeypatch, tmp_path): + """End to end through Workflow.run: the events a consumer reads carry + no 'loading' marker for a run the gate refused.""" + from dw.events import RunContext, activate_context, deactivate_context + + _untrust(monkeypatch) + events = [] + token = activate_context(RunContext(on_event=events.append)) + try: + workflow = Workflow( + { + "id": "untrusted_remote_code", + "steps": [ + { + "name": "main", + "pipeline": { + "configuration": { + "component_type": "StableDiffusionPipeline" + }, + "from_pretrained_arguments": { + "model_name": "a/b", + "trust_remote_code": True, + }, + "arguments": {"prompt": "an apple"}, + }, + "result": {"content_type": "image/jpeg"}, + } + ], + }, + str(tmp_path / "outputs"), + "", + ) + with pytest.raises(UntrustedWorkflowError): + workflow.run({}) + finally: + deactivate_context(token) + + phases = [e for e in events if e.get("event") == "phase"] + assert not any(p.get("phase") == "loading" for p in phases), phases diff --git a/workflows/templates/dissolve-between-shots.json b/workflows/templates/dissolve-between-shots.json index 82f6ea80..493588e7 100644 --- a/workflows/templates/dissolve-between-shots.json +++ b/workflows/templates/dissolve-between-shots.json @@ -1,6 +1,6 @@ { "id": "dissolve-between-shots", - "description": "assemble-and-score.json with the cuts softened into cross-dissolves. dissolve_videos overlaps each pair by 'dissolve_frames' rather than butting them together, which suits shots that share a composition - registered on the same centre at the same size, an overlap reads as one thing becoming another rather than as a fade between two pictures. Shots whose framing disagrees will read as a plain cross-fade, so use hard cuts there. As in assemble-and-score the shots are a list and go in untouched - a shot that drifts wants stabilize_video run on it first, as its own step, not on every shot by default. The audio bed is mixed the same way as in assemble-and-score.", + "description": "assemble-and-score.json with the cuts softened into cross-dissolves. dissolve_videos overlaps each pair by 'dissolve_frames' rather than butting them together, which suits shots that share a composition - registered on the same centre at the same size, an overlap reads as one thing becoming another rather than as a fade between two pictures. Shots whose framing disagrees will read as a plain cross-fade, so use hard cuts there. As in assemble-and-score the shots are a list and go in untouched - a shot that drifts wants stabilize_video run on it first, as its own step, not on every shot by default. The audio bed is mixed the same way as in assemble-and-score. Shots generated independently drift in loudness and no overlap can hide a level jump, because it is either side of the seam rather than at it; 'match_levels' ('rms' for perceived level, 'peak' for the loudest sample) evens the shots out before they are joined, and left null, as it is by default, a wide spread is warned about in the log rather than passing in silence. 'total_frames' is the length of the joined cut, which a dissolve shortens: every seam eats one 'dissolve_frames' overlap, so n shots of f frames joined with d-frame dissolves run n*f - (n-1)*d frames, not n*f. 'score' is a separate asset with its own length: it is sliced to 'total_frames' from 'score_start_frame', and a score that does not reach that far is padded to it with digital silence, which leaves the rest of the film unscored under the shots' own sound (the run says so as a 'slice_past_end' warning, but the film itself sounds plausible). A score must therefore be at least as long as the cut; to stretch a short bed to reach, make a longer one with the 'loop_audio' task first ('target_frames' and 'fps') and pass that as 'score'.", "cost": [ {"device": "cuda", "name": "RTX 3090", "vram_gb": 24, "minutes": 0.2} ], @@ -11,6 +11,8 @@ "asset:shot_3.mp4" ], "dissolve_frames": 12, + "match_levels": null, + "match_levels_dbfs": null, "score": "asset:score.wav", "sample_rate": 44100, "fps": 24, @@ -27,7 +29,9 @@ "arguments": { "videos": "variable:shots", "dissolve_frames": "variable:dissolve_frames", - "fps": "variable:fps" + "fps": "variable:fps", + "match_levels": "variable:match_levels", + "match_levels_dbfs": "variable:match_levels_dbfs" } } }, diff --git a/workflows/templates/text-to-image.json b/workflows/templates/text-to-image.json index caa1d173..feb2d67f 100644 --- a/workflows/templates/text-to-image.json +++ b/workflows/templates/text-to-image.json @@ -4,7 +4,7 @@ "num_images_per_prompt": 1 }, "id": "text-to-image", - "description": "Baseline text-to-image with Stable Diffusion 1.5 - the smallest, fastest starting point.", + "description": "Baseline text-to-image with Stable Diffusion 1.5 - the smallest, fastest starting point. It loads with 'safety_checker': null: SD 1.5's checker false-positives on ordinary prompts for particular seeds and returns a solid black image rather than an error, which a reference template used to prove the engine works must not do. Any pipeline that keeps the checker says so as a 'safety_checker_blanked' warning when it fires.", "cost": [ {"device": "cuda", "name": "RTX 3090", "vram_gb": 24, "minutes": 0.2} ], @@ -17,7 +17,8 @@ }, "from_pretrained_arguments": { "model_name": "stable-diffusion-v1-5/stable-diffusion-v1-5", - "torch_dtype": "torch.float32" + "torch_dtype": "torch.float32", + "safety_checker": null }, "arguments": { "prompt": "variable:prompt", From a6607e25a521e275e5b4e8aff0286a56d9317432 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 19:39:16 -0500 Subject: [PATCH 15/17] first ruff run --- .github/workflows/ci.yml | 4 +- docs/SECURITY_QUICKREF.md | 30 +- .../plans/2026-03-26-ecosystem-utilities.md | 106 +-- .../plans/2026-09-01-desktop-installers.md | 4 +- .../plans/2026-09-01-mcp-server.md | 130 +-- ...-03-memory-manager-step-cache-downloads.md | 112 ++- .../plans/2026-09-04-remote-gpu-server.md | 27 +- .../plans/2026-09-06-catalog-metadata.md | 455 +++++++--- ...2026-09-06-workflow-catalog-restructure.md | 4 +- .../plans/2026-09-07-dw-plugin-skills.md | 61 +- .../plans/2026-09-07-guides-and-validation.md | 151 ++-- .../plans/2026-09-07-ltx-h3-catalog-repair.md | 40 +- .../plans/2026-09-08-job-record-and-export.md | 251 +++--- .../2026-09-10-mcp-field-report-followups.md | 65 +- .../plans/2026-09-11-list-driven-stage-3.md | 105 ++- .../plans/2026-09-11-list-driven-steps.md | 178 ++-- .../plans/2026-09-11-list-driven-templates.md | 70 +- ...-09-12-output-subfolders-stage-1-engine.md | 226 ++--- ...-09-12-output-subfolders-stage-2-server.md | 117 +-- ...9-12-output-subfolders-stage-3-steering.md | 15 +- ...26-09-13-acknowledged-cost-stage-1-plan.md | 162 +++- ...26-09-13-acknowledged-cost-stage-2-plan.md | 820 ++++++++++-------- .../2026-03-26-ecosystem-utilities-design.md | 2 + .../2026-09-04-remote-gpu-server-design.md | 16 +- ...2026-09-08-job-record-and-export-design.md | 13 +- ...-09-12-acknowledged-cost-binding-design.md | 8 +- dw/arguments.py | 6 +- dw/repl.py | 4 +- dw/serve.py | 5 +- dw/server/app.py | 10 +- dw/server/guides.py | 2 +- dw/tasks/audio_utils.py | 5 +- dw/tasks/image_utils.py | 4 +- dw/teacache.py | 7 +- dw/worker.py | 1 - dw/workflow.py | 12 +- dw_mcp/diagnose.py | 3 +- pyproject.toml | 22 +- requirements-test.txt | 2 +- tests/README.md | 3 +- tests/conftest.py | 1 - tests/run_tests.py | 3 +- tests/test_argument_updates.py | 47 +- tests/test_cache_blocks.py | 2 - tests/test_catalog_structure.py | 96 +- tests/test_chain.py | 2 - tests/test_configuration_schema.py | 4 +- tests/test_device.py | 1 - tests/test_diffusion_upscale.py | 8 +- tests/test_gather.py | 6 +- tests/test_integration.py | 1 - tests/test_introspection.py | 3 +- tests/test_ltx_prompt_library.py | 6 +- tests/test_mcp_main.py | 2 - tests/test_mcp_media.py | 8 +- tests/test_mcp_server.py | 6 +- tests/test_pipeline_caching.py | 52 +- tests/test_pipeline_components.py | 4 +- tests/test_plan.py | 6 +- tests/test_plugin_skills.py | 30 +- tests/test_realize.py | 25 +- tests/test_repl_commands.py | 4 +- tests/test_repl_reorganization.py | 12 +- tests/test_result_output_naming.py | 2 - tests/test_segment.py | 1 - tests/test_server.py | 22 +- tests/test_server_exports.py | 2 +- tests/test_server_workspaces.py | 1 - tests/test_step.py | 4 +- tests/test_task.py | 12 +- tests/test_template_subfolders.py | 18 +- tests/test_tensor_image.py | 1 - tests/test_type_helpers.py | 2 - tests/test_video_utils.py | 5 +- tests/test_workflow_step_cache.py | 1 - 75 files changed, 2190 insertions(+), 1468 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35938933..21436dbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,9 @@ jobs: pip install -r requirements.txt -r requirements-test.txt pip install git+https://github.com/huggingface/diffusers - name: Format check - run: black --check dw dw_mcp tests + run: ruff format --check dw dw_mcp tests + - name: Lint + run: ruff check dw dw_mcp tests - name: Tests run: pytest -q diff --git a/docs/SECURITY_QUICKREF.md b/docs/SECURITY_QUICKREF.md index ccaeb634..79bff9a1 100644 --- a/docs/SECURITY_QUICKREF.md +++ b/docs/SECURITY_QUICKREF.md @@ -13,9 +13,16 @@ and [REMOTE.md](REMOTE.md). ```python from dw.security import ( - validate_path, validate_workflow_path, validate_output_path, - validate_url, validate_variable_name, validate_string_input, - sanitize_command_args, SecurityError, PathTraversalError, InvalidInputError + validate_path, + validate_workflow_path, + validate_output_path, + validate_url, + validate_variable_name, + validate_string_input, + sanitize_command_args, + SecurityError, + PathTraversalError, + InvalidInputError, ) ``` @@ -28,11 +35,11 @@ anything a workflow names: ```python from dw.locations import ( - validate_media_path, # confined to the workflow dir / assets / outputs - validate_media_glob, # the same, on a pattern's fixed prefix - contained_matches, # each match re-checked on its real path - validate_media_url, # no loopback / link-local / private host - validate_model_name, # a Hub repo id, or a contained path + validate_media_path, # confined to the workflow dir / assets / outputs + validate_media_glob, # the same, on a pattern's fixed prefix + contained_matches, # each match re-checked on its real path + validate_media_url, # no loopback / link-local / private host + validate_model_name, # a Hub repo id, or a contained path ) ``` @@ -49,13 +56,14 @@ workflow_path = validate_workflow_path("workflow.json") output_path = validate_output_path(user_path, base_output_dir) # User input -var_name = validate_variable_name("prompt") # OK -var_name = validate_variable_name("bad;name") # raises InvalidInputError +var_name = validate_variable_name("prompt") # OK +var_name = validate_variable_name("bad;name") # raises InvalidInputError value = validate_string_input(user_input, max_length=1000) -url = validate_url(user_url) # http/https only +url = validate_url(user_url) # http/https only # Subprocess (dw/ doesn't currently shell out anywhere - pattern for if/when it does) import subprocess + cmd = sanitize_command_args(["python", "-m", "dw.run", validated_path]) subprocess.Popen(cmd, shell=False) ``` diff --git a/docs/superpowers/plans/2026-03-26-ecosystem-utilities.md b/docs/superpowers/plans/2026-03-26-ecosystem-utilities.md index d2da6433..53ee7f0c 100644 --- a/docs/superpowers/plans/2026-03-26-ecosystem-utilities.md +++ b/docs/superpowers/plans/2026-03-26-ecosystem-utilities.md @@ -229,7 +229,9 @@ class TestSegmentImage: torch.zeros(1, 1, 480, 640) ] # Set the mask area to 1 - mock_sam_proc_instance.post_process_masks.return_value[0][0, 0, 100:300, 100:300] = 1.0 + mock_sam_proc_instance.post_process_masks.return_value[0][ + 0, 0, 100:300, 100:300 + ] = 1.0 image = _make_test_image() result = segment_image(image, "dog") @@ -787,9 +789,7 @@ def interpolate_frames(video, device="cpu", **kwargs): ) if len(video) < 2: - raise ValueError( - f"Need at least 2 frames to interpolate, got {len(video)}" - ) + raise ValueError(f"Need at least 2 frames to interpolate, got {len(video)}") logger.info( f"Interpolating {len(video)} frames with {multiplier}x multiplier on {device}" @@ -836,7 +836,9 @@ def _load_rife_model(device, model_name=None): ) if model_name is None: - model_name = "skytnt/anime-seg" # Placeholder — replace with actual RIFE HF repo + model_name = ( + "skytnt/anime-seg" # Placeholder — replace with actual RIFE HF repo + ) logger.info(f"Loading RIFE model from {model_name} to {device}") @@ -1026,14 +1028,20 @@ class TestMetadataEmbedding: def test_png_metadata_embedded(self): """When embed_metadata is true, PNG should contain parameters text chunk.""" with tempfile.TemporaryDirectory() as temp_dir: - result_def = {"content_type": "image/png", "save": True, "embed_metadata": True} + result_def = { + "content_type": "image/png", + "save": True, + "embed_metadata": True, + } result = Result(result_def) - result.set_metadata({ - "workflow_id": "test_workflow", - "step_name": "generate", - "model_name": "test/model", - "arguments": {"prompt": "a cat", "num_inference_steps": 25}, - }) + result.set_metadata( + { + "workflow_id": "test_workflow", + "step_name": "generate", + "model_name": "test/model", + "arguments": {"prompt": "a cat", "num_inference_steps": 25}, + } + ) # Add a real PIL image img = Image.new("RGB", (64, 64), color=(128, 64, 32)) @@ -1163,45 +1171,45 @@ Replace it with metadata-aware saving: Add the `_save_image_with_metadata` method to the Result class (after `save_artifact`): ```python - def _save_image_with_metadata(self, image, output_path, content_type): - """Save an image with embedded generation metadata. +def _save_image_with_metadata(self, image, output_path, content_type): + """Save an image with embedded generation metadata. - Args: - image: PIL Image to save - output_path: File path to save to - content_type: MIME type (determines embedding method) - """ - metadata_json = json.dumps(self.metadata, default=str) - - if content_type == "image/png": - from PIL.PngImagePlugin import PngInfo - - png_info = PngInfo() - png_info.add_text("parameters", metadata_json) - image.save(output_path, pnginfo=png_info) - logger.debug(f"Embedded PNG metadata in {output_path}") - elif content_type in ("image/jpeg", "image/webp"): - try: - import piexif - - exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}} - if hasattr(image, "info") and "exif" in image.info: - exif_dict = piexif.load(image.info["exif"]) - exif_dict["Exif"][ - piexif.ExifIFD.UserComment - ] = piexif.helper.UserComment.dump(metadata_json) - exif_bytes = piexif.dump(exif_dict) - image.save(output_path, exif=exif_bytes) - logger.debug(f"Embedded EXIF metadata in {output_path}") - except ImportError: - logger.warning( - "piexif not installed - saving without metadata. " - "Install with: pip install piexif" - ) - image.save(output_path) - else: - # Unsupported image format for metadata - save normally + Args: + image: PIL Image to save + output_path: File path to save to + content_type: MIME type (determines embedding method) + """ + metadata_json = json.dumps(self.metadata, default=str) + + if content_type == "image/png": + from PIL.PngImagePlugin import PngInfo + + png_info = PngInfo() + png_info.add_text("parameters", metadata_json) + image.save(output_path, pnginfo=png_info) + logger.debug(f"Embedded PNG metadata in {output_path}") + elif content_type in ("image/jpeg", "image/webp"): + try: + import piexif + + exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}} + if hasattr(image, "info") and "exif" in image.info: + exif_dict = piexif.load(image.info["exif"]) + exif_dict["Exif"][piexif.ExifIFD.UserComment] = ( + piexif.helper.UserComment.dump(metadata_json) + ) + exif_bytes = piexif.dump(exif_dict) + image.save(output_path, exif=exif_bytes) + logger.debug(f"Embedded EXIF metadata in {output_path}") + except ImportError: + logger.warning( + "piexif not installed - saving without metadata. " + "Install with: pip install piexif" + ) image.save(output_path) + else: + # Unsupported image format for metadata - save normally + image.save(output_path) ``` - [ ] **Step 3: Run metadata tests** diff --git a/docs/superpowers/plans/2026-09-01-desktop-installers.md b/docs/superpowers/plans/2026-09-01-desktop-installers.md index 64b2ee2d..4c91ccee 100644 --- a/docs/superpowers/plans/2026-09-01-desktop-installers.md +++ b/docs/superpowers/plans/2026-09-01-desktop-installers.md @@ -139,7 +139,9 @@ def settings_dir(): runs dw/__init__.py and pulls in torch, which this pure HTTP client has no use for (test_mcp_server.py::TestStartupWeight guards the boundary). """ - return Path(os.environ.get("DIFFUSERS_HELPER_ROOT") or "~/.diffusers_helper/").expanduser() + return Path( + os.environ.get("DIFFUSERS_HELPER_ROOT") or "~/.diffusers_helper/" + ).expanduser() def _base_url_from_server_file(): diff --git a/docs/superpowers/plans/2026-09-01-mcp-server.md b/docs/superpowers/plans/2026-09-01-mcp-server.md index 0715a23b..62bcde11 100644 --- a/docs/superpowers/plans/2026-09-01-mcp-server.md +++ b/docs/superpowers/plans/2026-09-01-mcp-server.md @@ -208,27 +208,25 @@ In `JobHistory.__init__`, add `events TEXT` to the `CREATE TABLE` and migrate databases that predate it: ```python - connection.execute("""CREATE TABLE IF NOT EXISTS jobs ( - id TEXT PRIMARY KEY, - workflow TEXT, - status TEXT, - created_at REAL, - started_at REAL, - finished_at REAL, - arguments TEXT, - spec TEXT, - manifest TEXT, - warnings TEXT, - error TEXT, - events TEXT - )""") - # Databases written before events were persisted are missing the - # column; ALTER is the whole migration, and rows keep NULL - columns = { - row[1] for row in connection.execute("PRAGMA table_info(jobs)") - } - if "events" not in columns: - connection.execute("ALTER TABLE jobs ADD COLUMN events TEXT") +connection.execute("""CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + workflow TEXT, + status TEXT, + created_at REAL, + started_at REAL, + finished_at REAL, + arguments TEXT, + spec TEXT, + manifest TEXT, + warnings TEXT, + error TEXT, + events TEXT + )""") +# Databases written before events were persisted are missing the +# column; ALTER is the whole migration, and rows keep NULL +columns = {row[1] for row in connection.execute("PRAGMA table_info(jobs)")} +if "events" not in columns: + connection.execute("ALTER TABLE jobs ADD COLUMN events TEXT") ``` Update the class docstring, which currently says events are not persisted: @@ -385,9 +383,7 @@ def test_event_log_pages_with_after_and_limit(client_and_manager): assert first["truncated"] is True assert first["last_seq"] == 1 - rest = client.get( - f"/api/jobs/{job_id}/event-log?after={first['last_seq']}" - ).json() + rest = client.get(f"/api/jobs/{job_id}/event-log?after={first['last_seq']}").json() assert rest["events"][0]["seq"] == 2 assert rest["truncated"] is False @@ -423,9 +419,7 @@ def test_event_log_serves_a_historical_jobs_persisted_events(client_and_manager) def test_event_log_pages_a_historical_jobs_events(client_and_manager): client, manager = client_and_manager manager.get = lambda job_id: {"id": job_id, "status": "complete"} - manager.history.events_for = lambda job_id: [ - {"seq": index} for index in range(5) - ] + manager.history.events_for = lambda job_id: [{"seq": index} for index in range(5)] body = client.get("/api/jobs/historical/event-log?after=1&limit=2").json() @@ -920,11 +914,15 @@ def recording_client(body=None, status=200): (lambda c: catalog.get_workflow(c, "folder/w"), "/api/workflows/folder/w"), (lambda c: catalog.get_schema(c), "/api/schema"), (lambda c: catalog.list_pipelines(c), "/api/pipelines"), - (lambda c: catalog.get_pipeline_signature(c, "FluxPipeline"), - "/api/pipelines/FluxPipeline"), + ( + lambda c: catalog.get_pipeline_signature(c, "FluxPipeline"), + "/api/pipelines/FluxPipeline", + ), (lambda c: catalog.list_classes(c, "schedulers"), "/api/classes"), - (lambda c: catalog.get_class(c, "diffusers.AutoencoderKL"), - "/api/classes/diffusers.AutoencoderKL"), + ( + lambda c: catalog.get_class(c, "diffusers.AutoencoderKL"), + "/api/classes/diffusers.AutoencoderKL", + ), (lambda c: catalog.list_tasks(c), "/api/tasks"), (lambda c: catalog.get_task(c, "upscale"), "/api/tasks/upscale"), (lambda c: catalog.list_models(c), "/api/models"), @@ -932,8 +930,10 @@ def recording_client(body=None, status=200): (lambda c: catalog.get_health(c), "/api/health"), (lambda c: catalog.list_jobs(c), "/api/jobs"), (lambda c: catalog.list_gallery(c), "/api/gallery"), - (lambda c: catalog.get_gallery_metadata(c, "a.png"), - "/api/gallery/a.png/metadata"), + ( + lambda c: catalog.get_gallery_metadata(c, "a.png"), + "/api/gallery/a.png/metadata", + ), ], ) def test_each_catalog_tool_calls_its_route(call, path): @@ -1410,7 +1410,12 @@ def scripted(routes): def test_validate_posts_an_inline_workflow(): client, seen = scripted( - {("POST", "/api/validate"): (200, {"valid": True, "error": None, "warnings": []})} + { + ("POST", "/api/validate"): ( + 200, + {"valid": True, "error": None, "warnings": []}, + ) + } ) result = authoring.validate_workflow(client, workflow=WORKFLOW) @@ -1475,8 +1480,9 @@ def test_save_puts_the_definition_under_its_name(): body_seen["method"] = request.method body_seen["path"] = request.url.path body_seen["body"] = request.read() - return httpx.Response(200, json={"name": "mine", "path": "/w/mine.json", - "warnings": []}) + return httpx.Response( + 200, json={"name": "mine", "path": "/w/mine.json", "warnings": []} + ) client = DwClient(transport=httpx.MockTransport(handler)) @@ -1633,8 +1639,9 @@ def scripted(routes): def handler(request): key = (request.method, request.url.path) - seen.append({"key": key, "body": request.read(), - "params": dict(request.url.params)}) + seen.append( + {"key": key, "body": request.read(), "params": dict(request.url.params)} + ) if key not in routes: return httpx.Response(404, json={"detail": f"unrouted {key}"}) status, body = routes[key] @@ -1688,9 +1695,7 @@ def test_run_returns_immediately_rather_than_waiting_for_the_job(): def test_run_sends_an_inline_workflow_when_given_one(): client, seen = submitting() - diagnose.run_workflow( - client, inline_workflow=WORKFLOW, acknowledged_cost=True - ) + diagnose.run_workflow(client, inline_workflow=WORKFLOW, acknowledged_cost=True) assert b'"workflow"' in seen[0]["body"] @@ -1700,9 +1705,7 @@ def test_run_never_sends_base_dir(): a path-authority parameter the tool surface deliberately withholds.""" client, seen = submitting() - diagnose.run_workflow( - client, inline_workflow=WORKFLOW, acknowledged_cost=True - ) + diagnose.run_workflow(client, inline_workflow=WORKFLOW, acknowledged_cost=True) assert b"base_dir" not in seen[0]["body"] @@ -1747,15 +1750,17 @@ def test_run_surfaces_a_rejected_workflow(): ) with pytest.raises(DwApiError, match="steps must not be empty"): - diagnose.run_workflow( - client, inline_workflow=WORKFLOW, acknowledged_cost=True - ) + diagnose.run_workflow(client, inline_workflow=WORKFLOW, acknowledged_cost=True) def test_get_job_returns_the_detail_payload(): client, _seen = scripted( - {("GET", "/api/jobs/job-1"): (200, {"id": "job-1", "status": "failed", - "error": "CUDA out of memory"})} + { + ("GET", "/api/jobs/job-1"): ( + 200, + {"id": "job-1", "status": "failed", "error": "CUDA out of memory"}, + ) + } ) assert diagnose.get_job(client, "job-1")["error"] == "CUDA out of memory" @@ -1766,9 +1771,14 @@ def test_get_job_events_pages_from_the_event_log(): { ("GET", "/api/jobs/job-1/event-log"): ( 200, - {"id": "job-1", "status": "running", - "events": [{"seq": 3, "event": "phase"}], - "last_seq": 3, "truncated": True, "note": None}, + { + "id": "job-1", + "status": "running", + "events": [{"seq": 3, "event": "phase"}], + "last_seq": 3, + "truncated": True, + "note": None, + }, ) } ) @@ -1793,10 +1803,14 @@ def test_get_job_events_defaults_to_the_whole_log(): def test_cancel_rerun_and_move_call_their_routes(): client, seen = scripted( { - ("POST", "/api/jobs/job-1/cancel"): (200, {"id": "job-1", - "status": "cancelled"}), - ("POST", "/api/jobs/job-1/rerun"): (201, {"id": "job-2", - "status": "queued"}), + ("POST", "/api/jobs/job-1/cancel"): ( + 200, + {"id": "job-1", "status": "cancelled"}, + ), + ("POST", "/api/jobs/job-1/rerun"): ( + 201, + {"id": "job-2", "status": "queued"}, + ), ("POST", "/api/jobs/job-1/move"): (200, {"id": "job-1", "queue": []}), } ) @@ -2032,7 +2046,9 @@ async def test_every_designed_tool_is_registered(): async def test_every_tool_has_a_description(): tools = await tools_of(server_over(ok({}))) - missing = [name for name, tool in tools.items() if not (tool.description or "").strip()] + missing = [ + name for name, tool in tools.items() if not (tool.description or "").strip() + ] assert missing == [] diff --git a/docs/superpowers/plans/2026-09-03-memory-manager-step-cache-downloads.md b/docs/superpowers/plans/2026-09-03-memory-manager-step-cache-downloads.md index d85a0022..447ca82e 100644 --- a/docs/superpowers/plans/2026-09-03-memory-manager-step-cache-downloads.md +++ b/docs/superpowers/plans/2026-09-03-memory-manager-step-cache-downloads.md @@ -195,7 +195,9 @@ class FakeModel: self.oom_on = None def to(self, device): - device = torch.device(device) if not isinstance(device, torch.device) else device + device = ( + torch.device(device) if not isinstance(device, torch.device) else device + ) if self.oom_on is not None and str(device) == str(self.oom_on): self.oom_on = None # only OOM once, so a retry after eviction succeeds raise torch.OutOfMemoryError(f"{self.name} cannot fit on {device}") @@ -301,6 +303,7 @@ makes for its node graph. It is not consulted by 'offload: model/sequential' or 'group_offload', which install diffusers' own hooks - dw does not own the individual .to() calls those make, so there is nothing to intercept. """ + import time import logging @@ -334,10 +337,16 @@ class MemoryManager: try: component.to(device) if entry is not None: - entry["device"] = torch.device(device) if not isinstance(device, torch.device) else device + entry["device"] = ( + torch.device(device) + if not isinstance(device, torch.device) + else device + ) return except torch.OutOfMemoryError: - victim_id = self._pick_eviction_candidate(device, exclude_id=id(component)) + victim_id = self._pick_eviction_candidate( + device, exclude_id=id(component) + ) if victim_id is None: raise self._evict(victim_id, offload_device) @@ -346,7 +355,11 @@ class MemoryManager: """Record that `component` has been moved back to `offload_device`.""" entry = self._entries.get(id(component)) if entry is not None: - entry["device"] = torch.device(offload_device) if not isinstance(offload_device, torch.device) else offload_device + entry["device"] = ( + torch.device(offload_device) + if not isinstance(offload_device, torch.device) + else offload_device + ) def _pick_eviction_candidate(self, device, exclude_id): device = str(device) @@ -364,9 +377,15 @@ class MemoryManager: entry = self._entries.get(comp_id) if entry is None: return - logger.debug(f"Evicting a lower-priority on-demand component to free {entry['device']}") + logger.debug( + f"Evicting a lower-priority on-demand component to free {entry['device']}" + ) entry["component"].to(offload_device) - entry["device"] = torch.device(offload_device) if not isinstance(offload_device, torch.device) else offload_device + entry["device"] = ( + torch.device(offload_device) + if not isinstance(offload_device, torch.device) + else offload_device + ) memory_manager = MemoryManager() @@ -752,6 +771,7 @@ A step is safe to skip only if: 3. every previous_result: it reads was ITSELF served from cache this run - otherwise a change upstream leaves this step's cached output stale """ + import logging from .workflow import referenced_result_names @@ -896,43 +916,41 @@ from .step_cache import step_cache Then in `Workflow.run`, initialize `hits_this_run = set()` immediately before the `for i, step_data in enumerate(steps):` loop (`dw/workflow.py:337-338`), and replace the loop body from `step = Step(step_data, step_seed, self.workflow_definition)` through the `saved_files = result.save(...)` / `self.manifest.append(...)` block (`dw/workflow.py:352-370`) with: ```python - step = Step(step_data, step_seed, self.workflow_definition) - - is_cacheable = "workflow" not in step_data - cached_result = ( - step_cache.get(step_data, step_seed, hits_this_run) - if is_cacheable - else None - ) - - if cached_result is not None: - logger.info(f"Step '{step.name}' unchanged - reusing cached result") - result = cached_result - saved_files = result.saved_files - hits_this_run.add(step.name) - step_action = None - else: - step_action = self.create_step_action( - step_data, - shared_components, - pipelines, - step_seed, - get_device(), - ) - result = step.run(results, pipelines, step_action) - saved_files = result.save( - self.effective_output_dir, f"{workflow_id}-{step.name}.{i}" - ) - if is_cacheable: - step_cache.put(step_data, step_seed, result) - - last_result = result - results[step.name] = result - self.manifest.append({"step": step.name, "files": saved_files}) - # A sub-workflow's saves land in the child's manifest - roll - # them up so job history and the gallery see every file - if isinstance(step_action, Workflow): - self.manifest.extend(getattr(step_action, "manifest", [])) +step = Step(step_data, step_seed, self.workflow_definition) + +is_cacheable = "workflow" not in step_data +cached_result = ( + step_cache.get(step_data, step_seed, hits_this_run) if is_cacheable else None +) + +if cached_result is not None: + logger.info(f"Step '{step.name}' unchanged - reusing cached result") + result = cached_result + saved_files = result.saved_files + hits_this_run.add(step.name) + step_action = None +else: + step_action = self.create_step_action( + step_data, + shared_components, + pipelines, + step_seed, + get_device(), + ) + result = step.run(results, pipelines, step_action) + saved_files = result.save( + self.effective_output_dir, f"{workflow_id}-{step.name}.{i}" + ) + if is_cacheable: + step_cache.put(step_data, step_seed, result) + +last_result = result +results[step.name] = result +self.manifest.append({"step": step.name, "files": saved_files}) +# A sub-workflow's saves land in the child's manifest - roll +# them up so job history and the gallery see every file +if isinstance(step_action, Workflow): + self.manifest.extend(getattr(step_action, "manifest", [])) ``` Leave everything below this (the `run_context.emit("step_end", ...)` call and everything after, `dw/workflow.py:371-395`) unchanged — `saved_files` is defined on both branches so it still works. @@ -1305,14 +1323,18 @@ def test_download_output_writes_bytes_to_explicit_file_path(tmp_path): def test_download_output_into_a_directory_uses_the_output_basename(tmp_path): client = serving(png_bytes(10, 10), "image/png") - result = download_output(client, "sub/run-step.0-0.0.png", destination=str(tmp_path)) + result = download_output( + client, "sub/run-step.0-0.0.png", destination=str(tmp_path) + ) saved = tmp_path / "run-step.0-0.0.png" assert saved.read_bytes() == png_bytes(10, 10) assert result["saved_to"] == str(saved) -def test_download_output_with_no_destination_saves_to_current_directory(tmp_path, monkeypatch): +def test_download_output_with_no_destination_saves_to_current_directory( + tmp_path, monkeypatch +): monkeypatch.chdir(tmp_path) client = serving(png_bytes(10, 10), "image/png") diff --git a/docs/superpowers/plans/2026-09-04-remote-gpu-server.md b/docs/superpowers/plans/2026-09-04-remote-gpu-server.md index 4f209836..035e24fb 100644 --- a/docs/superpowers/plans/2026-09-04-remote-gpu-server.md +++ b/docs/superpowers/plans/2026-09-04-remote-gpu-server.md @@ -364,7 +364,12 @@ def no_stdio(monkeypatch): def healthy(request): return httpx.Response( 200, - json={"status": "ok", "hostname": "gpu-box", "version": "1.2.3", "device": "cuda"}, + json={ + "status": "ok", + "hostname": "gpu-box", + "version": "1.2.3", + "device": "cuda", + }, ) @@ -434,7 +439,9 @@ def test_a_successful_probe_prints_the_server_identity(no_stdio, capsys): seen = [] def handler(request): - seen.append((request.method, request.url.path, request.headers.get("authorization"))) + seen.append( + (request.method, request.url.path, request.headers.get("authorization")) + ) return healthy(request) code = cli.main( @@ -710,7 +717,9 @@ def test_mcp_is_not_mounted_by_default(tmp_path): app = make_app(tmp_path, mcp=False) with TestClient(app, base_url="http://localhost") as client: assert client.get("/api/health").json()["mcp"] is False - assert client.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS).status_code == 404 + assert ( + client.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS).status_code == 404 + ) def test_mcp_mount_answers_initialize(tmp_path): @@ -726,12 +735,16 @@ def test_mcp_mount_answers_initialize(tmp_path): def test_mcp_mount_is_gated_by_the_bearer_token(tmp_path): app = make_app(tmp_path, token="s3cr3t") with TestClient(app, base_url="http://localhost") as client: - assert client.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS).status_code == 401 + assert ( + client.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS).status_code == 401 + ) wrong = {**MCP_HEADERS, "Authorization": "Bearer nope"} assert client.post("/mcp", json=INITIALIZE, headers=wrong).status_code == 401 # never as a query parameter - that allowance is for / only assert ( - client.post("/mcp?token=s3cr3t", json=INITIALIZE, headers=MCP_HEADERS).status_code + client.post( + "/mcp?token=s3cr3t", json=INITIALIZE, headers=MCP_HEADERS + ).status_code == 401 ) right = {**MCP_HEADERS, "Authorization": "Bearer s3cr3t"} @@ -759,7 +772,9 @@ async def test_a_real_mcp_client_lists_every_tool_over_http(tmp_path): async with streamable_http_client( "http://localhost/mcp", http_client=http ) as streams: - async with ClientSession(streams.read_stream, streams.write_stream) as session: + async with ClientSession( + streams.read_stream, streams.write_stream + ) as session: await session.initialize() tools = await session.list_tools() assert {t.name for t in tools.tools} == EXPECTED_TOOLS diff --git a/docs/superpowers/plans/2026-09-06-catalog-metadata.md b/docs/superpowers/plans/2026-09-06-catalog-metadata.md index 4cb684cb..ffa1acd6 100644 --- a/docs/superpowers/plans/2026-09-06-catalog-metadata.md +++ b/docs/superpowers/plans/2026-09-06-catalog-metadata.md @@ -72,7 +72,9 @@ from dw.server.catalog_shape import ( ) -def pipeline_step(name, content_type, component_type="{Fake}", arguments=None, chain=None): +def pipeline_step( + name, content_type, component_type="{Fake}", arguments=None, chain=None +): pipeline = { "configuration": {"component_type": component_type}, "from_pretrained_arguments": {"model_name": "m"}, @@ -80,7 +82,11 @@ def pipeline_step(name, content_type, component_type="{Fake}", arguments=None, c } if chain is not None: pipeline["chain"] = chain - return {"name": name, "pipeline": pipeline, "result": {"content_type": content_type}} + return { + "name": name, + "pipeline": pipeline, + "result": {"content_type": content_type}, + } def task_step(name, command, arguments, content_type=None): @@ -95,13 +101,32 @@ def definition(*steps, **top): def test_vocabularies_are_closed_and_stable(): - assert SHAPES == ("image", "image-set", "image-edit", "shot", "sequence", "audio", "text", "utility") + assert SHAPES == ( + "image", + "image-set", + "image-edit", + "shot", + "sequence", + "audio", + "text", + "utility", + ) assert TRAITS == ( - "speech", "chained", "image-conditioned", "identity-referenced", - "needs-input-media", "composes-workflows", + "speech", + "chained", + "image-conditioned", + "identity-referenced", + "needs-input-media", + "composes-workflows", ) assert GENERATIVE_TASKS == frozenset( - {"generate_speech", "text_generation", "image_to_text", "diffusion_upscale", "interpolate_frames"} + { + "generate_speech", + "text_generation", + "image_to_text", + "diffusion_upscale", + "interpolate_frames", + } ) @@ -130,22 +155,36 @@ def test_a_workflow_step_emitting_images_is_an_image_set(): @pytest.mark.parametrize( - "component_type", ["FluxImg2ImgPipeline", "StableDiffusionInpaintPipeline", "QwenImageEditPipeline", "FluxKontextPipeline", "StableDiffusionUpscalePipeline"] + "component_type", + [ + "FluxImg2ImgPipeline", + "StableDiffusionInpaintPipeline", + "QwenImageEditPipeline", + "FluxKontextPipeline", + "StableDiffusionUpscalePipeline", + ], ) def test_an_editing_pipeline_is_image_edit(component_type): - meta = derive_catalog_metadata(definition(pipeline_step("gen", "image/jpeg", component_type))) + meta = derive_catalog_metadata( + definition(pipeline_step("gen", "image/jpeg", component_type)) + ) assert meta["shape"] == "image-edit" def test_an_image_argument_on_an_image_pipeline_is_image_edit(): - step = pipeline_step("gen", "image/jpeg", arguments={"prompt": "p", "image": "variable:image"}) + step = pipeline_step( + "gen", "image/jpeg", arguments={"prompt": "p", "image": "variable:image"} + ) meta = derive_catalog_metadata(definition(step)) assert meta["shape"] == "image-edit" assert "needs-input-media" in meta["traits"] def test_one_clip_is_a_shot(): - assert derive_catalog_metadata(definition(pipeline_step("v", "video/mp4")))["shape"] == "shot" + assert ( + derive_catalog_metadata(definition(pipeline_step("v", "video/mp4")))["shape"] + == "shot" + ) def test_a_concat_fed_by_two_steps_is_a_sequence(): @@ -153,7 +192,12 @@ def test_a_concat_fed_by_two_steps_is_a_sequence(): definition( pipeline_step("a", "video/mp4"), pipeline_step("b", "video/mp4"), - task_step("cut", "concat_videos", {"videos": ["previous_result:a", "previous_result:b"]}, "video/mp4"), + task_step( + "cut", + "concat_videos", + {"videos": ["previous_result:a", "previous_result:b"]}, + "video/mp4", + ), ) ) assert meta["shape"] == "sequence" @@ -164,7 +208,12 @@ def test_a_dissolve_fed_by_two_steps_is_a_sequence(): definition( pipeline_step("a", "video/mp4"), pipeline_step("b", "video/mp4"), - task_step("cut", "dissolve_videos", {"videos": ["previous_result:a", "previous_result:b"]}, "video/mp4"), + task_step( + "cut", + "dissolve_videos", + {"videos": ["previous_result:a", "previous_result:b"]}, + "video/mp4", + ), ) ) assert meta["shape"] == "sequence" @@ -174,7 +223,12 @@ def test_a_concat_fed_by_one_step_is_still_a_shot(): meta = derive_catalog_metadata( definition( pipeline_step("a", "video/mp4"), - task_step("cut", "concat_videos", {"videos": ["previous_result:a", "previous_result:a"]}, "video/mp4"), + task_step( + "cut", + "concat_videos", + {"videos": ["previous_result:a", "previous_result:a"]}, + "video/mp4", + ), ) ) assert meta["shape"] == "shot" @@ -187,15 +241,21 @@ def test_a_chain_is_a_chained_shot_not_a_sequence(): assert "chained" in meta["traits"] -@pytest.mark.parametrize("name", ["last_frame", "last_segment", "last_image", "match_audio"]) +@pytest.mark.parametrize( + "name", ["last_frame", "last_segment", "last_image", "match_audio"] +) def test_continuation_arguments_are_chained(name): - step = pipeline_step("v", "video/mp4", arguments={"prompt": "p", name: "previous_result:x"}) + step = pipeline_step( + "v", "video/mp4", arguments={"prompt": "p", name: "previous_result:x"} + ) assert "chained" in derive_catalog_metadata(definition(step))["traits"] def test_video_outranks_image_when_both_are_produced(): meta = derive_catalog_metadata( - definition(pipeline_step("board", "image/jpeg"), pipeline_step("v", "video/mp4")) + definition( + pipeline_step("board", "image/jpeg"), pipeline_step("v", "video/mp4") + ) ) assert meta["shape"] == "shot" @@ -208,7 +268,9 @@ def test_audio_only_is_audio(): def test_text_only_is_text(): - step = task_step("expand", "text_generation", {"prompt": "variable:prompt"}, "text/plain") + step = task_step( + "expand", "text_generation", {"prompt": "variable:prompt"}, "text/plain" + ) assert derive_catalog_metadata(definition(step))["shape"] == "text" @@ -225,13 +287,19 @@ def test_a_generative_task_is_not_utility(): def test_a_video_pipeline_emitting_audio_speaks(): - step = pipeline_step("v", "video/mp4", arguments={"prompt": "p", "output": ["videos", "audio"]}) + step = pipeline_step( + "v", "video/mp4", arguments={"prompt": "p", "output": ["videos", "audio"]} + ) assert "speech" in derive_catalog_metadata(definition(step))["traits"] def test_a_video_pipeline_with_an_image_argument_is_image_conditioned(): - step = pipeline_step("v", "video/mp4", arguments={"prompt": "p", "image": "previous_result:still"}) - meta = derive_catalog_metadata(definition(pipeline_step("still", "image/jpeg"), step)) + step = pipeline_step( + "v", "video/mp4", arguments={"prompt": "p", "image": "previous_result:still"} + ) + meta = derive_catalog_metadata( + definition(pipeline_step("still", "image/jpeg"), step) + ) assert "image-conditioned" in meta["traits"] # previous_result is not supplied media assert "needs-input-media" not in meta["traits"] @@ -243,26 +311,42 @@ def test_an_image_to_video_component_is_image_conditioned(): def test_references_are_identity_referenced(): - step = pipeline_step("v", "video/mp4", arguments={"prompt": "p", "references": ["previous_result:face"]}) + step = pipeline_step( + "v", + "video/mp4", + arguments={"prompt": "p", "references": ["previous_result:face"]}, + ) assert "identity-referenced" in derive_catalog_metadata(definition(step))["traits"] def test_a_location_argument_needs_input_media(): - step = pipeline_step("v", "video/mp4", arguments={"prompt": "p", "image": {"location": "https://x/y.png"}}) + step = pipeline_step( + "v", + "video/mp4", + arguments={"prompt": "p", "image": {"location": "https://x/y.png"}}, + ) assert "needs-input-media" in derive_catalog_metadata(definition(step))["traits"] def test_a_pipeline_reference_step_counts_as_generation(): ref = { "name": "shot_2", - "pipeline_reference": {"reference_name": "shot_1", "arguments": {"prompt": "p", "references": ["asset:face.png"]}}, + "pipeline_reference": { + "reference_name": "shot_1", + "arguments": {"prompt": "p", "references": ["asset:face.png"]}, + }, "result": {"content_type": "video/mp4"}, } meta = derive_catalog_metadata( definition( pipeline_step("shot_1", "video/mp4"), ref, - task_step("cut", "concat_videos", {"videos": ["previous_result:shot_1", "previous_result:shot_2"]}, "video/mp4"), + task_step( + "cut", + "concat_videos", + {"videos": ["previous_result:shot_1", "previous_result:shot_2"]}, + "video/mp4", + ), ) ) assert meta["shape"] == "sequence" @@ -271,19 +355,27 @@ def test_a_pipeline_reference_step_counts_as_generation(): def test_summary_is_the_first_sentence(): - meta = derive_catalog_metadata(definition(pipeline_step("g", "image/jpeg"), description="Makes a cat. Then more.")) + meta = derive_catalog_metadata( + definition( + pipeline_step("g", "image/jpeg"), description="Makes a cat. Then more." + ) + ) assert meta["summary"] == "Makes a cat." assert meta["summary_truncated"] is False def test_summary_splits_on_newline_too(): - meta = derive_catalog_metadata(definition(pipeline_step("g", "image/jpeg"), description="Line one\nLine two.")) + meta = derive_catalog_metadata( + definition(pipeline_step("g", "image/jpeg"), description="Line one\nLine two.") + ) assert meta["summary"] == "Line one" def test_a_long_first_sentence_is_truncated_at_a_word_boundary(): words = " ".join(["word"] * 40) + "." - meta = derive_catalog_metadata(definition(pipeline_step("g", "image/jpeg"), description=words)) + meta = derive_catalog_metadata( + definition(pipeline_step("g", "image/jpeg"), description=words) + ) assert len(meta["summary"]) <= SUMMARY_LIMIT assert meta["summary"].endswith("…") assert not meta["summary"][:-1].endswith(" ") @@ -342,7 +434,16 @@ read structure (a concat step, a `references` argument), never checkpoints. import re -SHAPES = ("image", "image-set", "image-edit", "shot", "sequence", "audio", "text", "utility") +SHAPES = ( + "image", + "image-set", + "image-edit", + "shot", + "sequence", + "audio", + "text", + "utility", +) TRAITS = ( "speech", "chained", @@ -354,13 +455,21 @@ TRAITS = ( # Tasks that create content rather than process it. A workflow made only # of processing tasks is a utility. GENERATIVE_TASKS = frozenset( - {"generate_speech", "text_generation", "image_to_text", "diffusion_upscale", "interpolate_frames"} + { + "generate_speech", + "text_generation", + "image_to_text", + "diffusion_upscale", + "interpolate_frames", + } ) SUMMARY_LIMIT = 120 _KIND_PRECEDENCE = ("video", "audio", "image", "text") _EDIT_PIPELINE = re.compile(r"inpaint|img2img|edit|upscale|outpaint|kontext", re.I) -_CHAIN_ARGUMENTS = frozenset({"last_frame", "last_segment", "last_image", "match_audio"}) +_CHAIN_ARGUMENTS = frozenset( + {"last_frame", "last_segment", "last_image", "match_audio"} +) _MEDIA_ARGUMENTS = frozenset({"image", "video", "audio", "mask_image"}) _CUT_TASKS = frozenset({"concat_videos", "dissolve_videos"}) _SENTENCE_END = re.compile(r"(?<=[.!?])\s|\n") @@ -368,7 +477,11 @@ _SENTENCE_END = re.compile(r"(?<=[.!?])\s|\n") def _steps(definition): steps = definition.get("steps") if isinstance(definition, dict) else None - return [step for step in steps if isinstance(step, dict)] if isinstance(steps, list) else [] + return ( + [step for step in steps if isinstance(step, dict)] + if isinstance(steps, list) + else [] + ) def _block(step): @@ -440,7 +553,11 @@ def _needs_input_media(steps): for step in steps: arguments = _arguments(step) for name, value in arguments.items(): - if name in _MEDIA_ARGUMENTS and isinstance(value, str) and value.startswith("variable:"): + if ( + name in _MEDIA_ARGUMENTS + and isinstance(value, str) + and value.startswith("variable:") + ): return True for value in _walk(arguments): if isinstance(value, str) and value.startswith("asset:"): @@ -464,13 +581,17 @@ def _derive_shape(steps, kind): if len(_fed_by(_arguments(step).get("videos"))) >= 2: return "sequence" return "shot" - image_steps = [step for step in steps if _generates(step) and _kind(step) == "image"] + image_steps = [ + step for step in steps if _generates(step) and _kind(step) == "image" + ] for step in image_steps: if _EDIT_PIPELINE.search(_component_type(step)): return "image-edit" if _MEDIA_ARGUMENTS & set(_arguments(step)) & {"image", "mask_image"}: return "image-edit" - if len(image_steps) >= 2 or any(_block(step)[0] == "workflow" for step in image_steps): + if len(image_steps) >= 2 or any( + _block(step)[0] == "workflow" for step in image_steps + ): return "image-set" return "image" @@ -489,7 +610,9 @@ def _derive_traits(steps): traits.add("chained") if _CHAIN_ARGUMENTS & set(arguments): traits.add("chained") - if _kind(step) == "video" and ("image" in arguments or "ImageToVideo" in _component_type(step)): + if _kind(step) == "video" and ( + "image" in arguments or "ImageToVideo" in _component_type(step) + ): traits.add("image-conditioned") if "references" in arguments: traits.add("identity-referenced") @@ -599,9 +722,19 @@ def test_the_schema_declares_the_vocabulary(): def test_a_declared_cost_validates_and_a_bad_one_does_not(): schema = load_schema("workflow") base = definition(pipeline_step("g", "image/jpeg")) - ok, _ = validate_data({**base, "cost": [{"device": "cuda", "name": "RTX 4090", "vram_gb": 22, "minutes": 3}]}, schema) + ok, _ = validate_data( + { + **base, + "cost": [ + {"device": "cuda", "name": "RTX 4090", "vram_gb": 22, "minutes": 3} + ], + }, + schema, + ) assert ok - bad, message = validate_data({**base, "cost": [{"device": "tpu", "vram_gb": 1, "minutes": 1}]}, schema) + bad, message = validate_data( + {**base, "cost": [{"device": "tpu", "vram_gb": 1, "minutes": 1}]}, schema + ) assert not bad and "cost" in message bad, _ = validate_data({**base, "shape": "cinematic"}, schema) assert not bad @@ -693,20 +826,28 @@ def video_workflow(job_id, with_cost=False): "pipeline": { "configuration": {"component_type": "{Fake}", "no_generator": True}, "from_pretrained_arguments": {"model_name": "m"}, - "arguments": {"prompt": "variable:prompt", "output": ["videos", "audio"]}, + "arguments": { + "prompt": "variable:prompt", + "output": ["videos", "audio"], + }, }, "result": {"content_type": "video/mp4"}, } ], } if with_cost: - workflow["cost"] = [{"device": "cuda", "name": "RTX 4090", "vram_gb": 20, "minutes": 2}] + workflow["cost"] = [ + {"device": "cuda", "name": "RTX 4090", "vram_gb": 20, "minutes": 2} + ] return workflow def test_the_listing_carries_derived_metadata(server): with server(success_script) as client: - client.put("/api/workflows/templates/clip", json={"workflow": video_workflow("clip", with_cost=True)}) + client.put( + "/api/workflows/templates/clip", + json={"workflow": video_workflow("clip", with_cost=True)}, + ) tuned = video_workflow("tuned") tuned["configures"] = "templates/clip" tuned["description"] = "The same clip on a bigger checkpoint." @@ -718,7 +859,9 @@ def test_the_listing_carries_derived_metadata(server): assert clip["shape"] == "shot" assert clip["traits"] == ["speech"] assert clip["summary"] == "One clip from a prompt." - assert clip["cost"] == [{"device": "cuda", "name": "RTX 4090", "vram_gb": 20, "minutes": 2}] + assert clip["cost"] == [ + {"device": "cuda", "name": "RTX 4090", "vram_gb": 20, "minutes": 2} + ] tuned = details["models/tuned"] assert tuned["shape"] == "shot" and tuned["traits"] == ["speech"] @@ -729,7 +872,17 @@ def test_the_listing_carries_derived_metadata(server): assert basic["shape"] == "utility" # no result block, so no kind assert basic["summary"] == "" and basic["cost"] is None # nothing the UI reads went away - assert {"kinds", "steps", "variables", "variable_names", "description", "configures", "prompt_refs", "origin", "writable"} <= set(basic) + assert { + "kinds", + "steps", + "variables", + "variable_names", + "description", + "configures", + "prompt_refs", + "origin", + "writable", + } <= set(basic) ``` - [ ] **Step 2: Run to verify failure** @@ -833,10 +986,19 @@ from dw.server.catalog_shape import COMPACT_FIELDS, project_listing def entry(shape, traits=(), configures="", **extra): return { - "kinds": [], "steps": 1, "variables": 0, "variable_names": [], - "description": "long text", "configures": configures, "prompt_refs": [], - "origin": "workspace", "writable": True, - "shape": shape, "traits": sorted(traits), "summary": "short", "cost": None, + "kinds": [], + "steps": 1, + "variables": 0, + "variable_names": [], + "description": "long text", + "configures": configures, + "prompt_refs": [], + "origin": "workspace", + "writable": True, + "shape": shape, + "traits": sorted(traits), + "summary": "short", + "cost": None, **extra, } @@ -859,8 +1021,13 @@ def test_shape_filters(): def test_traits_must_all_match(): - assert set(project_listing(LISTING, traits=["speech"])) == {"templates/talk", "templates/clip"} - assert set(project_listing(LISTING, traits=["speech", "identity-referenced"])) == {"templates/talk"} + assert set(project_listing(LISTING, traits=["speech"])) == { + "templates/talk", + "templates/clip", + } + assert set(project_listing(LISTING, traits=["speech", "identity-referenced"])) == { + "templates/talk" + } def test_configures_filters_to_a_templates_configs(): @@ -875,15 +1042,23 @@ def test_compact_drops_prose_and_model_configs_and_keeps_user_workflows(): def test_compact_with_include_models_keeps_them(): - assert "models/flux" in project_listing(LISTING, view="compact", include_models=True) + assert "models/flux" in project_listing( + LISTING, view="compact", include_models=True + ) def test_compact_with_configures_implies_models(): - assert set(project_listing(LISTING, view="compact", configures="templates/tti")) == {"models/flux"} + assert set( + project_listing(LISTING, view="compact", configures="templates/tti") + ) == {"models/flux"} def test_compact_keeps_configures_missing_when_set(): - listing = {"models/typo": entry("image", configures="", configures_missing="templates/nope")} + listing = { + "models/typo": entry( + "image", configures="", configures_missing="templates/nope" + ) + } compact = project_listing(listing, view="compact", include_models=True) assert compact["models/typo"]["configures_missing"] == "templates/nope" @@ -918,7 +1093,15 @@ COMPACT_FIELDS = ( ) -def project_listing(details, *, shape=None, traits=None, configures=None, include_models=False, view=None): +def project_listing( + details, + *, + shape=None, + traits=None, + configures=None, + include_models=False, + view=None, +): """The listing an agent asked for: filtered by shape and traits, and in the compact view stripped to what choosing a template needs. @@ -929,11 +1112,15 @@ def project_listing(details, *, shape=None, traits=None, configures=None, includ found. The full view never drops entries or fields. """ if shape is not None and shape not in SHAPES: - raise ValueError(f"Unknown shape {shape!r}. The shapes are: {', '.join(SHAPES)}.") + raise ValueError( + f"Unknown shape {shape!r}. The shapes are: {', '.join(SHAPES)}." + ) traits = list(traits or []) unknown = [t for t in traits if t not in TRAITS] if unknown: - raise ValueError(f"Unknown trait(s) {', '.join(unknown)}. The traits are: {', '.join(TRAITS)}.") + raise ValueError( + f"Unknown trait(s) {', '.join(unknown)}. The traits are: {', '.join(TRAITS)}." + ) if view not in (None, "compact"): raise ValueError("view must be 'compact' or omitted") @@ -970,7 +1157,9 @@ Expected: all pass. ```python def test_the_listing_filters_and_compacts(server): with server(success_script) as client: - client.put("/api/workflows/templates/clip", json={"workflow": video_workflow("clip")}) + client.put( + "/api/workflows/templates/clip", json={"workflow": video_workflow("clip")} + ) tuned = video_workflow("tuned") tuned["configures"] = "templates/clip" client.put("/api/workflows/models/tuned", json={"workflow": tuned}) @@ -985,12 +1174,18 @@ def test_the_listing_filters_and_compacts(server): compact = client.get("/api/workflows", params={"view": "compact"}).json() assert set(compact["details"]) == {"Basic", "templates/clip"} assert "description" not in compact["details"]["templates/clip"] - assert compact["details"]["templates/clip"]["summary"] == "One clip from a prompt." + assert ( + compact["details"]["templates/clip"]["summary"] == "One clip from a prompt." + ) - with_models = client.get("/api/workflows", params={"view": "compact", "include_models": "true"}).json() + with_models = client.get( + "/api/workflows", params={"view": "compact", "include_models": "true"} + ).json() assert "models/tuned" in with_models["details"] - configs = client.get("/api/workflows", params={"configures": "templates/clip"}).json() + configs = client.get( + "/api/workflows", params={"configures": "templates/clip"} + ).json() assert set(configs["details"]) == {"models/tuned"} by_trait = client.get("/api/workflows", params={"traits": "speech"}).json() @@ -1101,7 +1296,11 @@ def test_list_workflows_passes_its_filters_through(): client, seen = recording_client({"workflows": [], "details": {}}) catalog.list_workflows( - client, shape="sequence", traits=["speech", "chained"], configures="templates/x", include_models=True + client, + shape="sequence", + traits=["speech", "chained"], + configures="templates/x", + include_models=True, ) assert seen["params"] == { @@ -1120,7 +1319,9 @@ In `tests/test_mcp_server.py`, add: async def test_list_workflows_takes_shape_and_traits(): tools = await tools_of(server_over(ok({}))) schema = tools["list_workflows"].inputSchema - assert {"shape", "traits", "configures", "include_models"} <= set(schema["properties"]) + assert {"shape", "traits", "configures", "include_models"} <= set( + schema["properties"] + ) assert "shape" in tools["list_workflows"].description @@ -1141,7 +1342,9 @@ Expected: `seen["params"]` is `{}`; the schema lacks `shape`. `dw_mcp/catalog.py`: ```python -def list_workflows(client, shape=None, traits=None, configures=None, include_models=False): +def list_workflows( + client, shape=None, traits=None, configures=None, include_models=False +): """Workflow names the server can reach, in the compact view: summary, shape, traits, cost, output kinds and variable names per workflow - what choosing one needs and nothing that reading one needs. Templates @@ -1151,7 +1354,9 @@ def list_workflows(client, shape=None, traits=None, configures=None, include_mod if shape: params["shape"] = shape if traits: - params["traits"] = ",".join(traits) if isinstance(traits, (list, tuple)) else traits + params["traits"] = ( + ",".join(traits) if isinstance(traits, (list, tuple)) else traits + ) if configures: params["configures"] = configures if include_models: @@ -1194,15 +1399,16 @@ Add `from typing import Optional` at the top of `dw_mcp/server.py` if it is not Instructions: replace the paragraph beginning `"Start from \`list_workflows\`: ..."` with: ```python - "Start from `list_workflows(shape=...)`: the server keeps a " - "large catalog, and its compact listing carries each " - "workflow's summary, shape, traits, cost and variable names - " - "run what is already there, with `arguments` overriding its " - "variables, rather than authoring a new workflow for a " - "request an existing one covers. Shapes: image, image-set, " - "image-edit, shot, sequence, audio, text, utility. Traits: " - "speech, chained, image-conditioned, identity-referenced, " - "needs-input-media, composes-workflows.\n" +"Start from `list_workflows(shape=...)`: the server keeps a" + +"large catalog, and its compact listing carries each " +"workflow's summary, shape, traits, cost and variable names - " +"run what is already there, with `arguments` overriding its " +"variables, rather than authoring a new workflow for a " +"request an existing one covers. Shapes: image, image-set, " +"image-edit, shot, sequence, audio, text, utility. Traits: " +"speech, chained, image-conditioned, identity-referenced, " +"needs-input-media, composes-workflows.\n" ``` and in the next paragraph change `"Decide which shape the deliverable is first, then match the catalog against that; "` to `"Decide which shape the deliverable is first, then call `list_workflows` with it; "`. @@ -1263,7 +1469,9 @@ Co-Authored-By: Claude Fable 5.1 " ```python def test_saving_reports_how_the_workflow_will_be_matched(server): with server(success_script) as client: - saved = client.put("/api/workflows/clip", json={"workflow": video_workflow("clip")}).json() + saved = client.put( + "/api/workflows/clip", json={"workflow": video_workflow("clip")} + ).json() assert saved["shape"] == "shot" assert saved["traits"] == ["speech"] assert saved["summary"] == "One clip from a prompt." @@ -1340,14 +1548,16 @@ Note the existing attribute `Job.workflow_name` holds the definition's **`id`** def test_a_job_remembers_the_catalog_name_it_ran_from(server, tmp_path): with server(success_script) as client: job = client.post("/api/jobs", json={"workflow_path": "Basic"}).json() - assert job["workflow"] == "basic" # the definition's id, as before - assert job["workflow_name"] == "Basic" # the catalog name + assert job["workflow"] == "basic" # the definition's id, as before + assert job["workflow_name"] == "Basic" # the catalog name wait_for_status(client, job["id"], TERMINAL_STATES) listed = {j["id"]: j for j in client.get("/api/jobs").json()["jobs"]} assert listed[job["id"]]["workflow_name"] == "Basic" - inline = client.post("/api/jobs", json={"workflow": valid_workflow("inline")}).json() + inline = client.post( + "/api/jobs", json={"workflow": valid_workflow("inline")} + ).json() assert inline["workflow_name"] is None wait_for_status(client, inline["id"], TERMINAL_STATES) @@ -1364,6 +1574,7 @@ def test_a_job_remembers_the_catalog_name_it_ran_from(server, tmp_path): def test_an_old_history_database_gains_the_column(tmp_path): import sqlite3 + db = tmp_path / "old.sqlite" with sqlite3.connect(db) as connection: connection.execute( @@ -1371,8 +1582,14 @@ def test_an_old_history_database_gains_the_column(tmp_path): " started_at REAL, finished_at REAL, arguments TEXT, spec TEXT, manifest TEXT," " warnings TEXT, error TEXT)" ) - connection.execute("INSERT INTO jobs (id, workflow, status) VALUES ('old1', 'sd', 'finished')") - manager = JobManager(str(tmp_path / "outputs"), worker_manager=ScriptedWorkerManager(success_script), history_path=str(db)) + connection.execute( + "INSERT INTO jobs (id, workflow, status) VALUES ('old1', 'sd', 'finished')" + ) + manager = JobManager( + str(tmp_path / "outputs"), + worker_manager=ScriptedWorkerManager(success_script), + history_path=str(db), + ) assert manager.get("old1")["workflow_name"] is None ``` @@ -1412,9 +1629,9 @@ Migration — after the `workspace` block in `JobHistory.__init__`: `dw/server/app.py` `submit_job` — add to the `manager.submit(` call: ```python - # The listing name, when the request came as one - what a - # later runtime-by-workflow report joins on - catalog_name=request.workflow_path if source else None, +# The listing name, when the request came as one - what a +# later runtime-by-workflow report joins on +catalog_name = (request.workflow_path if source else None,) ``` Check `rerun` passes `spec` through to `submit(**spec, ...)`; if it enumerates keyword arguments instead, add `catalog_name=spec.get("catalog_name")`. @@ -1460,13 +1677,31 @@ def load(path): EXPECTED_SHAPES = { "workflows/templates/text-to-image.json": ("image", []), "workflows/templates/minimax/storyboard.json": ("shot", ["identity-referenced"]), - "workflows/templates/minimax/dialogue-short.json": ("sequence", ["identity-referenced", "speech"]), - "workflows/templates/minimax/music-video.json": ("sequence", ["identity-referenced", "speech"]), - "workflows/templates/minimax/chained-segments.json": ("shot", ["chained", "image-conditioned", "needs-input-media", "speech"]), - "workflows/templates/ltx2/chained-segments.json": ("shot", ["chained", "image-conditioned", "needs-input-media", "speech"]), + "workflows/templates/minimax/dialogue-short.json": ( + "sequence", + ["identity-referenced", "speech"], + ), + "workflows/templates/minimax/music-video.json": ( + "sequence", + ["identity-referenced", "speech"], + ), + "workflows/templates/minimax/chained-segments.json": ( + "shot", + ["chained", "image-conditioned", "needs-input-media", "speech"], + ), + "workflows/templates/ltx2/chained-segments.json": ( + "shot", + ["chained", "image-conditioned", "needs-input-media", "speech"], + ), "workflows/templates/image-variation.json": ("image-edit", ["needs-input-media"]), - "workflows/templates/segment-and-inpaint.json": ("image-edit", ["needs-input-media"]), - "workflows/templates/describe-and-regenerate.json": ("image-set", ["composes-workflows", "needs-input-media"]), + "workflows/templates/segment-and-inpaint.json": ( + "image-edit", + ["needs-input-media"], + ), + "workflows/templates/describe-and-regenerate.json": ( + "image-set", + ["composes-workflows", "needs-input-media"], + ), "workflows/templates/compose-workflows.json": ("image-set", ["composes-workflows"]), "workflows/templates/generate-speech.json": ("audio", ["speech"]), "workflows/templates/assemble-and-score.json": ("sequence", ["needs-input-media"]), @@ -1495,9 +1730,13 @@ def test_the_rules_read_these_templates_as_expected(path, expected): def test_no_template_falls_through_to_utility(path): meta = derive_catalog_metadata(load(path)) if meta["shape"] == "utility": - assert path in UTILITIES, f"{path} derived 'utility' - a rule missed it, or add it to UTILITIES" + assert path in UTILITIES, ( + f"{path} derived 'utility' - a rule missed it, or add it to UTILITIES" + ) else: - assert path not in UTILITIES, f"{path} is listed as a utility but derives {meta['shape']}" + assert path not in UTILITIES, ( + f"{path} is listed as a utility but derives {meta['shape']}" + ) @pytest.mark.parametrize("path", TEMPLATES + MODEL_CONFIGS) @@ -1506,10 +1745,14 @@ def test_a_declaration_must_differ_from_the_derivation(path): rules or the file change. Declare only what derivation gets wrong.""" definition = load(path) meta = derive_catalog_metadata(definition) - stripped = {k: v for k, v in definition.items() if k not in ("shape", "traits", "summary")} + stripped = { + k: v for k, v in definition.items() if k not in ("shape", "traits", "summary") + } derived = derive_catalog_metadata(stripped) for key in meta["declared"]: - assert meta[key] != derived[key], f"{path} declares {key}={meta[key]!r}, which derivation already produces" + assert meta[key] != derived[key], ( + f"{path} declares {key}={meta[key]!r}, which derivation already produces" + ) @pytest.mark.parametrize("path", TEMPLATES) @@ -1580,7 +1823,9 @@ def test_workflow_ids_are_unique_across_the_catalog(): for path in TEMPLATES + MODEL_CONFIGS + BUILTINS: identity = load(path).get("id") assert identity, f"{path} has no id" - assert identity not in seen, f"{path} and {seen[identity]} share id {identity!r}" + assert identity not in seen, ( + f"{path} and {seen[identity]} share id {identity!r}" + ) seen[identity] = path @@ -1589,11 +1834,17 @@ def test_a_declared_cost_is_well_formed(path): cost = load(path).get("cost") if cost is None: return - assert isinstance(cost, list) and cost, f"{path}: cost must be a non-empty list or absent" + assert isinstance(cost, list) and cost, ( + f"{path}: cost must be a non-empty list or absent" + ) for entry in cost: assert entry["device"] in ("cuda", "mps", "cpu"), path - assert isinstance(entry["vram_gb"], (int, float)) and entry["vram_gb"] >= 0, path - assert isinstance(entry["minutes"], (int, float)) and entry["minutes"] >= 0, path + assert isinstance(entry["vram_gb"], (int, float)) and entry["vram_gb"] >= 0, ( + path + ) + assert isinstance(entry["minutes"], (int, float)) and entry["minutes"] >= 0, ( + path + ) BACKTICKED = re.compile(r"`([a-z_][a-z0-9_]*)`") @@ -1617,7 +1868,9 @@ def test_a_description_names_only_variables_the_workflow_declares(path): catalog_variables = _variable_names_in_catalog() mentioned = set(BACKTICKED.findall(definition.get("description", ""))) undeclared = (mentioned & catalog_variables) - declared - assert not undeclared, f"{path} describes {sorted(undeclared)} but declares no such variable" + assert not undeclared, ( + f"{path} describes {sorted(undeclared)} but declares no such variable" + ) ``` - [ ] **Step 2: Run and resolve** @@ -1669,15 +1922,21 @@ def _tokens(payload): def test_the_compact_listing_fits_the_budget(): - found = listing([WorkflowSource(os.path.join(REPO_ROOT, "workflows"), "workspace", True)]) + found = listing( + [WorkflowSource(os.path.join(REPO_ROOT, "workflows"), "workspace", True)] + ) details = workflow_details(found) compact = project_listing(details, view="compact") - assert _tokens(compact) <= COMPACT_BUDGET, f"compact listing is {_tokens(compact):.0f} tokens" + assert _tokens(compact) <= COMPACT_BUDGET, ( + f"compact listing is {_tokens(compact):.0f} tokens" + ) sequences = project_listing(details, view="compact", shape="sequence") assert sequences, "no template derives 'sequence'" - assert _tokens(sequences) <= FILTERED_BUDGET, f"shape=sequence is {_tokens(sequences):.0f} tokens" + assert _tokens(sequences) <= FILTERED_BUDGET, ( + f"shape=sequence is {_tokens(sequences):.0f} tokens" + ) ``` - [ ] **Step 2: Run** diff --git a/docs/superpowers/plans/2026-09-06-workflow-catalog-restructure.md b/docs/superpowers/plans/2026-09-06-workflow-catalog-restructure.md index b8d833e3..c290be38 100644 --- a/docs/superpowers/plans/2026-09-06-workflow-catalog-restructure.md +++ b/docs/superpowers/plans/2026-09-06-workflow-catalog-restructure.md @@ -381,9 +381,7 @@ def test_every_model_config_names_the_template_it_configures(path): definition = json.load(open(path, encoding="utf-8")) configures = definition.get("configures", "") - assert configures, ( - f"{os.path.relpath(path, REPO_ROOT)} has no 'configures'" - ) + assert configures, f"{os.path.relpath(path, REPO_ROOT)} has no 'configures'" target = os.path.join(REPO_ROOT, "workflows", f"{configures}.json") assert os.path.isfile(target), ( f"{os.path.relpath(path, REPO_ROOT)} configures '{configures}', " diff --git a/docs/superpowers/plans/2026-09-07-dw-plugin-skills.md b/docs/superpowers/plans/2026-09-07-dw-plugin-skills.md index 4d7563eb..c81266ef 100644 --- a/docs/superpowers/plans/2026-09-07-dw-plugin-skills.md +++ b/docs/superpowers/plans/2026-09-07-dw-plugin-skills.md @@ -109,7 +109,12 @@ def _pyproject_version(): def test_the_marketplace_names_the_plugin(): import json - manifest = json.load(open(os.path.join(REPO_ROOT, ".claude-plugin", "marketplace.json"), encoding="utf-8")) + manifest = json.load( + open( + os.path.join(REPO_ROOT, ".claude-plugin", "marketplace.json"), + encoding="utf-8", + ) + ) assert manifest["name"] == "diffusers-workflow" (plugin,) = manifest["plugins"] @@ -122,7 +127,11 @@ def test_the_plugin_version_is_the_engine_version(): this number, so the release script bumps both in one commit.""" import json - plugin = json.load(open(os.path.join(PLUGIN_DIR, ".claude-plugin", "plugin.json"), encoding="utf-8")) + plugin = json.load( + open( + os.path.join(PLUGIN_DIR, ".claude-plugin", "plugin.json"), encoding="utf-8" + ) + ) assert plugin["name"] == "dw" assert plugin["version"] == _pyproject_version() @@ -132,25 +141,35 @@ def test_there_are_skills(): assert SKILLS, "the plugin ships at least one skill" -@pytest.mark.parametrize("path", SKILLS, ids=lambda p: os.path.basename(os.path.dirname(p))) +@pytest.mark.parametrize( + "path", SKILLS, ids=lambda p: os.path.basename(os.path.dirname(p)) +) def test_a_skill_has_a_triggering_description_under_the_size_cap(path): text = skill_text(path) fields = frontmatter(text) assert fields["name"] == os.path.basename(os.path.dirname(path)) assert "description" in fields and len(fields["description"]) > 40 - assert len(text.encode("utf-8")) <= SKILL_SIZE_LIMIT, f"{path} is over {SKILL_SIZE_LIMIT} bytes" + assert len(text.encode("utf-8")) <= SKILL_SIZE_LIMIT, ( + f"{path} is over {SKILL_SIZE_LIMIT} bytes" + ) -@pytest.mark.parametrize("path", SKILLS, ids=lambda p: os.path.basename(os.path.dirname(p))) +@pytest.mark.parametrize( + "path", SKILLS, ids=lambda p: os.path.basename(os.path.dirname(p)) +) def test_every_catalog_name_a_skill_quotes_resolves(path): """A renamed template fails here rather than in a cold session.""" names = CATALOG_NAME.findall(skill_text(path)) assert names, f"{path} quotes no catalog names" for name in names: - target = os.path.join(REPO_ROOT, "workflows", name.removesuffix(".json") + ".json") - assert os.path.isfile(target), f"{path} quotes {name}, which is not a workflow ({target})" + target = os.path.join( + REPO_ROOT, "workflows", name.removesuffix(".json") + ".json" + ) + assert os.path.isfile(target), ( + f"{path} quotes {name}, which is not a workflow ({target})" + ) ``` - [ ] **Step 2: Run it to see it fail** @@ -314,7 +333,10 @@ class TestMiniMaxH3Skill: def test_the_frame_rule_and_bounds_are_the_pipeline_s(self): import inspect - from diffusers.modular_pipelines.minimax_h3 import before_encoder, modular_pipeline + from diffusers.modular_pipelines.minimax_h3 import ( + before_encoder, + modular_pipeline, + ) text = skill_text(H3_SKILL) assert "17n + 5" in text or "17 * n + 5" in text @@ -323,12 +345,18 @@ class TestMiniMaxH3Skill: # 124 and 345 are the smallest and largest 17n + 5 inside 5 to 15 seconds at 24 fps assert "124" in text and "345" in text assert 124 == 17 * 7 + 5 and 345 == 17 * 20 + 5 - assert 124 / modular_pipeline.MINIMAX_H3_FPS >= 5 and 345 / modular_pipeline.MINIMAX_H3_FPS <= 15 + assert ( + 124 / modular_pipeline.MINIMAX_H3_FPS >= 5 + and 345 / modular_pipeline.MINIMAX_H3_FPS <= 15 + ) def test_the_canvas_rules_are_the_pipeline_s(self): import inspect - from diffusers.modular_pipelines.minimax_h3 import before_encoder, modular_pipeline + from diffusers.modular_pipelines.minimax_h3 import ( + before_encoder, + modular_pipeline, + ) text = skill_text(H3_SKILL) source = inspect.getsource(before_encoder) @@ -548,7 +576,10 @@ class TestLtx25Skill: and the one vendor text it quotes is the library's own constant.""" def test_the_schedule_is_the_library_s(self): - from diffusers.pipelines.ltx2.utils import DISTILLED_SIGMA_VALUES, STAGE_2_DISTILLED_SIGMA_VALUES + from diffusers.pipelines.ltx2.utils import ( + DISTILLED_SIGMA_VALUES, + STAGE_2_DISTILLED_SIGMA_VALUES, + ) text = skill_text(LTX_SKILL) assert len(DISTILLED_SIGMA_VALUES) == 8 and "eight" in text @@ -573,9 +604,13 @@ class TestLtx25Skill: ships it so it cannot drift.""" from diffusers.pipelines.ltx2.utils import LTX2_5_T2V_DEFAULT_SYSTEM_PROMPT - quoted = _fenced_block_after(skill_text(LTX_SKILL), "## The trained caption spec") + quoted = _fenced_block_after( + skill_text(LTX_SKILL), "## The trained caption spec" + ) - assert " ".join(quoted.split()) == " ".join(LTX2_5_T2V_DEFAULT_SYSTEM_PROMPT.split()) + assert " ".join(quoted.split()) == " ".join( + LTX2_5_T2V_DEFAULT_SYSTEM_PROMPT.split() + ) def test_the_skill_starts_with_the_server(self): text = skill_text(LTX_SKILL) diff --git a/docs/superpowers/plans/2026-09-07-guides-and-validation.md b/docs/superpowers/plans/2026-09-07-guides-and-validation.md index 43b923d1..ff5b4b3f 100644 --- a/docs/superpowers/plans/2026-09-07-guides-and-validation.md +++ b/docs/superpowers/plans/2026-09-07-guides-and-validation.md @@ -116,9 +116,7 @@ class TestListing: def test_each_guide_carries_a_summary_and_its_sections(self, checkout): # The summary is what an agent matches a request against; the # section headings are the routing table - tasks = next( - g for g in guides.list_guides()["guides"] if g["name"] == "tasks" - ) + tasks = next(g for g in guides.list_guides()["guides"] if g["name"] == "tasks") assert tasks["summary"].strip() assert tasks["file"] == "TASKS.md" @@ -207,9 +205,7 @@ class TestTheRealDocs: assert guides.read_guide(name).strip() def test_the_tasks_guide_indexes_speech_generation(self): - tasks = next( - g for g in guides.list_guides()["guides"] if g["name"] == "tasks" - ) + tasks = next(g for g in guides.list_guides()["guides"] if g["name"] == "tasks") assert "Speech Generation" in tasks["sections"] ``` @@ -479,26 +475,28 @@ from .guides import GuideError Directly after the `/api/schema` route (the `workflow_schema` function, ~line 1005), before `@app.post("/api/validate")`: ```python - # ------------------------------------------------------------ guides - - @app.get("/api/guides") - def list_guides(): - """The documentation that bears on choosing a capability: each - guide's name, what it covers, and its section headings. Served by - the engine rather than read from an MCP client's install, so the - guides an agent reads are the guides for the engine it drives.""" - return guides.list_guides() - - @app.get("/api/guides/{name}") - def get_guide(name: str, section: Optional[str] = None): - """One guide from /api/guides, whole or one section of it. A - section name is matched loosely - case and punctuation dropped - - so a heading copied approximately still resolves. An unknown name - or section is a 404 whose detail lists what exists.""" - try: - return guides.get_guide(name, section=section) - except GuideError as e: - raise HTTPException(status_code=404, detail=str(e)) +# ------------------------------------------------------------ guides + + +@app.get("/api/guides") +def list_guides(): + """The documentation that bears on choosing a capability: each + guide's name, what it covers, and its section headings. Served by + the engine rather than read from an MCP client's install, so the + guides an agent reads are the guides for the engine it drives.""" + return guides.list_guides() + + +@app.get("/api/guides/{name}") +def get_guide(name: str, section: Optional[str] = None): + """One guide from /api/guides, whole or one section of it. A + section name is matched loosely - case and punctuation dropped - + so a heading copied approximately still resolves. An unknown name + or section is a 404 whose detail lists what exists.""" + try: + return guides.get_guide(name, section=section) + except GuideError as e: + raise HTTPException(status_code=404, detail=str(e)) ``` A missing guide *file* (an install with no `dw/docs/`) raises `FileNotFoundError` and surfaces as a 500 — that is a broken install, not a client error, and the log carries the message. @@ -833,7 +831,10 @@ class TestEveryError: _status, message = validate_data(workflow, schema) assert len(errors) == 1 - assert message == f"Validation error at {errors[0]['path']}: {errors[0]['message']}" + assert ( + message + == f"Validation error at {errors[0]['path']}: {errors[0]['message']}" + ) def test_a_valid_definition_yields_no_errors(self): assert validate_data_all(_pipeline_step({}), load_schema("workflow")) == [] @@ -871,7 +872,9 @@ class TestFormatting: assert text == "Validation error at steps[0].seed: 'x' is not of type 'integer'" def test_one_root_error_has_no_location(self): - text = format_validation_errors([{"path": None, "message": "'steps' is a required property"}]) + text = format_validation_errors( + [{"path": None, "message": "'steps' is a required property"}] + ) assert text == "Validation error: 'steps' is a required property" @@ -892,7 +895,10 @@ class TestFormatting: assert text.count("Validation error") == 1 def test_a_capped_list_says_so(self): - errors = [{"path": f"steps[{i}]", "message": "bad"} for i in range(MAX_VALIDATION_ERRORS)] + errors = [ + {"path": f"steps[{i}]", "message": "bad"} + for i in range(MAX_VALIDATION_ERRORS) + ] text = format_validation_errors(errors) @@ -1059,12 +1065,14 @@ def test_validate_endpoint_lists_every_schema_error(server): In `tests/test_mcp_authoring.py`, change the scripted body in `test_validate_returns_an_invalid_verdict_rather_than_raising` to the new shape (the test's assertions stay): ```python - { - "valid": False, - "error": "Validation error: steps must not be empty", - "errors": [{"path": None, "message": "steps must not be empty"}], - "warnings": [], - }, +( + { + "valid": False, + "error": "Validation error: steps must not be empty", + "errors": [{"path": None, "message": "steps must not be empty"}], + "warnings": [], + }, +) ``` - [ ] **Step 2: Run the tests to verify they fail** @@ -1191,34 +1199,51 @@ Co-Authored-By: Claude Fable 5.1 " Append to `TestTheRealDocs` in `tests/test_server_guides.py`: ```python - def test_the_authoring_section_is_reachable_by_name(self): - guide = guides.get_guide("workflows", section="authoring-a-workflow-from-an-agent") - - assert guide["section"] == "Authoring a workflow from an agent" - - def test_the_authoring_section_names_every_reference_prefix(self): - """The prefixes the engine reserves are the ones the section has to - explain; a new prefix added to the engine fails here until it is - written up.""" - from dw.prompts import RESERVED_TEXT_PREFIXES - - content = guides.get_guide( - "workflows", section="Authoring a workflow from an agent" - )["content"] - - for prefix in RESERVED_TEXT_PREFIXES: - assert f"`{prefix}`" in content, prefix - - def test_the_authoring_section_states_the_cartesian_rule_and_the_loop(self): - content = guides.get_guide( - "workflows", section="Authoring a workflow from an agent" - )["content"] - - assert "cartesian" in content.lower() - for tool in ("validate_workflow", "save_workflow", "run_workflow", "wait_for_job", "get_output_image"): - assert f"`{tool}`" in content, tool - for shape in ("image", "image-set", "image-edit", "shot", "sequence", "audio", "text", "utility"): - assert f"`{shape}`" in content, shape +def test_the_authoring_section_is_reachable_by_name(self): + guide = guides.get_guide("workflows", section="authoring-a-workflow-from-an-agent") + + assert guide["section"] == "Authoring a workflow from an agent" + + +def test_the_authoring_section_names_every_reference_prefix(self): + """The prefixes the engine reserves are the ones the section has to + explain; a new prefix added to the engine fails here until it is + written up.""" + from dw.prompts import RESERVED_TEXT_PREFIXES + + content = guides.get_guide( + "workflows", section="Authoring a workflow from an agent" + )["content"] + + for prefix in RESERVED_TEXT_PREFIXES: + assert f"`{prefix}`" in content, prefix + + +def test_the_authoring_section_states_the_cartesian_rule_and_the_loop(self): + content = guides.get_guide( + "workflows", section="Authoring a workflow from an agent" + )["content"] + + assert "cartesian" in content.lower() + for tool in ( + "validate_workflow", + "save_workflow", + "run_workflow", + "wait_for_job", + "get_output_image", + ): + assert f"`{tool}`" in content, tool + for shape in ( + "image", + "image-set", + "image-edit", + "shot", + "sequence", + "audio", + "text", + "utility", + ): + assert f"`{shape}`" in content, shape ``` - [ ] **Step 2: Run the tests to verify they fail** diff --git a/docs/superpowers/plans/2026-09-07-ltx-h3-catalog-repair.md b/docs/superpowers/plans/2026-09-07-ltx-h3-catalog-repair.md index 7673e888..3a0859c9 100644 --- a/docs/superpowers/plans/2026-09-07-ltx-h3-catalog-repair.md +++ b/docs/superpowers/plans/2026-09-07-ltx-h3-catalog-repair.md @@ -81,7 +81,9 @@ class TestLatentHandoff: def _base_result(self): result = Result({"content_type": "video/mp4", "save": False}) result.add_result( - _LatentOutput(frames=torch.zeros(1, 128, 16, 14, 24), audio=torch.zeros(1, 8, 50, 16)) + _LatentOutput( + frames=torch.zeros(1, 128, 16, 14, 24), audio=torch.zeros(1, 8, 50, 16) + ) ) return result @@ -147,7 +149,9 @@ class TestLtxTwoStage: name, so the template carries the literal and this test ties it to the library.""" def _definition(self): - path = os.path.join(REPO_ROOT, "workflows", "templates", "ltx2", "two-stage.json") + path = os.path.join( + REPO_ROOT, "workflows", "templates", "ltx2", "two-stage.json" + ) return json.load(open(path, encoding="utf-8")) def test_the_renoise_scale_is_the_first_stage_two_sigma(self): @@ -155,13 +159,19 @@ class TestLtxTwoStage: refine = _step(self._definition(), "refine") - assert refine["pipeline"]["arguments"]["noise_scale"] == STAGE_2_DISTILLED_SIGMA_VALUES[0] + assert ( + refine["pipeline"]["arguments"]["noise_scale"] + == STAGE_2_DISTILLED_SIGMA_VALUES[0] + ) def test_the_refine_pass_runs_the_stage_two_schedule_on_the_upsampled_latents(self): refine = _step(self._definition(), "refine") arguments = refine["pipeline"]["arguments"] - assert arguments["sigmas"] == "constant:diffusers.pipelines.ltx2.utils.STAGE_2_DISTILLED_SIGMA_VALUES" + assert ( + arguments["sigmas"] + == "constant:diffusers.pipelines.ltx2.utils.STAGE_2_DISTILLED_SIGMA_VALUES" + ) assert arguments["latents"] == "previous_result:upscale.frames" assert arguments["audio_latents"] == "previous_result:base.audio" @@ -597,7 +607,9 @@ def test_a_prompt_is_one_paragraph_of_caption_length(path): assert "\n" not in text.strip(), f"{path} is more than one paragraph" words = len(text.split()) - assert 140 <= words <= 240, f"{path} is {words} words; the trained caption is 150-220" + assert 140 <= words <= 240, ( + f"{path} is {words} words; the trained caption is 150-220" + ) @pytest.mark.parametrize("path", PROMPTS, ids=os.path.basename) @@ -614,7 +626,9 @@ def test_a_prompt_names_the_model_it_is_for(path): def test_no_ltx_template_summary_names_the_older_model(): - templates = glob.glob(os.path.join(REPO_ROOT, "workflows", "templates", "ltx2", "*.json")) + templates = glob.glob( + os.path.join(REPO_ROOT, "workflows", "templates", "ltx2", "*.json") + ) for path in templates: summary = json.load(open(path, encoding="utf-8")).get("summary", "") assert "LTX-2 " not in summary and not summary.endswith("LTX-2"), path @@ -719,7 +733,10 @@ def test_silent_audio_fields_are_written_as_not_applicable(): prompt = _system_prompt() assert "N/A" in prompt - assert "overall_soundscape" in prompt[prompt.index("N/A") - 600 : prompt.index("N/A") + 600] + assert ( + "overall_soundscape" + in prompt[prompt.index("N/A") - 600 : prompt.index("N/A") + 600] + ) def test_video_and_audio_references_are_numbered_within_their_own_category(): @@ -868,7 +885,10 @@ Append to `tests/test_catalog_structure.py`: LINK_PATTERN = re.compile(r"\]\(([^)]+)\)") READMES = sorted( os.path.relpath(path, REPO_ROOT) - for path in glob.glob(os.path.join(REPO_ROOT, "workflows", "templates", "**", "README.md"), recursive=True) + for path in glob.glob( + os.path.join(REPO_ROOT, "workflows", "templates", "**", "README.md"), + recursive=True, + ) ) @@ -884,7 +904,9 @@ def test_every_readme_link_resolves(path): if target.startswith(("http://", "https://", "#")): continue target = target.split("#", 1)[0] - assert os.path.exists(os.path.join(base, target)), f"{path} links to {target}, which does not exist" + assert os.path.exists(os.path.join(base, target)), ( + f"{path} links to {target}, which does not exist" + ) ``` and add `import glob` to the file's imports. diff --git a/docs/superpowers/plans/2026-09-08-job-record-and-export.md b/docs/superpowers/plans/2026-09-08-job-record-and-export.md index f1aa086f..694339df 100644 --- a/docs/superpowers/plans/2026-09-08-job-record-and-export.md +++ b/docs/superpowers/plans/2026-09-08-job-record-and-export.md @@ -149,9 +149,7 @@ def output_root(tmp_path): class TestVariablesAndSeed: def test_arguments_become_the_variable_defaults(self): - realized, _ = realize_workflow( - definition(), {"prompt": "a cat", "steps": 4}, 7 - ) + realized, _ = realize_workflow(definition(), {"prompt": "a cat", "steps": 4}, 7) assert realized["variables"] == {"prompt": "a cat", "steps": 4} def test_variable_references_are_left_alone(self): @@ -309,9 +307,7 @@ def test_the_realized_file_validates_against_the_schema(prompt_library): source = definition() source["steps"][0]["pipeline"]["arguments"]["prompt"] = "prompt:scenic/dusk" - realized, _ = realize_workflow( - source, {"steps": 4}, 991, prompt_dir=prompt_library - ) + realized, _ = realize_workflow(source, {"steps": 4}, 991, prompt_dir=prompt_library) ok, message = validate_data(realized, load_schema("workflow")) assert ok, message @@ -408,9 +404,7 @@ def realize_workflow( realized["seed"] = seed realized = _pin(realized, annotations, base_dir, prompt_dir, output_root) - _record_sub_workflows( - realized.get("steps"), annotations, base_dir, workflow_dir - ) + _record_sub_workflows(realized.get("steps"), annotations, base_dir, workflow_dir) return realized, annotations @@ -435,8 +429,7 @@ def _pin(value, annotations, base_dir, prompt_dir, output_root): } if isinstance(value, list): return [ - _pin(item, annotations, base_dir, prompt_dir, output_root) - for item in value + _pin(item, annotations, base_dir, prompt_dir, output_root) for item in value ] return value @@ -738,27 +731,25 @@ Then, immediately after the `if not self._run_dir_inherited:` block that sets between steps`), insert: ```python - # The record of what actually ran, written before the first step - # so a crash or a cancel still leaves it. A sub-workflow inherits - # the parent's directory and writes none of its own, as with the - # manifest, and the flat layout has no directory to write into - if self._run_dir and not self._run_dir_inherited: - try: - realized, annotations = realize_workflow( - self.workflow_definition, - arguments, - default_seed, - base_dir=base_dir, - output_root=self.output_dir, - workflow_dir=self.workflow_dir, - ) - if write_realized_workflow(self._run_dir, realized): - realized_name = REALIZED_FILE_NAME - except Exception as e: - # Never fatal: the record is worth less than the run - logger.warning( - f"Could not realize workflow {workflow_id}: {e}" - ) +# The record of what actually ran, written before the first step +# so a crash or a cancel still leaves it. A sub-workflow inherits +# the parent's directory and writes none of its own, as with the +# manifest, and the flat layout has no directory to write into +if self._run_dir and not self._run_dir_inherited: + try: + realized, annotations = realize_workflow( + self.workflow_definition, + arguments, + default_seed, + base_dir=base_dir, + output_root=self.output_dir, + workflow_dir=self.workflow_dir, + ) + if write_realized_workflow(self._run_dir, realized): + realized_name = REALIZED_FILE_NAME + except Exception as e: + # Never fatal: the record is worth less than the run + logger.warning(f"Could not realize workflow {workflow_id}: {e}") ``` Note the arguments: `self.workflow_definition` (the original, before `run`'s @@ -1028,17 +1019,15 @@ In `dw/workflow.py`, immediately after the realization block added in Task 2 indentation as the `try:` it contains), add: ```python - # Which run this is, so a server job can find the directory - # it wrote. Emitted even when the realized file did not land: - # the manifest is still there, and so are the files - run_context.emit( - "run_start", - run_id=run_id, - identity=workflow_identity(self.file_spec, workflow_id), - run_dir=os.path.relpath( - self._run_dir, self.output_dir - ).replace(os.sep, "/"), - ) +# Which run this is, so a server job can find the directory +# it wrote. Emitted even when the realized file did not land: +# the manifest is still there, and so are the files +run_context.emit( + "run_start", + run_id=run_id, + identity=workflow_identity(self.file_spec, workflow_id), + run_dir=os.path.relpath(self._run_dir, self.output_dir).replace(os.sep, "/"), +) ``` The worker already forwards every emitted event as a `progress` message, so no @@ -1075,17 +1064,18 @@ In `JobHistory.__init__`, after the `workflow_name` migration: In `record`, extend the column list, the placeholders and the values tuple: ```python - "INSERT OR REPLACE INTO jobs (id, workflow, status, created_at," - " started_at, finished_at, arguments, spec, manifest, warnings," - " error, events, workspace, workflow_name, run_id, run_dir) VALUES" - " (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", +"INSERT OR REPLACE INTO jobs (id, workflow, status, created_at," + +" started_at, finished_at, arguments, spec, manifest, warnings," +" error, events, workspace, workflow_name, run_id, run_dir) VALUES" +(" (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",) ``` and, after `job.catalog_name` in the tuple: ```python - job.run_id, - job.run_dir, +(job.run_id,) +(job.run_dir,) ``` In `recent_summaries`, add `run_id` to the SELECT and the dict, so a live @@ -1107,9 +1097,10 @@ summary and a historical one keep the same shape: In `get`, extend the SELECT: ```python - "SELECT id, workflow, status, created_at, started_at, finished_at," - " arguments, spec, manifest, warnings, error, workspace," - " workflow_name, run_id, run_dir FROM jobs WHERE id = ?", +"SELECT id, workflow, status, created_at, started_at, finished_at," + +" arguments, spec, manifest, warnings, error, workspace," +(" workflow_name, run_id, run_dir FROM jobs WHERE id = ?",) ``` and in `_to_detail`, after `"workflow_name": row[12],`: @@ -1153,40 +1144,36 @@ branch, between building `event` and `job.add_event(event)`: And add `realized` beside `definition`: ```python - def realized(self, job_id): - """The realized workflow a job ran, or None when the job predates - run tracking or its run directory no longer holds the file. - - Read from the job's own output directory, not the manager's: one - server holds several workspaces, and a job carries the root it ran - against. The join is confined to that root, so a run_dir read back - out of the database cannot name anything outside it. - """ - job = self.jobs.get(job_id) - if job is not None: - run_dir = job.run_dir - output_dir = job.spec.get("output_dir") or self.output_dir - else: - historical = self.history.get(job_id) - if historical is None: - return None - run_dir = historical.get("run_dir") - output_dir = ( - historical.get("spec") or {} - ).get("output_dir") or self.output_dir - if not run_dir: - return None - try: - root = validate_output_path(output_dir, None) - path = validate_path( - os.path.join(root, run_dir, REALIZED_FILE_NAME), root - ) - validate_json_size(path) - with open(path, "r") as file: - return json.load(file) - except (SecurityError, OSError, ValueError) as e: - logger.debug(f"No realized workflow for job {job_id}: {e}") +def realized(self, job_id): + """The realized workflow a job ran, or None when the job predates + run tracking or its run directory no longer holds the file. + + Read from the job's own output directory, not the manager's: one + server holds several workspaces, and a job carries the root it ran + against. The join is confined to that root, so a run_dir read back + out of the database cannot name anything outside it. + """ + job = self.jobs.get(job_id) + if job is not None: + run_dir = job.run_dir + output_dir = job.spec.get("output_dir") or self.output_dir + else: + historical = self.history.get(job_id) + if historical is None: return None + run_dir = historical.get("run_dir") + output_dir = (historical.get("spec") or {}).get("output_dir") or self.output_dir + if not run_dir: + return None + try: + root = validate_output_path(output_dir, None) + path = validate_path(os.path.join(root, run_dir, REALIZED_FILE_NAME), root) + validate_json_size(path) + with open(path, "r") as file: + return json.load(file) + except (SecurityError, OSError, ValueError) as e: + logger.debug(f"No realized workflow for job {job_id}: {e}") + return None ``` - [ ] **Step 5: Add the flag to the route** @@ -1510,9 +1497,7 @@ def exporting_script(command): "type": "success", "message": "ok", "run_count": 1, - "manifest": [ - {"step": "gen", "files": [os.path.join(run_dir, "still.png")]} - ], + "manifest": [{"step": "gen", "files": [os.path.join(run_dir, "still.png")]}], } @@ -1577,9 +1562,7 @@ class TestExportDirectory: job_id = finished(client) body = client.post(f"/api/jobs/{job_id}/export").json() - recorded = json.loads( - open(os.path.join(body["directory"], "job.json")).read() - ) + recorded = json.loads(open(os.path.join(body["directory"], "job.json")).read()) assert recorded["realized"] is True assert "traceback" not in recorded and "event_count" not in recorded assert body["workflow"]["seed"] == 7 @@ -1659,7 +1642,9 @@ class TestReservedName: with server() as client: job_id = finished(client) client.post(f"/api/jobs/{job_id}/export") - names = [w["name"] for w in client.get("/api/workspaces").json()["workspaces"]] + names = [ + w["name"] for w in client.get("/api/workspaces").json()["workspaces"] + ] assert names == ["default"] ``` @@ -2213,46 +2198,44 @@ Add beside the `/outputs` route (ungated for the same reason it is: the auth middleware only gates `/api/`): ```python - @app.get("/exports/{job_id}.zip") - def export_zip(job_id: str, ws: Workspace = Depends(selected_workspace)): - """One job's export as a zip, built on request from the directory - rather than kept as a second copy. Entries are named - '/', so unzipping anywhere gives the same tree - the server holds.""" - try: - directory = export_directory(ws.root, job_id) - except SecurityError: - raise HTTPException(status_code=404, detail="No export for this job") - if not os.path.isdir(directory): - raise HTTPException(status_code=404, detail="No export for this job") - - handle = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) - handle.close() - with zipfile.ZipFile(handle.name, "w", zipfile.ZIP_DEFLATED) as archive: - for current, _dirs, names in os.walk(directory): - for name in sorted(names): - path = os.path.join(current, name) - entry = os.path.relpath(path, directory).replace(os.sep, "/") - archive.write(path, f"{job_id}/{entry}") - - def stream(): - with open(handle.name, "rb") as file: - while True: - chunk = file.read(64 * 1024) - if not chunk: - return - yield chunk - - return StreamingResponse( - stream(), - media_type="application/zip", - headers={ - "content-disposition": f'attachment; filename="{job_id}.zip"' - }, - # The archive is a temp file, not a second permanent copy - it - # goes as soon as the response has been sent - background=BackgroundTask(os.unlink, handle.name), - ) +@app.get("/exports/{job_id}.zip") +def export_zip(job_id: str, ws: Workspace = Depends(selected_workspace)): + """One job's export as a zip, built on request from the directory + rather than kept as a second copy. Entries are named + '/', so unzipping anywhere gives the same tree + the server holds.""" + try: + directory = export_directory(ws.root, job_id) + except SecurityError: + raise HTTPException(status_code=404, detail="No export for this job") + if not os.path.isdir(directory): + raise HTTPException(status_code=404, detail="No export for this job") + + handle = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) + handle.close() + with zipfile.ZipFile(handle.name, "w", zipfile.ZIP_DEFLATED) as archive: + for current, _dirs, names in os.walk(directory): + for name in sorted(names): + path = os.path.join(current, name) + entry = os.path.relpath(path, directory).replace(os.sep, "/") + archive.write(path, f"{job_id}/{entry}") + + def stream(): + with open(handle.name, "rb") as file: + while True: + chunk = file.read(64 * 1024) + if not chunk: + return + yield chunk + + return StreamingResponse( + stream(), + media_type="application/zip", + headers={"content-disposition": f'attachment; filename="{job_id}.zip"'}, + # The archive is a temp file, not a second permanent copy - it + # goes as soon as the response has been sent + background=BackgroundTask(os.unlink, handle.name), + ) ``` - [ ] **Step 6: Run the tests to verify they pass** @@ -2355,9 +2338,7 @@ def scripted(routes): def exporting(status=201, body=None): - return scripted( - {("POST", "/api/jobs/job-1/export"): (status, body or SUMMARY)} - ) + return scripted({("POST", "/api/jobs/job-1/export"): (status, body or SUMMARY)}) def test_it_returns_the_directory_the_zip_and_the_file_list(): diff --git a/docs/superpowers/plans/2026-09-10-mcp-field-report-followups.md b/docs/superpowers/plans/2026-09-10-mcp-field-report-followups.md index 5e21c69c..78f52638 100644 --- a/docs/superpowers/plans/2026-09-10-mcp-field-report-followups.md +++ b/docs/superpowers/plans/2026-09-10-mcp-field-report-followups.md @@ -338,19 +338,20 @@ git commit -m "run_workflow/validate_workflow: pin one call to a named workspace In `tests/test_mcp_workspaces.py` `TestLifecycle`, add beside `test_creating_one_does_not_switch_to_it`: ```python - def test_creating_one_says_it_did_not_switch(self): - """A create-then-run sequence landed a five-shot job in the wrong - workspace; the result now says where the session still is.""" - client, _ = recording({"name": "shots"}) - result = create_workspace(client, "shots") - assert result["current"] == DEFAULT_WORKSPACE - assert "use_workspace" in result["next"] - - def test_creating_with_use_switches_to_it(self): - client, seen = recording({"name": "shots"}) - result = create_workspace(client, "shots", use=True) - assert client.workspace == "shots" - assert result["current"] == "shots" +def test_creating_one_says_it_did_not_switch(self): + """A create-then-run sequence landed a five-shot job in the wrong + workspace; the result now says where the session still is.""" + client, _ = recording({"name": "shots"}) + result = create_workspace(client, "shots") + assert result["current"] == DEFAULT_WORKSPACE + assert "use_workspace" in result["next"] + + +def test_creating_with_use_switches_to_it(self): + client, seen = recording({"name": "shots"}) + result = create_workspace(client, "shots", use=True) + assert client.workspace == "shots" + assert result["current"] == "shots" ``` `recording` scripts one response for every request; `use_workspace` GETs the listing and checks the name is in it. If the recorded body `{"name": "shots"}` makes `use_workspace` raise "No workspace named", script the listing instead: look at how `test_deleting_the_current_one_falls_back_to_the_default` uses `listing("default", "shots")` and use that body for the `use=True` test. @@ -791,26 +792,24 @@ Expected: FAIL with `KeyError: 'media'`. In `dw/server/app.py`, import `from dw.media_info import probe_media` beside the other `dw.` imports, and change `gallery_metadata`: ```python - @app.get("/api/gallery/{name:path}/metadata") - def gallery_metadata(name: str, ws: Workspace = Depends(selected_workspace)): - """Generation metadata embedded in a saved image ('workflow' inside - it is the full definition the editor can reopen), plus the job that - produced the file when history remembers one, plus - for audio and - video - what the file itself holds: duration, format and level, - which is how an agent that cannot listen checks a track.""" - path = _output_file(name, ws.outputs) - metadata = read_embedded_metadata(path) - try: - job = manager.history.job_for_file(name, workspace=ws.name) - except Exception: - job = None - extension = os.path.splitext(path)[1].lower() - media = ( - probe_media(path) - if MEDIA_KINDS.get(extension) in ("audio", "video") - else None - ) - return {"name": name, "metadata": metadata, "job": job, "media": media} +@app.get("/api/gallery/{name:path}/metadata") +def gallery_metadata(name: str, ws: Workspace = Depends(selected_workspace)): + """Generation metadata embedded in a saved image ('workflow' inside + it is the full definition the editor can reopen), plus the job that + produced the file when history remembers one, plus - for audio and + video - what the file itself holds: duration, format and level, + which is how an agent that cannot listen checks a track.""" + path = _output_file(name, ws.outputs) + metadata = read_embedded_metadata(path) + try: + job = manager.history.job_for_file(name, workspace=ws.name) + except Exception: + job = None + extension = os.path.splitext(path)[1].lower() + media = ( + probe_media(path) if MEDIA_KINDS.get(extension) in ("audio", "video") else None + ) + return {"name": name, "metadata": metadata, "job": job, "media": media} ``` Keep the existing "Scoped to this workspace" comment above the `job_for_file` call. diff --git a/docs/superpowers/plans/2026-09-11-list-driven-stage-3.md b/docs/superpowers/plans/2026-09-11-list-driven-stage-3.md index 25708a89..0b54d3ab 100644 --- a/docs/superpowers/plans/2026-09-11-list-driven-stage-3.md +++ b/docs/superpowers/plans/2026-09-11-list-driven-stage-3.md @@ -75,17 +75,25 @@ class TestListFields: def test_a_bare_item_means_the_entry_is_a_value(self): fields = list_fields( definition( - {"name": "say", "for_each": "variable:lines", "task": {"arguments": {"text": "item:"}}} + { + "name": "say", + "for_each": "variable:lines", + "task": {"arguments": {"text": "item:"}}, + } ) ) assert fields == {"lines": {"fields": None, "steps": ["say"]}} def test_a_literal_list_is_not_an_argument(self): - assert list_fields(definition({"name": "s", "for_each": ["a"], "task": {}})) == {} + assert ( + list_fields(definition({"name": "s", "for_each": ["a"], "task": {}})) == {} + ) def test_a_step_reading_no_field_still_lists_name(self): fields = list_fields( - definition({"name": "s", "for_each": "variable:xs", "task": {"arguments": {}}}) + definition( + {"name": "s", "for_each": "variable:xs", "task": {"arguments": {}}} + ) ) assert fields == {"xs": {"fields": ["name"], "steps": ["s"]}} @@ -192,7 +200,9 @@ def for_each_step(name, variable, arguments): def test_lists_name_the_fields_an_entry_takes_and_the_default_length(): meta = derive_catalog_metadata( definition( - for_each_step("shot", "shots", {"prompt": "item:prompt", "n": "item:num_frames"}), + for_each_step( + "shot", "shots", {"prompt": "item:prompt", "n": "item:num_frames"} + ), variables={"shots": [{"name": "a", "prompt": "p", "num_frames": 1}] * 3}, ) ) @@ -206,7 +216,10 @@ def test_lists_name_the_fields_an_entry_takes_and_the_default_length(): def test_lists_is_empty_without_for_each_and_entries_is_none_without_a_list_default(): - assert derive_catalog_metadata(definition(pipeline_step("g", "image/jpeg")))["lists"] == {} + assert ( + derive_catalog_metadata(definition(pipeline_step("g", "image/jpeg")))["lists"] + == {} + ) meta = derive_catalog_metadata( definition(for_each_step("shot", "shots", {"prompt": "item:prompt"})) ) @@ -218,7 +231,9 @@ def test_compact_carries_lists_only_when_there_are_any(): "plain": entry("image"), "cut": entry( "sequence", - lists={"shots": {"fields": ["name", "prompt"], "steps": ["shot"], "entries": 2}}, + lists={ + "shots": {"fields": ["name", "prompt"], "steps": ["shot"], "entries": 2} + }, ), } compact = project_listing(listing, view="compact") @@ -240,7 +255,9 @@ def test_workflow_details_describe_their_lists(server): "cut.json", { "id": "cut", - "variables": {"shots": [{"name": "a", "prompt": "p"}, {"name": "b", "prompt": "q"}]}, + "variables": { + "shots": [{"name": "a", "prompt": "p"}, {"name": "b", "prompt": "q"}] + }, "steps": [ { "name": "shot", @@ -355,7 +372,9 @@ class TestEntryFieldWarnings: def test_a_caller_s_list_is_reported_under_arguments(self): warnings = entry_field_warnings( self.workflow(), - arguments={"shots": [{"name": "a", "prompt": "p", "num_frames": 1, "note": "x"}]}, + arguments={ + "shots": [{"name": "a", "prompt": "p", "num_frames": 1, "note": "x"}] + }, ) assert warnings == [ "arguments.shots[0]: entry 'a' carries 'note', which no step reads; " @@ -368,7 +387,11 @@ class TestEntryFieldWarnings: def test_value_entries_and_clean_entries_warn_about_nothing(self): assert entry_field_warnings(self.workflow()) == [] w = definition( - {"name": "say", "for_each": "variable:lines", "task": {"arguments": {"t": "item:"}}}, + { + "name": "say", + "for_each": "variable:lines", + "task": {"arguments": {"t": "item:"}}, + }, variables={"lines": ["a", "b"]}, ) assert entry_field_warnings(w) == [] @@ -458,11 +481,17 @@ def test_an_entry_key_no_step_reads_is_a_warning_not_an_error(client_and_workspa } response = client.post( "/api/validate", - json={"workflow": workflow, "arguments": {"shots": [{"name": "a", "text": "p", "txt": "q"}]}}, + json={ + "workflow": workflow, + "arguments": {"shots": [{"name": "a", "text": "p", "txt": "q"}]}, + }, ) body = response.json() assert body["valid"] is True - assert any(w.startswith("arguments.shots[0]: entry 'a' carries 'txt'") for w in body["warnings"]) + assert any( + w.startswith("arguments.shots[0]: entry 'a' carries 'txt'") + for w in body["warnings"] + ) ``` Use whatever `task` command the file's other tests use for a no-op step so the schema and the task registry accept it. @@ -523,12 +552,24 @@ def test_a_per_entry_cost_validates_and_a_partial_one_does_not(): base = definition(pipeline_step("g", "image/jpeg")) cost = {"device": "cuda", "vram_gb": 24, "minutes": 42} ok, _ = validate_data( - {**base, "cost": [{**cost, "per_entry": {"variable": "shots", "minutes": 7.2, "entries": 5}}]}, + { + **base, + "cost": [ + { + **cost, + "per_entry": {"variable": "shots", "minutes": 7.2, "entries": 5}, + } + ], + }, schema, ) assert ok bad, message = validate_data( - {**base, "cost": [{**cost, "per_entry": {"variable": "shots", "minutes": 7.2}}]}, schema + { + **base, + "cost": [{**cost, "per_entry": {"variable": "shots", "minutes": 7.2}}], + }, + schema, ) assert not bad and "per_entry" in message ``` @@ -550,7 +591,9 @@ def test_a_per_entry_cost_names_a_list_the_steps_read(path): if per_entry is None: continue lists = list_fields(definition) - assert per_entry["variable"] in lists, f"{path}: {per_entry['variable']} is not a for_each list" + assert per_entry["variable"] in lists, ( + f"{path}: {per_entry['variable']} is not a for_each list" + ) default = (definition.get("variables") or {}).get(per_entry["variable"]) assert isinstance(default, list) and len(default) == per_entry["entries"], path ``` @@ -595,20 +638,21 @@ def test_workflow_variables_preview_inside_list_entries(server): `dw/server/app.py` `get_workflow_variables`: replace the loop with a recursive preview: ```python - def preview(value, path): - if isinstance(value, str) and len(value) > VARIABLE_VALUE_PREVIEW: - truncated.append(path) - return value[:VARIABLE_VALUE_PREVIEW] - if isinstance(value, list): - return [preview(item, f"{path}[{i}]") for i, item in enumerate(value)] - if isinstance(value, dict): - return {key: preview(item, f"{path}.{key}") for key, item in value.items()} - return value - - variables = definition.get("variables") or {} - values, truncated = {}, [] - for variable, value in variables.items(): - values[variable] = value if full else preview(value, variable) +def preview(value, path): + if isinstance(value, str) and len(value) > VARIABLE_VALUE_PREVIEW: + truncated.append(path) + return value[:VARIABLE_VALUE_PREVIEW] + if isinstance(value, list): + return [preview(item, f"{path}[{i}]") for i, item in enumerate(value)] + if isinstance(value, dict): + return {key: preview(item, f"{path}.{key}") for key, item in value.items()} + return value + + +variables = definition.get("variables") or {} +values, truncated = {}, [] +for variable, value in variables.items(): + values[variable] = value if full else preview(value, variable) ``` Update the docstring: "Long strings - a shot's prompt runs to kilobytes, and a list-driven workflow's default list holds several - are cut to their first 200 characters wherever they sit and named in `truncated` (`shots[0].prompt`)". @@ -659,7 +703,10 @@ def test_validation_realizes_a_constant_default_list(tmp_path): definition["variables"]["shots"] = "constant:tests.test_workflow.CONSTANT_SHOTS" workflow = _workflow_from(definition, tmp_path) assert workflow.validation_errors() == [] - assert [s["name"] for s in workflow.expanded_definition()["steps"]][:2] == ["shot@a", "shot@b"] + assert [s["name"] for s in workflow.expanded_definition()["steps"]][:2] == [ + "shot@a", + "shot@b", + ] ``` with `CONSTANT_SHOTS = [{"name": "a", "text": "1"}, {"name": "b", "text": "2"}]` at module level (match `_for_each_workflow`'s entry field name). `validate_constant_name` (`dw/security.py`) must accept a `tests.` module path — check its rules; if it refuses, put the constant on a module it accepts and say which. diff --git a/docs/superpowers/plans/2026-09-11-list-driven-steps.md b/docs/superpowers/plans/2026-09-11-list-driven-steps.md index e92eb789..f13cc3e8 100644 --- a/docs/superpowers/plans/2026-09-11-list-driven-steps.md +++ b/docs/superpowers/plans/2026-09-11-list-driven-steps.md @@ -456,7 +456,8 @@ def _entry_keys(entries, path): else: hint = "" raise ForEachError( - _render_path(path), f"for_each must be a list, got {type(entries).__name__}{hint}" + _render_path(path), + f"for_each must be a list, got {type(entries).__name__}{hint}", ) if len(entries) > MAX_FOR_EACH_ENTRIES: raise ForEachError( @@ -707,7 +708,10 @@ class TestGather: self.group( { "name": "score", - "workflow": {"path": "builtin:x.json", "arguments": {"clips": "gather:shot"}}, + "workflow": { + "path": "builtin:x.json", + "arguments": {"clips": "gather:shot"}, + }, } ) ) @@ -784,7 +788,9 @@ class TestGroupReferences: definition( { "name": "edit", - "task": {"arguments": {"r": [{"from_previous_result": "variable:x"}]}}, + "task": { + "arguments": {"r": [{"from_previous_result": "variable:x"}]} + }, } ) ) @@ -956,8 +962,16 @@ class TestMusicVideoTemplate: template = load_template("music-video.json") today = steps_by_name(template) shots = [ - {"name": "wide_open", "prompt": "variable:shot_1_wide_open", "start_frame": 0}, - {"name": "closeup", "prompt": "variable:shot_2_closeup", "start_frame": 124}, + { + "name": "wide_open", + "prompt": "variable:shot_1_wide_open", + "start_frame": 0, + }, + { + "name": "closeup", + "prompt": "variable:shot_2_closeup", + "start_frame": 124, + }, {"name": "room", "prompt": "variable:shot_3_room", "start_frame": 248}, {"name": "finale", "prompt": "variable:shot_4_finale", "start_frame": 372}, ] @@ -979,8 +993,13 @@ class TestMusicVideoTemplate: expanded = expand_for_each( definition( - today["draw_singer"], today["write_song"], slice_template, - today["soundtrack"], shot_template, edit, today["music_video"], + today["draw_singer"], + today["write_song"], + slice_template, + today["soundtrack"], + shot_template, + edit, + today["music_video"], ) ) got = steps_by_name(expanded) @@ -993,8 +1012,15 @@ class TestMusicVideoTemplate: # Each expanded shot is today's shot (as a full pipeline block) with # the new name and its slice renamed - hand_written = ["shot_1_wide_open", "shot_2_closeup", "shot_3_room", "shot_4_finale"] - for key, old, index in zip(["wide_open", "closeup", "room", "finale"], hand_written, range(1, 5)): + hand_written = [ + "shot_1_wide_open", + "shot_2_closeup", + "shot_3_room", + "shot_4_finale", + ] + for key, old, index in zip( + ["wide_open", "closeup", "room", "finale"], hand_written, range(1, 5) + ): step = today[old] if "pipeline_reference" in step: step = without_pipeline_reference(step, today["shot_1_wide_open"]) @@ -1006,7 +1032,8 @@ class TestMusicVideoTemplate: assert got[f"shot@{key}"] == expected assert got["edit"]["task"]["arguments"]["videos"] == [ - f"previous_result:shot@{k}" for k in ["wide_open", "closeup", "room", "finale"] + f"previous_result:shot@{k}" + for k in ["wide_open", "closeup", "room", "finale"] ] ``` @@ -1040,16 +1067,27 @@ class TestDialogueShortTemplate: ] first = today["shot_1_cold_open"] full = { - old: (step if "pipeline_reference" not in step else without_pipeline_reference(step, first)) + old: ( + step + if "pipeline_reference" not in step + else without_pipeline_reference(step, first) + ) for old, step in today.items() if old.startswith("shot_") } shots = [] for key, old in hand_written: arguments = full[old]["pipeline"]["arguments"] - entry = {"name": key, "prompt": arguments["prompt"], "references": arguments["references"]} + entry = { + "name": key, + "prompt": arguments["prompt"], + "references": arguments["references"], + } # Only the tag shot has its own frame count in the template - if arguments.get("num_frames") != first["pipeline"]["arguments"]["num_frames"]: + if ( + arguments.get("num_frames") + != first["pipeline"]["arguments"]["num_frames"] + ): entry["num_frames"] = arguments["num_frames"] shots.append(entry) @@ -1060,7 +1098,9 @@ class TestDialogueShortTemplate: shot_template["pipeline"]["arguments"]["references"] = "item:references" expanded = expand_for_each( - definition(today["draw_character_a"], today["draw_character_b"], shot_template) + definition( + today["draw_character_a"], today["draw_character_b"], shot_template + ) ) got = steps_by_name(expanded) for key, old in hand_written: @@ -1071,8 +1111,14 @@ class TestDialogueShortTemplate: # what the hand-written shot has too pass got_step = got[f"shot@{key}"] - assert got_step["pipeline"]["arguments"]["prompt"] == expected["pipeline"]["arguments"]["prompt"] - assert got_step["pipeline"]["arguments"]["references"] == expected["pipeline"]["arguments"]["references"] + assert ( + got_step["pipeline"]["arguments"]["prompt"] + == expected["pipeline"]["arguments"]["prompt"] + ) + assert ( + got_step["pipeline"]["arguments"]["references"] + == expected["pipeline"]["arguments"]["references"] + ) ``` Then read `dialogue-short.json` (`python3 -c "import json; d=json.load(open('workflows/templates/minimax/dialogue-short.json')); [print(s['name'], json.dumps(s)[:600]) for s in d['steps']]"`) and extend the entry to carry every argument that differs between shots (the template's `num_frames` vs `tag_num_frames` is one; there may be others), mapping each to an `item:` in `shot_template`. The final assertion should compare the *whole* step: `assert got[f"shot@{key}"] == expected`, with `expected["name"]` set as above. Replace the two field assertions with that once the entry shape is right. @@ -1175,17 +1221,25 @@ Append to `tests/test_workflow.py` (it already imports `Workflow`/`workflow_from def _for_each_workflow(**overrides): definition = { "id": "fe", - "variables": {"shots": [{"name": "a", "text": "A"}, {"name": "b", "text": "B"}]}, + "variables": { + "shots": [{"name": "a", "text": "A"}, {"name": "b", "text": "B"}] + }, "steps": [ { "name": "shot", "for_each": "variable:shots", - "task": {"command": "compose_text", "arguments": {"parts": ["item:text"]}}, + "task": { + "command": "compose_text", + "arguments": {"parts": ["item:text"]}, + }, "result": {"content_type": "text/plain"}, }, { "name": "edit", - "task": {"command": "compose_text", "arguments": {"parts": "gather:shot"}}, + "task": { + "command": "compose_text", + "arguments": {"parts": "gather:shot"}, + }, "result": {"content_type": "text/plain"}, }, ], @@ -1272,42 +1326,43 @@ from .variables import ( Replace `validation_errors`: ```python - def expanded_definition(self, arguments=None): - """The definition as the run will see it: variables substituted - - the caller's `arguments` folded in when they are all good, else the - declared defaults - and every for_each step expanded. - - Raises ForEachError for a for_each that cannot be expanded. A - 'variable:' that names nothing is left in place rather than raised: - validate_workflow already reports that as a warning, and the - reference check is happy to skip a reference it cannot read. - """ - definition = copy.deepcopy(self.workflow_definition) - variables = definition.get("variables") - if isinstance(variables, dict): - if arguments and not argument_errors(definition, arguments): - set_variables(arguments, variables) - try: - definition = replace_variables(definition, variables) - except VariableNotFoundError: - pass - return expand_for_each(definition) - - def validation_errors(self, arguments=None): - """Every schema violation in the definition, as [{path, message}]; - empty when it validates. `arguments` are the caller's, so a - for_each over a list the caller supplies is checked as it will run.""" - errors = validate_data_all(self.workflow_definition, load_schema("workflow")) - # Only once the shape is known good: the passes below walk the - # steps array and a definition that fails the schema may have no - # such array to walk - if errors: - return errors +def expanded_definition(self, arguments=None): + """The definition as the run will see it: variables substituted - + the caller's `arguments` folded in when they are all good, else the + declared defaults - and every for_each step expanded. + + Raises ForEachError for a for_each that cannot be expanded. A + 'variable:' that names nothing is left in place rather than raised: + validate_workflow already reports that as a warning, and the + reference check is happy to skip a reference it cannot read. + """ + definition = copy.deepcopy(self.workflow_definition) + variables = definition.get("variables") + if isinstance(variables, dict): + if arguments and not argument_errors(definition, arguments): + set_variables(arguments, variables) try: - expanded = self.expanded_definition(arguments) - except ForEachError as e: - return [{"path": e.path, "message": str(e)}] - return previous_result_reference_errors(expanded) + definition = replace_variables(definition, variables) + except VariableNotFoundError: + pass + return expand_for_each(definition) + + +def validation_errors(self, arguments=None): + """Every schema violation in the definition, as [{path, message}]; + empty when it validates. `arguments` are the caller's, so a + for_each over a list the caller supplies is checked as it will run.""" + errors = validate_data_all(self.workflow_definition, load_schema("workflow")) + # Only once the shape is known good: the passes below walk the + # steps array and a definition that fails the schema may have no + # such array to walk + if errors: + return errors + try: + expanded = self.expanded_definition(arguments) + except ForEachError as e: + return [{"path": e.path, "message": str(e)}] + return previous_result_reference_errors(expanded) ``` `set_variables` may need `realize_constants` first when a default is `constant:` - it does not for the list case, and `argument_errors` already runs `set_variables` on the raw declared block, so this mirrors it. @@ -1367,12 +1422,18 @@ def test_validate_expands_for_each_with_the_callers_list(client): { "name": "shot", "for_each": "variable:shots", - "task": {"command": "compose_text", "arguments": {"parts": ["item:text"]}}, + "task": { + "command": "compose_text", + "arguments": {"parts": ["item:text"]}, + }, "result": {"content_type": "text/plain"}, }, { "name": "edit", - "task": {"command": "compose_text", "arguments": {"parts": "gather:shot"}}, + "task": { + "command": "compose_text", + "arguments": {"parts": "gather:shot"}, + }, "result": {"content_type": "text/plain"}, }, ], @@ -1382,7 +1443,10 @@ def test_validate_expands_for_each_with_the_callers_list(client): bad = client.post( "/api/validate", - json={"workflow": workflow, "arguments": {"shots": [{"name": "x"}, {"name": "x"}]}}, + json={ + "workflow": workflow, + "arguments": {"shots": [{"name": "x"}, {"name": "x"}]}, + }, ).json() assert bad["valid"] is False assert bad["errors"][0]["path"] == "steps[0].for_each[1].name" diff --git a/docs/superpowers/plans/2026-09-11-list-driven-templates.md b/docs/superpowers/plans/2026-09-11-list-driven-templates.md index 0529c3d1..877111e2 100644 --- a/docs/superpowers/plans/2026-09-11-list-driven-templates.md +++ b/docs/superpowers/plans/2026-09-11-list-driven-templates.md @@ -104,7 +104,9 @@ class TestResolveVariableValues: class TestUndeclaredReferencesInsideVariableValues: def test_a_reference_inside_a_list_value_is_found_with_its_path(self): definition = { - "variables": {"shots": [{"references": [{}, {"from_file": "variable:nope"}]}]}, + "variables": { + "shots": [{"references": [{}, {"from_file": "variable:nope"}]}] + }, "steps": [], } assert undeclared_variable_references(definition) == [ @@ -533,12 +535,17 @@ class TestMusicVideoTemplate: def expanded(self): from dw.workflow import Workflow - return Workflow(load_template("music-video.json"), TEMPLATES).expanded_definition() + return Workflow( + load_template("music-video.json"), TEMPLATES + ).expanded_definition() def test_the_template_validates_as_it_will_run(self): from dw.workflow import Workflow - assert Workflow(load_template("music-video.json"), TEMPLATES).validation_errors() == [] + assert ( + Workflow(load_template("music-video.json"), TEMPLATES).validation_errors() + == [] + ) def test_one_slice_and_one_shot_per_entry_in_list_order(self): names = [s["name"] for s in self.expanded()["steps"]] @@ -552,7 +559,9 @@ class TestMusicVideoTemplate: def test_each_slice_starts_where_its_entry_says(self): got = steps_by_name(self.expanded()) - starts = [got[f"slice@{k}"]["task"]["arguments"]["start_frame"] for k in self.KEYS] + starts = [ + got[f"slice@{k}"]["task"]["arguments"]["start_frame"] for k in self.KEYS + ] assert starts == [0, 124, 248, 372] def test_each_shot_reads_its_own_slice_and_the_one_portrait(self): @@ -592,7 +601,10 @@ class TestMusicVideoTemplate: 'shots' variable, not the expanded members.""" template = load_template("music-video.json") assert "shots" in template["variables"] - assert [s["name"] for s in template["steps"] if "for_each" in s] == ["slice", "shot"] + assert [s["name"] for s in template["steps"] if "for_each" in s] == [ + "slice", + "shot", + ] ``` Check `Workflow.__init__`'s signature (`dw/workflow.py`, `class Workflow`) before writing: if it takes `(workflow_definition, base_dir, ...)` in a different order or by keyword, match it. If the file's `without_pipeline_reference` helper is now unused by any test, delete it. @@ -767,11 +779,11 @@ class TestOptionalVoices: def test_every_entry_names_the_voice_variables_rather_than_a_file(self, definition): for entry in definition["variables"]["shots"]: - voices = [ - r["from_file"] for r in entry["references"] if "from_file" in r - ] + voices = [r["from_file"] for r in entry["references"] if "from_file" in r] assert voices, entry["name"] - assert all(v.startswith("variable:character_") for v in voices), entry["name"] + assert all(v.startswith("variable:character_") for v in voices), entry[ + "name" + ] def test_no_voice_named_leaves_only_the_portraits(self, definition): for name, references in shot_references(definition, {}).items(): @@ -798,20 +810,30 @@ class TestOptionalVoices: def test_the_tag_runs_longer(self, definition): frames = {e["name"]: e["num_frames"] for e in definition["variables"]["shots"]} assert frames == { - "cold_open": 124, "deflect": 124, "react": 124, "button": 124, "tag": 141 + "cold_open": 124, + "deflect": 124, + "react": 124, + "button": 124, + "tag": 141, } def test_the_variable_names_are_roles_rather_than_a_cast(self, definition): """Every run carried howie_portrait_prompt and shot_3_howie_incredulous through its arguments, manifest and export whatever the cast was.""" - names = " ".join(definition["variables"]) + " ".join( - step["name"] for step in definition["steps"] - ) + " ".join(e["name"] for e in definition["variables"]["shots"]) + names = ( + " ".join(definition["variables"]) + + " ".join(step["name"] for step in definition["steps"]) + + " ".join(e["name"] for e in definition["variables"]["shots"]) + ) assert "howie" not in names.lower() assert "pat_" not in names.lower() assert "character_a_portrait_prompt" in definition["variables"] assert [e["name"] for e in definition["variables"]["shots"]] == [ - "cold_open", "deflect", "react", "button", "tag" + "cold_open", + "deflect", + "react", + "button", + "tag", ] ``` @@ -830,12 +852,19 @@ class TestDialogueShortTemplate: def expanded(self): from dw.workflow import Workflow - return Workflow(load_template("dialogue-short.json"), TEMPLATES).expanded_definition() + return Workflow( + load_template("dialogue-short.json"), TEMPLATES + ).expanded_definition() def test_the_template_validates_as_it_will_run(self): from dw.workflow import Workflow - assert Workflow(load_template("dialogue-short.json"), TEMPLATES).validation_errors() == [] + assert ( + Workflow( + load_template("dialogue-short.json"), TEMPLATES + ).validation_errors() + == [] + ) def test_one_shot_per_entry_between_the_cast_and_the_edit(self): names = [s["name"] for s in self.expanded()["steps"]] @@ -873,14 +902,19 @@ class TestDialogueShortTemplate: def test_the_tag_runs_longer(self): got = steps_by_name(self.expanded()) - frames = [got[f"shot@{k}"]["pipeline"]["arguments"]["num_frames"] for k in self.KEYS] + frames = [ + got[f"shot@{k}"]["pipeline"]["arguments"]["num_frames"] for k in self.KEYS + ] assert frames == [124, 124, 124, 124, 141] def test_every_shot_is_the_same_pipeline(self): from dw.workflow import pipeline_cache_key got = steps_by_name(self.expanded()) - assert len({pipeline_cache_key(got[f"shot@{k}"]["pipeline"]) for k in self.KEYS}) == 1 + assert ( + len({pipeline_cache_key(got[f"shot@{k}"]["pipeline"]) for k in self.KEYS}) + == 1 + ) def test_the_episode_gathers_the_shots_in_order(self): got = steps_by_name(self.expanded()) diff --git a/docs/superpowers/plans/2026-09-12-output-subfolders-stage-1-engine.md b/docs/superpowers/plans/2026-09-12-output-subfolders-stage-1-engine.md index dbbc4867..31066ef1 100644 --- a/docs/superpowers/plans/2026-09-12-output-subfolders-stage-1-engine.md +++ b/docs/superpowers/plans/2026-09-12-output-subfolders-stage-1-engine.md @@ -53,39 +53,41 @@ Add to `tests/test_runs.py`, importing `split_run_path` alongside `strip_run_id` in the `from dw.runs import (...)` block, and inside the class that holds `test_stripping_a_run_id_gives_the_workflow_folder`: ```python - def test_splitting_a_run_path_names_its_three_parts(self): - run_id = new_run_id({"id": "x"}) - assert split_run_path(f"ltx2/Gyre/{run_id}/still.png") == ( - "ltx2/Gyre", - run_id, - "", - ) - assert split_run_path(f"ltx2/Gyre/{run_id}/final/still.png") == ( - "ltx2/Gyre", - run_id, - "final", - ) - assert split_run_path(f"ltx2/Gyre/{run_id}/shots/act-1/x.mp4") == ( - "ltx2/Gyre", - run_id, - "shots/act-1", - ) - # A counter suffix is still a run id - assert split_run_path(f"Gyre/{run_id}-2/final/x.mp4") == ( - "Gyre", - f"{run_id}-2", - "final", - ) +def test_splitting_a_run_path_names_its_three_parts(self): + run_id = new_run_id({"id": "x"}) + assert split_run_path(f"ltx2/Gyre/{run_id}/still.png") == ( + "ltx2/Gyre", + run_id, + "", + ) + assert split_run_path(f"ltx2/Gyre/{run_id}/final/still.png") == ( + "ltx2/Gyre", + run_id, + "final", + ) + assert split_run_path(f"ltx2/Gyre/{run_id}/shots/act-1/x.mp4") == ( + "ltx2/Gyre", + run_id, + "shots/act-1", + ) + # A counter suffix is still a run id + assert split_run_path(f"Gyre/{run_id}-2/final/x.mp4") == ( + "Gyre", + f"{run_id}-2", + "final", + ) + - def test_a_path_with_no_run_id_splits_to_its_directory(self): - # Flat layout: nothing to anchor on, so the directory is the identity - assert split_run_path("ltx2/final/still.png") == ("ltx2/final", "", "") - assert split_run_path("still.png") == ("", "", "") +def test_a_path_with_no_run_id_splits_to_its_directory(self): + # Flat layout: nothing to anchor on, so the directory is the identity + assert split_run_path("ltx2/final/still.png") == ("ltx2/final", "", "") + assert split_run_path("still.png") == ("", "", "") - def test_stripping_a_run_id_ignores_what_follows_it(self): - run_id = new_run_id({"id": "x"}) - assert strip_run_id(f"ltx2/Gyre/{run_id}/final/still.png") == "ltx2/Gyre" - assert strip_run_id(f"{run_id}/final/still.png") == "" + +def test_stripping_a_run_id_ignores_what_follows_it(self): + run_id = new_run_id({"id": "x"}) + assert strip_run_id(f"ltx2/Gyre/{run_id}/final/still.png") == "ltx2/Gyre" + assert strip_run_id(f"{run_id}/final/still.png") == "" ``` - [ ] **Step 2: Run the tests to verify they fail** @@ -361,7 +363,9 @@ class TestSubfolderErrors: errors = subfolder_errors(definition) assert len(errors) == 1 assert errors[0]["path"] == "steps[0].result.subfolder" - assert "Subfolder" in errors[0]["message"] or "subfolder" in errors[0]["message"] + assert ( + "Subfolder" in errors[0]["message"] or "subfolder" in errors[0]["message"] + ) def test_a_separator_in_file_base_name_is_reported_at_its_path(self): definition = {"steps": [_step("a", {"file_base_name": "final/"})]} @@ -552,7 +556,10 @@ class TestValidationErrorsIntegration: { "name": "a", "task": {"command": "noop", "arguments": {}}, - "result": {"content_type": "image/png", "subfolder": "variable:dest"}, + "result": { + "content_type": "image/png", + "subfolder": "variable:dest", + }, } ], } @@ -602,9 +609,9 @@ Change the last line of `validation_errors` from: to: ```python - return previous_result_reference_errors( - expanded, source_indices - ) + subfolder_errors(expanded, source_indices) +return previous_result_reference_errors(expanded, source_indices) + subfolder_errors( + expanded, source_indices +) ``` - [ ] **Step 7: Run the tests to verify they pass** @@ -653,7 +660,10 @@ class TestResultSubfolder: { "name": "a", "task": {"command": "noop", "arguments": {}}, - "result": {"content_type": "image/png", "subfolder": "variable:dest"}, + "result": { + "content_type": "image/png", + "subfolder": "variable:dest", + }, } ], } @@ -758,7 +768,9 @@ class TestSubfolders: def test_step_output_dir_is_the_run_directory_without_a_subfolder(self, tmp_path): from dw.workflow import Workflow - workflow = Workflow(_workflow_definition(), str(tmp_path), "/w/workflows/Gyre.json") + workflow = Workflow( + _workflow_definition(), str(tmp_path), "/w/workflows/Gyre.json" + ) workflow._run_dir = str(tmp_path / "Gyre" / "run") step = workflow.workflow_definition["steps"][0] assert workflow.step_output_dir(step) == workflow.effective_output_dir @@ -774,12 +786,16 @@ class TestSubfolders: with pytest.raises(SecurityError): workflow.step_output_dir(workflow.workflow_definition["steps"][0]) - def test_the_pipeline_wrapper_is_pointed_at_the_subfolder(self, tmp_path, fake_pipeline): + def test_the_pipeline_wrapper_is_pointed_at_the_subfolder( + self, tmp_path, fake_pipeline + ): # A chain step's save_segments spill writes through the pipeline's # output_dir, so it has to be the step's directory, not the run's from dw.workflow import Workflow - workflow = Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json") + workflow = Workflow( + _foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json" + ) workflow._run_dir = str(tmp_path / "Gyre" / "run") step = workflow.workflow_definition["steps"][0] action = workflow.create_step_action(step, {}, {}, 7, "cpu") @@ -832,9 +848,7 @@ Confirm `os` is imported in `dw/workflow.py` (it is used by `effective_output_di In the step loop (~line 743), change: ```python - saved_files = result.save( - self.effective_output_dir, f"{workflow_id}-{step.name}.{i}" - ) +saved_files = result.save(self.effective_output_dir, f"{workflow_id}-{step.name}.{i}") ``` to: @@ -849,7 +863,7 @@ to: In `create_step_action`, replace each of the three `output_dir=self.effective_output_dir,` kwargs (the cached-pipeline reuse branch ~962, the fresh `Pipeline(...)` ~1003, and the `pipeline_reference` branch ~1032) with: ```python - output_dir=self.step_output_dir(step_definition), +output_dir = (self.step_output_dir(step_definition),) ``` (`step_definition` is the parameter name in `create_step_action`; keep each line's existing indentation.) Do not touch the `workflow` (sub-workflow) branch: a child's steps compute their own directories against the inherited run directory, and the parent's `subfolder` governs only what the parent saves from the child's return value — which the `result.save` change above already covers. @@ -924,58 +938,53 @@ Co-Authored-By: Claude Opus 5 (1M context) " Add to `TestSubfolders` in `tests/test_runs.py`: ```python - def test_the_manifest_entry_carries_the_subfolder(self, tmp_path, fake_pipeline): - from dw.workflow import Workflow - - Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json").run( - {} - ) - (run,) = (tmp_path / "Gyre").iterdir() - manifest = json.loads((run / "manifest.json").read_text()) - (entry,) = manifest["steps"] - assert entry["subfolder"] == "final" - assert entry["files"] == ["final/runs_test-gen0.0-0.0.png"] - - def test_an_unfoldered_entry_carries_the_empty_string(self, tmp_path, fake_pipeline): - from dw.workflow import Workflow - - Workflow(_workflow_definition(), str(tmp_path), "/w/workflows/Gyre.json").run( - {} - ) - (run,) = (tmp_path / "Gyre").iterdir() - manifest = json.loads((run / "manifest.json").read_text()) - assert manifest["steps"][0]["subfolder"] == "" - - def test_a_reused_entry_carries_the_definitions_subfolder( - self, tmp_path, fake_pipeline - ): - from dw.workflow import Workflow - - Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json").run( - {} - ) - Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json").run( - {} - ) - first, second = sorted((tmp_path / "Gyre").iterdir()) - manifest = json.loads((second / "manifest.json").read_text()) - (entry,) = manifest["steps"] - assert entry["reused"] is True - assert entry["subfolder"] == "final" - # The file is the first run's, absolute, inside its 'final' - assert os.path.dirname(entry["files"][0]) == str(first / "final") - - def test_the_step_end_event_carries_the_subfolder(self, tmp_path, fake_pipeline): - from dw.events import RunContext - from dw.workflow import Workflow - - seen = [] - context = RunContext(on_event=seen.append) - Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json").run( - {}, previous_pipelines={}, context=context - ) - step_ends = [event for event in seen if event.get("event") == "step_end"] - assert step_ends and step_ends[0]["subfolder"] == "final" +def test_the_manifest_entry_carries_the_subfolder(self, tmp_path, fake_pipeline): + from dw.workflow import Workflow + + Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json").run({}) + (run,) = (tmp_path / "Gyre").iterdir() + manifest = json.loads((run / "manifest.json").read_text()) + (entry,) = manifest["steps"] + assert entry["subfolder"] == "final" + assert entry["files"] == ["final/runs_test-gen0.0-0.0.png"] + + +def test_an_unfoldered_entry_carries_the_empty_string(self, tmp_path, fake_pipeline): + from dw.workflow import Workflow + + Workflow(_workflow_definition(), str(tmp_path), "/w/workflows/Gyre.json").run({}) + (run,) = (tmp_path / "Gyre").iterdir() + manifest = json.loads((run / "manifest.json").read_text()) + assert manifest["steps"][0]["subfolder"] == "" + + +def test_a_reused_entry_carries_the_definitions_subfolder( + self, tmp_path, fake_pipeline +): + from dw.workflow import Workflow + + Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json").run({}) + Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json").run({}) + first, second = sorted((tmp_path / "Gyre").iterdir()) + manifest = json.loads((second / "manifest.json").read_text()) + (entry,) = manifest["steps"] + assert entry["reused"] is True + assert entry["subfolder"] == "final" + # The file is the first run's, absolute, inside its 'final' + assert os.path.dirname(entry["files"][0]) == str(first / "final") + + +def test_the_step_end_event_carries_the_subfolder(self, tmp_path, fake_pipeline): + from dw.events import RunContext + from dw.workflow import Workflow + + seen = [] + context = RunContext(on_event=seen.append) + Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json").run( + {}, previous_pipelines={}, context=context + ) + step_ends = [event for event in seen if event.get("event") == "step_end"] + assert step_ends and step_ends[0]["subfolder"] == "final" ``` (`RunContext(on_event=...)` and `run(..., context=context)` is the same setup `tests/test_events.py::_run` uses.) @@ -1056,20 +1065,21 @@ Co-Authored-By: Claude Opus 5 (1M context) " Add to `TestSubfolders` in `tests/test_runs.py` (import `resolve_output_reference` from `dw.runs` in the file's import block if it is not already there): ```python - def test_an_output_reference_reaches_into_a_subfolder(self, tmp_path, fake_pipeline): - from dw.workflow import Workflow +def test_an_output_reference_reaches_into_a_subfolder(self, tmp_path, fake_pipeline): + from dw.workflow import Workflow - Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json").run( - {} - ) - (run,) = (tmp_path / "Gyre").iterdir() - resolved = resolve_output_reference( - "output:Gyre/latest/final/runs_test-gen0.0-0.0.png", root=str(tmp_path) - ) - assert resolved == str(run / "final" / "runs_test-gen0.0-0.0.png") - assert resolve_output_reference( + Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json").run({}) + (run,) = (tmp_path / "Gyre").iterdir() + resolved = resolve_output_reference( + "output:Gyre/latest/final/runs_test-gen0.0-0.0.png", root=str(tmp_path) + ) + assert resolved == str(run / "final" / "runs_test-gen0.0-0.0.png") + assert ( + resolve_output_reference( f"output:Gyre/{run.name}/final/runs_test-gen0.0-0.0.png", root=str(tmp_path) - ) == resolved + ) + == resolved + ) ``` - [ ] **Step 2: Run it** diff --git a/docs/superpowers/plans/2026-09-12-output-subfolders-stage-2-server.md b/docs/superpowers/plans/2026-09-12-output-subfolders-stage-2-server.md index 262cd470..49aa39e8 100644 --- a/docs/superpowers/plans/2026-09-12-output-subfolders-stage-2-server.md +++ b/docs/superpowers/plans/2026-09-12-output-subfolders-stage-2-server.md @@ -84,7 +84,9 @@ def test_gallery_reports_and_filters_by_subfolder(server, tmp_path): final = by_name[f"dialogue/{run_id}/final/dialogue-assemble.0-0.0.png"] assert final["folder"] == "dialogue" assert final["subfolder"] == "final" - nested = by_name[f"dialogue/{run_id}/intermediate/shots/dialogue-slice.0-0.0.png"] + nested = by_name[ + f"dialogue/{run_id}/intermediate/shots/dialogue-slice.0-0.0.png" + ] assert nested["subfolder"] == "intermediate/shots" assert by_name[f"dialogue/{run_id}/dialogue-still.0-0.0.png"]["subfolder"] == "" assert by_name["ltx/flat.png"]["folder"] == "ltx" @@ -115,7 +117,12 @@ def test_job_for_file_attributes_a_file_in_a_subfolder(tmp_path): history = JobHistory(str(tmp_path / "jobs.sqlite")) name = "dialogue/20260912-120000-abcdef01/final/dialogue-assemble.0-0.0.png" - _record(history, "writer", 1.0, [{"step": "assemble", "files": [name], "subfolder": "final"}]) + _record( + history, + "writer", + 1.0, + [{"step": "assemble", "files": [name], "subfolder": "final"}], + ) assert history.job_for_file(name)["id"] == "writer" ``` @@ -499,56 +506,62 @@ No engine change. Both tests pin behaviour that is correct by inspection; if eit Add to `TestSubfolders`: ```python - def test_a_parents_subfolder_does_not_move_a_childs_files( - self, tmp_path, fake_pipeline - ): - # A 'workflow' step's own result block governs what the parent saves - # from the child's return value; the child's steps place their own - # files, into the run directory they inherit - from dw.workflow import Workflow - - tree = tmp_path / "workflows" - tree.mkdir() - (tree / "child.json").write_text(json.dumps(_workflow_definition())) - parent = { - "id": "parent", - "seed": 7, - "steps": [ - { - "name": "child", - "workflow": {"path": "child.json"}, - "result": {"subfolder": "final"}, - } - ], - } - Workflow(parent, str(tmp_path / "out"), str(tree / "Parent.json")).run({}) - (run,) = (tmp_path / "out" / "Parent").iterdir() - assert (run / "runs_test-gen0.0-0.0.png").is_file() - assert not (run / "final" / "runs_test-gen0.0-0.0.png").exists() - - def test_a_chain_spill_lands_in_the_steps_subfolder(self, tmp_path, fake_pipeline): - # save_segments writes through the pipeline wrapper's output_dir, - # which create_step_action points at the step's subfolder - from dw.pipeline_processors.chain import run_chain - from dw.workflow import Workflow - from tests.test_chain import FakePipeline, video_output - - workflow = Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json") - workflow._run_dir = str(tmp_path / "Gyre" / "run") - action = workflow.create_step_action( - workflow.workflow_definition["steps"][0], {}, {}, 7, "cpu" - ) - spilling = FakePipeline( - video_output, output_dir=action.output_dir, file_prefix=action.file_prefix - ) - run_chain( - spilling, - {"segments": 2, "trim_frames": 1, "fps": 4, "save_segments": True, - "keep_segments": True}, - {}, - ) - segments = sorted((tmp_path / "Gyre" / "run" / "final").glob("*.segment-*.mp4")) - assert len(segments) == 2 +def test_a_parents_subfolder_does_not_move_a_childs_files( + self, tmp_path, fake_pipeline +): + # A 'workflow' step's own result block governs what the parent saves + # from the child's return value; the child's steps place their own + # files, into the run directory they inherit + from dw.workflow import Workflow + + tree = tmp_path / "workflows" + tree.mkdir() + (tree / "child.json").write_text(json.dumps(_workflow_definition())) + parent = { + "id": "parent", + "seed": 7, + "steps": [ + { + "name": "child", + "workflow": {"path": "child.json"}, + "result": {"subfolder": "final"}, + } + ], + } + Workflow(parent, str(tmp_path / "out"), str(tree / "Parent.json")).run({}) + (run,) = (tmp_path / "out" / "Parent").iterdir() + assert (run / "runs_test-gen0.0-0.0.png").is_file() + assert not (run / "final" / "runs_test-gen0.0-0.0.png").exists() + + +def test_a_chain_spill_lands_in_the_steps_subfolder(self, tmp_path, fake_pipeline): + # save_segments writes through the pipeline wrapper's output_dir, + # which create_step_action points at the step's subfolder + from dw.pipeline_processors.chain import run_chain + from dw.workflow import Workflow + from tests.test_chain import FakePipeline, video_output + + workflow = Workflow(_foldered_definition(), str(tmp_path), "/w/workflows/Gyre.json") + workflow._run_dir = str(tmp_path / "Gyre" / "run") + action = workflow.create_step_action( + workflow.workflow_definition["steps"][0], {}, {}, 7, "cpu" + ) + spilling = FakePipeline( + video_output, output_dir=action.output_dir, file_prefix=action.file_prefix + ) + run_chain( + spilling, + { + "segments": 2, + "trim_frames": 1, + "fps": 4, + "save_segments": True, + "keep_segments": True, + }, + {}, + ) + segments = sorted((tmp_path / "Gyre" / "run" / "final").glob("*.segment-*.mp4")) + assert len(segments) == 2 ``` Before running, check: (a) that `Workflow` resolves a sub-workflow's `path` relative to the parent's `file_spec` directory (read the `workflow` branch of `create_step_action`); if it needs the parent file to exist, write `parent` to `tree / "Parent.json"` and load it with `workflow_from_file`; (b) the exact child file name — the child's id is `runs_test`, its step `gen0` — and adjust the asserted name to what the run wrote if the workflow-step naming differs, keeping the assertion that it is at the run root and not under `final/`; (c) that `tests/test_chain.py` exports `FakePipeline` and `video_output` at module level and `FakePipeline` accepts `output_dir`/`file_prefix` kwargs (it does in `TestSaveSegments.make_pipeline`); (d) the attribute name the `Pipeline` wrapper stores its file prefix under (`file_prefix` per the constructor kwarg — confirm in `dw/pipeline_processors/pipeline.py`). diff --git a/docs/superpowers/plans/2026-09-12-output-subfolders-stage-3-steering.md b/docs/superpowers/plans/2026-09-12-output-subfolders-stage-3-steering.md index 42c8796b..88782913 100644 --- a/docs/superpowers/plans/2026-09-12-output-subfolders-stage-3-steering.md +++ b/docs/superpowers/plans/2026-09-12-output-subfolders-stage-3-steering.md @@ -131,7 +131,8 @@ def test_the_subfolder_is_the_last_key_of_the_result(template): @pytest.mark.parametrize( - "builtin", sorted(name for name in os.listdir(BUILTIN_DIR) if name.endswith(".json")) + "builtin", + sorted(name for name in os.listdir(BUILTIN_DIR) if name.endswith(".json")), ) def test_the_packaged_builtins_stay_unmarked(builtin): with open(os.path.join(BUILTIN_DIR, builtin), encoding="utf-8") as file: @@ -141,7 +142,9 @@ def test_the_packaged_builtins_stay_unmarked(builtin): for step in definition.get("steps", []) if "subfolder" in (step.get("result") or {}) ] - assert not marked, f"dw/workflows/{builtin} marks {marked}; a role is the parent's to assign" + assert not marked, ( + f"dw/workflows/{builtin} marks {marked}; a role is the parent's to assign" + ) ``` - [ ] **Step 2: Run it to verify it fails** @@ -279,7 +282,9 @@ def test_the_h3_skill_names_the_intermediate_steps_the_templates_mark(): must change with it.""" text = skill_text(H3_SKILL) for name in ("dialogue-short", "music-video", "storyboard"): - path = os.path.join(REPO_ROOT, "workflows", "templates", "minimax", name + ".json") + path = os.path.join( + REPO_ROOT, "workflows", "templates", "minimax", name + ".json" + ) with open(path, encoding="utf-8") as f: spec = json.load(f) finals = [ @@ -288,7 +293,9 @@ def test_the_h3_skill_names_the_intermediate_steps_the_templates_mark(): if (step.get("result") or {}).get("subfolder") == "final" ] assert len(finals) == 1, (name, finals) - assert f"`{finals[0]}`" in text, f"the skill does not name {name}'s final step {finals[0]}" + assert f"`{finals[0]}`" in text, ( + f"the skill does not name {name}'s final step {finals[0]}" + ) ``` - [ ] **Step 2: Run them to verify they fail** diff --git a/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-1-plan.md b/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-1-plan.md index 7346e25d..c824214e 100644 --- a/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-1-plan.md +++ b/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-1-plan.md @@ -58,9 +58,7 @@ class TestUnpinnedOutputs: spec["steps"][0]["pipeline"]["arguments"]["image"] = ( "output:ltx2/Gyre/latest/still.png" ) - realized, _ = realize_workflow( - spec, {}, 7, output_root=root, pin_outputs=False - ) + realized, _ = realize_workflow(spec, {}, 7, output_root=root, pin_outputs=False) assert ( realized["steps"][0]["pipeline"]["arguments"]["image"] == "output:ltx2/Gyre/latest/still.png" @@ -225,14 +223,14 @@ Co-Authored-By: Claude Opus 5 (1M context) " ```python def build_plan( - candidate, # a dw.workflow.Workflow, as the route constructed it - arguments, # the caller's dict, already past argument_errors + candidate, # a dw.workflow.Workflow, as the route constructed it + arguments, # the caller's dict, already past argument_errors *, - device, # "cuda" | "mps" | "cpu" - the serving backend + device, # "cuda" | "mps" | "cpu" - the serving backend prompt_dir=None, cache_dir=None, lookup_sizes=True, - cache_probe=None, # stage 2; ignored here, cached_steps is always None + cache_probe=None, # stage 2; ignored here, cached_steps is always None ): """What a run of `candidate` with `arguments` will execute and cost.""" ``` @@ -245,8 +243,8 @@ returning "steps": int, "list_entries": {variable: int}, "cached_steps": None, - "downloads_required": [...], # Task 4; [] until then - "estimate": {...}, # Task 3; placeholder until then + "downloads_required": [...], # Task 4; [] until then + "estimate": {...}, # Task 3; placeholder until then } ``` @@ -312,7 +310,9 @@ def definition(): def prompt_library(tmp_path): library = tmp_path / "prompts" (library / "scenic").mkdir(parents=True) - (library / "scenic" / "dusk.json").write_text(json.dumps({"text": "a harbour at dusk"})) + (library / "scenic" / "dusk.json").write_text( + json.dumps({"text": "a harbour at dusk"}) + ) return library @@ -333,7 +333,9 @@ def plan(tmp_path, prompt_library, output_root, monkeypatch): monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) monkeypatch.setattr( - dw.plan, "model_info", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("offline")) + dw.plan, + "model_info", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("offline")), ) def make(spec=None, arguments=None, **overrides): @@ -352,8 +354,12 @@ class TestShape: def test_the_documented_keys(self, plan): answer = plan() assert set(answer) == { - "fingerprint", "steps", "list_entries", "cached_steps", - "downloads_required", "estimate", + "fingerprint", + "steps", + "list_entries", + "cached_steps", + "downloads_required", + "estimate", } assert answer["fingerprint"].startswith("sha256:") assert len(answer["fingerprint"]) == len("sha256:") + 64 @@ -443,9 +449,20 @@ class TestFingerprintChangesWith: removed = definition() del removed["steps"][1] added = definition() - added["steps"].append({"name": "extra", "task": {"command": "x", "arguments": {}}}) - assert len({base, plan(renamed)["fingerprint"], plan(removed)["fingerprint"], - plan(added)["fingerprint"]}) == 4 + added["steps"].append( + {"name": "extra", "task": {"command": "x", "arguments": {}}} + ) + assert ( + len( + { + base, + plan(renamed)["fingerprint"], + plan(removed)["fingerprint"], + plan(added)["fingerprint"], + } + ) + == 4 + ) ``` - [ ] **Step 2: Run the tests to verify they fail** @@ -479,7 +496,12 @@ import os from huggingface_hub import model_info from .hub_cache import scan_models -from .realize import BUILTIN_PREFIX, VARIABLE_PREFIX, read_sub_workflow, realize_workflow +from .realize import ( + BUILTIN_PREFIX, + VARIABLE_PREFIX, + read_sub_workflow, + realize_workflow, +) from .security import validate_url from .workflow import Workflow @@ -643,8 +665,11 @@ class TestEstimate: spec = definition() del spec["cost"] assert plan(spec)["estimate"] == { - "minutes": None, "basis": "unknown", "device": "cuda", - "measured_on": None, "partial": False, + "minutes": None, + "basis": "unknown", + "device": "cuda", + "measured_on": None, + "partial": False, } def test_an_empty_cost_list_is_unknown(self, plan): @@ -656,22 +681,30 @@ class TestEstimate: spec = definition() spec["cost"] = [cost("mps", 40, name="M2"), cost("cuda", 10, name="4090")] assert plan(spec)["estimate"] == { - "minutes": 10.0, "basis": "catalog", "device": "cuda", - "measured_on": "4090", "partial": False, + "minutes": 10.0, + "basis": "catalog", + "device": "cuda", + "measured_on": "4090", + "partial": False, } def test_another_devices_entry_is_reported_as_such(self, plan): spec = definition() spec["cost"] = [cost("mps", 40, name="M2")] assert plan(spec)["estimate"] == { - "minutes": 40.0, "basis": "other_device", "device": "cuda", - "measured_on": "M2", "partial": False, + "minutes": 40.0, + "basis": "other_device", + "device": "cuda", + "measured_on": "M2", + "partial": False, } def test_per_entry_scales_by_the_callers_list(self, plan): spec = definition() # 10 minutes for the 2-entry default, of which 3 per entry: 4 fixed - spec["cost"] = [cost("cuda", 10, {"variable": "shots", "minutes": 3, "entries": 2})] + spec["cost"] = [ + cost("cuda", 10, {"variable": "shots", "minutes": 3, "entries": 2}) + ] shots = [{"name": n, "prompt": n} for n in "abcde"] answer = plan(spec, arguments={"shots": shots})["estimate"] assert answer["minutes"] == 4 + 3 * 5 @@ -679,18 +712,24 @@ class TestEstimate: def test_per_entry_floors_at_zero(self, plan): spec = definition() - spec["cost"] = [cost("cuda", 1, {"variable": "shots", "minutes": 3, "entries": 2})] + spec["cost"] = [ + cost("cuda", 1, {"variable": "shots", "minutes": 3, "entries": 2}) + ] assert plan(spec)["estimate"]["minutes"] == 0.0 def test_per_entry_naming_no_list_falls_back_to_catalog(self, plan): spec = definition() - spec["cost"] = [cost("cuda", 10, {"variable": "other", "minutes": 3, "entries": 2})] + spec["cost"] = [ + cost("cuda", 10, {"variable": "other", "minutes": 3, "entries": 2}) + ] answer = plan(spec)["estimate"] assert (answer["minutes"], answer["basis"]) == (10.0, "catalog") def test_other_device_beats_per_entry(self, plan): spec = definition() - spec["cost"] = [cost("mps", 10, {"variable": "shots", "minutes": 3, "entries": 2})] + spec["cost"] = [ + cost("mps", 10, {"variable": "shots", "minutes": 3, "entries": 2}) + ] answer = plan(spec)["estimate"] assert (answer["minutes"], answer["basis"]) == (10.0, "other_device") @@ -820,7 +859,11 @@ def _price(cost, device, list_entries): basis = OTHER_DEVICE minutes = float(chosen.get("minutes", 0)) per = chosen.get("per_entry") - if basis == CATALOG and isinstance(per, dict) and per.get("variable") in list_entries: + if ( + basis == CATALOG + and isinstance(per, dict) + and per.get("variable") in list_entries + ): count = list_entries[per["variable"]] each = float(per.get("minutes", 0)) measured_with = int(per.get("entries", 0)) @@ -868,7 +911,8 @@ class TestDownloadsRequired: import dw.plan monkeypatch.setattr( - dw.plan, "scan_models", + dw.plan, + "scan_models", lambda cache_dir=None: {"repos": [{"repo_id": "org/still-model"}]}, ) assert plan()["downloads_required"] == [] @@ -878,7 +922,9 @@ class TestDownloadsRequired: seen = [] monkeypatch.setattr( - dw.plan, "scan_models", lambda cache_dir=None: seen.append(cache_dir) or {"repos": []} + dw.plan, + "scan_models", + lambda cache_dir=None: seen.append(cache_dir) or {"repos": []}, ) plan(cache_dir="/somewhere") assert seen == ["/somewhere"] @@ -887,7 +933,9 @@ class TestDownloadsRequired: local = tmp_path / "weights" local.mkdir() spec = definition() - spec["steps"][0]["pipeline"]["from_pretrained_arguments"]["model_name"] = str(local) + spec["steps"][0]["pipeline"]["from_pretrained_arguments"]["model_name"] = str( + local + ) assert plan(spec)["downloads_required"] == [] def test_a_single_file_url_is_listed_without_a_size(self, plan): @@ -913,16 +961,24 @@ class TestDownloadsRequired: } child = { "id": "child", - "steps": [{"name": "c", "pipeline": { - "configuration": {"component_type": "{Fake}"}, - "from_pretrained_arguments": {"model_name": "org/still-model"}, - "arguments": {}, - }}], + "steps": [ + { + "name": "c", + "pipeline": { + "configuration": {"component_type": "{Fake}"}, + "from_pretrained_arguments": {"model_name": "org/still-model"}, + "arguments": {}, + }, + } + ], } (tmp_path / "child.json").write_text(json.dumps(child)) - spec["steps"].append({"name": "sub", "workflow": {"path": "child.json", "arguments": {}}}) + spec["steps"].append( + {"name": "sub", "workflow": {"path": "child.json", "arguments": {}}} + ) assert [d["repo"] for d in plan(spec)["downloads_required"]] == [ - "org/still-model", "org/vae", + "org/still-model", + "org/vae", ] def test_sizes_come_from_the_hub_in_gib(self, plan, monkeypatch): @@ -1101,7 +1157,9 @@ class TestValidatePlan: def test_a_valid_answer_carries_a_plan(self, server, monkeypatch): import dw.plan - monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + monkeypatch.setattr( + dw.plan, "scan_models", lambda cache_dir=None: {"repos": []} + ) with server(success_script) as client: result = client.post( "/api/validate?sizes=false", @@ -1110,8 +1168,12 @@ class TestValidatePlan: assert result["valid"] is True plan = result["plan"] assert set(plan) == { - "fingerprint", "steps", "list_entries", "cached_steps", - "downloads_required", "estimate", + "fingerprint", + "steps", + "list_entries", + "cached_steps", + "downloads_required", + "estimate", } assert plan["steps"] == 1 assert plan["estimate"]["basis"] in {"catalog", "other_device"} @@ -1147,19 +1209,29 @@ class TestValidatePlan: def spy(candidate, arguments, **kwargs): seen.append(kwargs["lookup_sizes"]) - return {"fingerprint": "sha256:0", "steps": 0, "list_entries": {}, - "cached_steps": None, "downloads_required": [], "estimate": None} + return { + "fingerprint": "sha256:0", + "steps": 0, + "list_entries": {}, + "cached_steps": None, + "downloads_required": [], + "estimate": None, + } monkeypatch.setattr(app_module, "build_plan", spy) with server(success_script) as client: client.post("/api/validate", json={"workflow": valid_workflow("v")}) - client.post("/api/validate?sizes=false", json={"workflow": valid_workflow("v")}) + client.post( + "/api/validate?sizes=false", json={"workflow": valid_workflow("v")} + ) assert seen == [True, False] def test_the_plan_sees_the_callers_arguments(self, server, monkeypatch): import dw.plan - monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + monkeypatch.setattr( + dw.plan, "scan_models", lambda cache_dir=None: {"repos": []} + ) with server(success_script) as client: body = {"workflow": valid_workflow("v")} one = client.post("/api/validate?sizes=false", json=body).json()["plan"] diff --git a/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-2-plan.md b/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-2-plan.md index f5bebfac..d1404f89 100644 --- a/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-2-plan.md +++ b/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-2-plan.md @@ -76,7 +76,9 @@ class TestCacheHits: workflow.run({}) probe = workflow.cache_hits({}) workflow.run({}) - reused = [entry["step"] for entry in workflow.manifest if entry.get("reused")] + reused = [ + entry["step"] for entry in workflow.manifest if entry.get("reused") + ] assert probe == ["generate"] assert probe == reused assert call_count() == 1, "the probe executed nothing" @@ -127,167 +129,166 @@ Expected: FAIL - `AttributeError: 'Workflow' object has no attribute 'cache_hits In `dw/workflow.py`, inside `class Workflow`, add two methods (place them just above `run`): ```python - def _prepare_definition(self, workflow_def, arguments, base_dir): - """The definition as a run works from it: constants realized, - arguments folded into the variables, list entries' own references - resolved, variable values realized (assets loaded), every - 'variable:' substituted, every for_each expanded, and the seed read - and coerced. Returns (workflow_def, default_seed) - the seed is - None when the workflow names none, and the caller decides what - that means (run() draws one; cache_hits() reports no hits). - - Shared by run() and cache_hits() so the probe prepares exactly what - the run prepares - the step cache keys on the realized step, and a - probe that prepared it differently would answer for a run that - never happens. - """ - workflow_id = workflow_def["id"] - variables = workflow_def.get("variables", None) - if variables is not None: - logger.debug(f"Setting variables for workflow: {workflow_id}") - # a constant is the value a variable declares, so it resolves before - # anything is converted to the type of that declaration - realize_constants(variables) - # first set variable values base don the arguments passed to the workflow - # these may come form the command line or form a parent workflow - set_variables(arguments, variables) - # an entry of a list-valued variable may name another - # variable; resolve those before anything inside it is - # realized, so a reference type in an entry is a type name - variables = resolve_variable_values(variables) - # realize the variables, initialiting downloads of images etc - realize_args(variables, base_dir) - ## then replace any variable references in the workflow definition with the actual values - # replace_variables returns a new structure rather than mutating in - # place, so the result must be captured here - workflow_def = replace_variables(workflow_def, variables) - - # One ordinary step per entry of every for_each list, before the - # seed, the run id and the realized workflow are computed, so - # each covers what actually runs. A ForEachError here fails the - # run before anything loads - workflow_def = expand_for_each(workflow_def) - - # Set up random seed for reproducibility. Resolved lazily - as a - # dict.get default, torch.seed() would run on every call and reseed - # the global RNG even when the workflow names an explicit seed - default_seed = workflow_def.get("seed") - # The schema lets 'seed' be a string so it can hold a 'variable:' - # reference, which the substitution above has already resolved - - # but a variable overridden from the command line arrives as a - # string whenever the workflow declared no integer default to - # coerce against, and manual_seed would fail deep inside the run - if isinstance(default_seed, str): - try: - default_seed = int(default_seed) - except ValueError: - raise ValueError( - f"Workflow {workflow_id} seed must be an integer, " - f"got {default_seed!r}" - ) - workflow_def["seed"] = default_seed - return workflow_def, default_seed +def _prepare_definition(self, workflow_def, arguments, base_dir): + """The definition as a run works from it: constants realized, + arguments folded into the variables, list entries' own references + resolved, variable values realized (assets loaded), every + 'variable:' substituted, every for_each expanded, and the seed read + and coerced. Returns (workflow_def, default_seed) - the seed is + None when the workflow names none, and the caller decides what + that means (run() draws one; cache_hits() reports no hits). + + Shared by run() and cache_hits() so the probe prepares exactly what + the run prepares - the step cache keys on the realized step, and a + probe that prepared it differently would answer for a run that + never happens. + """ + workflow_id = workflow_def["id"] + variables = workflow_def.get("variables", None) + if variables is not None: + logger.debug(f"Setting variables for workflow: {workflow_id}") + # a constant is the value a variable declares, so it resolves before + # anything is converted to the type of that declaration + realize_constants(variables) + # first set variable values base don the arguments passed to the workflow + # these may come form the command line or form a parent workflow + set_variables(arguments, variables) + # an entry of a list-valued variable may name another + # variable; resolve those before anything inside it is + # realized, so a reference type in an entry is a type name + variables = resolve_variable_values(variables) + # realize the variables, initialiting downloads of images etc + realize_args(variables, base_dir) + ## then replace any variable references in the workflow definition with the actual values + # replace_variables returns a new structure rather than mutating in + # place, so the result must be captured here + workflow_def = replace_variables(workflow_def, variables) + + # One ordinary step per entry of every for_each list, before the + # seed, the run id and the realized workflow are computed, so + # each covers what actually runs. A ForEachError here fails the + # run before anything loads + workflow_def = expand_for_each(workflow_def) + + # Set up random seed for reproducibility. Resolved lazily - as a + # dict.get default, torch.seed() would run on every call and reseed + # the global RNG even when the workflow names an explicit seed + default_seed = workflow_def.get("seed") + # The schema lets 'seed' be a string so it can hold a 'variable:' + # reference, which the substitution above has already resolved - + # but a variable overridden from the command line arrives as a + # string whenever the workflow declared no integer default to + # coerce against, and manual_seed would fail deep inside the run + if isinstance(default_seed, str): + try: + default_seed = int(default_seed) + except ValueError: + raise ValueError( + f"Workflow {workflow_id} seed must be an integer, got {default_seed!r}" + ) + workflow_def["seed"] = default_seed + return workflow_def, default_seed + + +def _cache_lookup( + self, workflow_id, steps, index, step_data, step_seed, hits_this_run, cache_enabled +): + """Whether the step cache serves step `index`, as + (cached_result or None, the step_data snapshot the entry is keyed + on or None). Shared by run() and cache_hits() - see + _prepare_definition for why. + """ + # What later steps still read, which decides both whether this + # step's result has to be kept alive after the step and whether a + # cached entry that kept none can serve this run + remaining_refs = referenced_result_names(steps[index + 1 :]) + result_needed = index == len(steps) - 1 or any( + reference_resolves_to(ref, step_data["name"]) for ref in remaining_refs + ) + # create_step_action (and the pipeline load it triggers) mutates + # step_data in place - injecting a "generator" key - so the cache + # must key off a snapshot taken before that happens, and that same + # snapshot must be reused for the put() later. A sub-workflow step + # is never cacheable: its files roll up from the child's own + # manifest, which a hit does not rebuild. + is_cacheable = "workflow" not in step_data and cache_enabled + # The last step of a composed child whose parent does the saving + # (#92) - its files are the parent step's, written once, under the + # parent's name and subfolder + parent_saves_this = self._final_save_owned_by_parent and index == len(steps) - 1 + step_data_snapshot = None + if is_cacheable: + try: + step_data_snapshot = copy.deepcopy(step_data) + if parent_saves_this: + # Keyed apart from the same step run standalone: this + # entry's result was never saved here, so a standalone + # hit on it would report no files + step_data_snapshot["__saved_by_parent__"] = True + except Exception as ex: + # A realized argument that cannot be deep-copied (an open + # handle, a live model object) just means this step is not + # cacheable - never a failed run + logger.debug( + f"Step '{step_data['name']}' arguments are not copyable " + f"({ex}) - skipping the step cache for it" + ) + is_cacheable = False + if not is_cacheable: + return None, None + cached_result = step_cache.get( + workflow_id, + step_data_snapshot, + step_seed, + hits_this_run, + # The root, not this run's directory: a hit reports the earlier + # run's files and writes nothing new, so keying on a directory + # that is new every run would mean the cache could never hit + # again. What the root still guards is a run redirected + # somewhere else, where the earlier files are not what the + # caller asked for + self.output_dir, + needs_result=result_needed, + ) + return cached_result, step_data_snapshot - def _cache_lookup( - self, workflow_id, steps, index, step_data, step_seed, hits_this_run, cache_enabled - ): - """Whether the step cache serves step `index`, as - (cached_result or None, the step_data snapshot the entry is keyed - on or None). Shared by run() and cache_hits() - see - _prepare_definition for why. - """ - # What later steps still read, which decides both whether this - # step's result has to be kept alive after the step and whether a - # cached entry that kept none can serve this run - remaining_refs = referenced_result_names(steps[index + 1 :]) - result_needed = index == len(steps) - 1 or any( - reference_resolves_to(ref, step_data["name"]) for ref in remaining_refs - ) - # create_step_action (and the pipeline load it triggers) mutates - # step_data in place - injecting a "generator" key - so the cache - # must key off a snapshot taken before that happens, and that same - # snapshot must be reused for the put() later. A sub-workflow step - # is never cacheable: its files roll up from the child's own - # manifest, which a hit does not rebuild. - is_cacheable = "workflow" not in step_data and cache_enabled - # The last step of a composed child whose parent does the saving - # (#92) - its files are the parent step's, written once, under the - # parent's name and subfolder - parent_saves_this = self._final_save_owned_by_parent and index == len(steps) - 1 - step_data_snapshot = None - if is_cacheable: - try: - step_data_snapshot = copy.deepcopy(step_data) - if parent_saves_this: - # Keyed apart from the same step run standalone: this - # entry's result was never saved here, so a standalone - # hit on it would report no files - step_data_snapshot["__saved_by_parent__"] = True - except Exception as ex: - # A realized argument that cannot be deep-copied (an open - # handle, a live model object) just means this step is not - # cacheable - never a failed run - logger.debug( - f"Step '{step_data['name']}' arguments are not copyable " - f"({ex}) - skipping the step cache for it" - ) - is_cacheable = False - if not is_cacheable: - return None, None - cached_result = step_cache.get( - workflow_id, - step_data_snapshot, - step_seed, - hits_this_run, - # The root, not this run's directory: a hit reports the earlier - # run's files and writes nothing new, so keying on a directory - # that is new every run would mean the cache could never hit - # again. What the root still guards is a run redirected - # somewhere else, where the earlier files are not what the - # caller asked for - self.output_dir, - needs_result=result_needed, - ) - return cached_result, step_data_snapshot - def cache_hits(self, arguments): - """The steps the step cache would serve for a run with `arguments`, - in step order - what the plan reports as cached_steps (#85). +def cache_hits(self, arguments): + """The steps the step cache would serve for a run with `arguments`, + in step order - what the plan reports as cached_steps (#85). - Prepares the definition exactly as run() does and asks the cache the - question run() asks, step by step with the hits so far, and executes - nothing: no run directory, no events, no pipeline. An unseeded - workflow has no cache, so it answers [] without asking. - """ - output_root_token = activate_output_root(self.output_dir) - try: - workflow_def = copy.deepcopy(self.workflow_definition) - workflow_id = workflow_def["id"] - base_dir = ( - os.path.dirname(os.path.abspath(self.file_spec)) - if self.file_spec - else None - ) - workflow_def, default_seed = self._prepare_definition( - workflow_def, arguments or {}, base_dir + Prepares the definition exactly as run() does and asks the cache the + question run() asks, step by step with the hits so far, and executes + nothing: no run directory, no events, no pipeline. An unseeded + workflow has no cache, so it answers [] without asking. + """ + output_root_token = activate_output_root(self.output_dir) + try: + workflow_def = copy.deepcopy(self.workflow_definition) + workflow_id = workflow_def["id"] + base_dir = ( + os.path.dirname(os.path.abspath(self.file_spec)) if self.file_spec else None + ) + workflow_def, default_seed = self._prepare_definition( + workflow_def, arguments or {}, base_dir + ) + if default_seed is None or not self._cache_enabled_by_parent: + return [] + steps = workflow_def.get("steps", []) + realize_args(steps, base_dir) + hits_this_run = set() + hits = [] + for index, step_data in enumerate(steps): + step_seed = step_data.get("seed", default_seed) + cached_result, _ = self._cache_lookup( + workflow_id, steps, index, step_data, step_seed, hits_this_run, True ) - if default_seed is None or not self._cache_enabled_by_parent: - return [] - steps = workflow_def.get("steps", []) - realize_args(steps, base_dir) - hits_this_run = set() - hits = [] - for index, step_data in enumerate(steps): - step_seed = step_data.get("seed", default_seed) - cached_result, _ = self._cache_lookup( - workflow_id, steps, index, step_data, step_seed, hits_this_run, True - ) - if cached_result is not None: - hits_this_run.add(step_data["name"]) - hits.append(step_data["name"]) - return hits - finally: - deactivate_output_root(output_root_token) + if cached_result is not None: + hits_this_run.add(step_data["name"]) + hits.append(step_data["name"]) + return hits + finally: + deactivate_output_root(output_root_token) ``` Then in `run()`: @@ -295,9 +296,7 @@ Then in `run()`: - Replace the block from `# Handle variable substitution if variables are defined` through the `workflow_def["seed"] = default_seed` line that follows the `int(default_seed)` coercion (lines ~710-752, ending just before `# A workflow that names no seed gets a fresh one every run`) with: ```python - workflow_def, default_seed = self._prepare_definition( - workflow_def, arguments, base_dir - ) +workflow_def, default_seed = self._prepare_definition(workflow_def, arguments, base_dir) ``` Keep everything from `cache_enabled_this_run = (...)` onward unchanged (the random draw, `workflow_def["seed"] = default_seed`, `resolved_seed`). @@ -381,7 +380,12 @@ def test_probe_cache_reports_a_failure_as_unknown_not_as_a_crash(): worker = _make_worker() with patch("dw.worker.workflow_from_file", side_effect=ValueError("bad file")): worker._handle_probe_cache( - {"type": "probe_cache", "workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"} + { + "type": "probe_cache", + "workflow_path": "x.json", + "arguments": {}, + "output_dir": "/tmp", + } ) [answer] = _drain(worker.result_queue) assert answer["type"] == "probe_cache" @@ -444,7 +448,12 @@ class TestProbeCache: with server(success_script) as client: manager = client.app.state.job_manager assert manager.worker_manager.worker_active is False - assert manager.probe_cache({"workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"}) == [] + assert ( + manager.probe_cache( + {"workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"} + ) + == [] + ) assert manager.worker_manager.commands == [] def test_a_running_job_means_unknown(self, server): @@ -453,7 +462,12 @@ class TestProbeCache: job_id = response.json()["id"] wait_for_status(client, job_id, ("running",)) manager = client.app.state.job_manager - assert manager.probe_cache({"workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"}) is None + assert ( + manager.probe_cache( + {"workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"} + ) + is None + ) client.post(f"/api/jobs/{job_id}/cancel") def test_an_unanswered_probe_is_unknown(self, server): @@ -461,9 +475,13 @@ class TestProbeCache: manager = client.app.state.job_manager manager.worker_manager.ensure_worker() manager.worker_manager.send_command = lambda command: None # swallow it - assert manager.probe_cache( - {"workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"}, timeout=0.05 - ) is None + assert ( + manager.probe_cache( + {"workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"}, + timeout=0.05, + ) + is None + ) ``` - [ ] **Step 2: Run the tests to verify they fail** @@ -483,31 +501,29 @@ Expected: FAIL - no `_handle_probe_cache`, no `probe_cache`. and beside `_handle_memory_status`: ```python - def _handle_probe_cache(self, command: Dict[str, Any]): - """Which steps the step cache would serve for a run of this command - - the plan's cached_steps (#85). Same fields as an execute command; - loads the workflow, executes nothing. A failure answers - cached: null with the reason rather than an error message, since - an unknown answer is a valid plan and a crashed probe is not. - """ +def _handle_probe_cache(self, command: Dict[str, Any]): + """Which steps the step cache would serve for a run of this command + - the plan's cached_steps (#85). Same fields as an execute command; + loads the workflow, executes nothing. A failure answers + cached: null with the reason rather than an error message, since + an unknown answer is a valid plan and a crashed probe is not. + """ + try: + workflow, _ = self._load_workflow(command, command["output_dir"]) + asset_token = ( + activate_asset_dir(command["asset_dir"]) + if command.get("asset_dir") + else None + ) try: - workflow, _ = self._load_workflow(command, command["output_dir"]) - asset_token = ( - activate_asset_dir(command["asset_dir"]) - if command.get("asset_dir") - else None - ) - try: - cached = workflow.cache_hits(command.get("arguments") or {}) - finally: - if asset_token is not None: - deactivate_asset_dir(asset_token) - self.result_queue.put({"type": "probe_cache", "cached": cached}) - except Exception as e: - logger.debug(f"Cache probe failed: {e}") - self.result_queue.put( - {"type": "probe_cache", "cached": None, "error": str(e)} - ) + cached = workflow.cache_hits(command.get("arguments") or {}) + finally: + if asset_token is not None: + deactivate_asset_dir(asset_token) + self.result_queue.put({"type": "probe_cache", "cached": cached}) + except Exception as e: + logger.debug(f"Cache probe failed: {e}") + self.result_queue.put({"type": "probe_cache", "cached": None, "error": str(e)}) ``` `dw/server/jobs.py`, beside `memory_status`: @@ -615,38 +631,40 @@ class TestCachedSteps: Append to `tests/test_server.py` inside `TestValidatePlan`: ```python - def test_cached_steps_comes_from_the_worker(self, server, monkeypatch): - import dw.plan - - monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) - with server(success_script) as client: - manager = client.app.state.job_manager - manager.worker_manager.ensure_worker() - manager.worker_manager.cached_steps = ["gen"] - seeded = valid_workflow("seeded") - seeded["seed"] = 7 - result = client.post( - "/api/validate?sizes=false", json={"workflow": seeded, "arguments": {"prompt": "x"}} - ).json() - assert result["plan"]["cached_steps"] == 1 - probe = [c for c in manager.worker_manager.commands if c["type"] == "probe_cache"] - assert len(probe) == 1 - assert probe[0]["arguments"] == {"prompt": "x"} - assert probe[0]["workflow"] == seeded - assert probe[0]["output_dir"] == manager.output_dir +def test_cached_steps_comes_from_the_worker(self, server, monkeypatch): + import dw.plan - def test_an_unseeded_workflow_does_not_probe(self, server, monkeypatch): - import dw.plan + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + with server(success_script) as client: + manager = client.app.state.job_manager + manager.worker_manager.ensure_worker() + manager.worker_manager.cached_steps = ["gen"] + seeded = valid_workflow("seeded") + seeded["seed"] = 7 + result = client.post( + "/api/validate?sizes=false", + json={"workflow": seeded, "arguments": {"prompt": "x"}}, + ).json() + assert result["plan"]["cached_steps"] == 1 + probe = [c for c in manager.worker_manager.commands if c["type"] == "probe_cache"] + assert len(probe) == 1 + assert probe[0]["arguments"] == {"prompt": "x"} + assert probe[0]["workflow"] == seeded + assert probe[0]["output_dir"] == manager.output_dir + + +def test_an_unseeded_workflow_does_not_probe(self, server, monkeypatch): + import dw.plan - monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) - with server(success_script) as client: - manager = client.app.state.job_manager - manager.worker_manager.ensure_worker() - result = client.post( - "/api/validate?sizes=false", json={"workflow": valid_workflow("v")} - ).json() - assert result["plan"]["cached_steps"] == 0 - assert all(c["type"] != "probe_cache" for c in manager.worker_manager.commands) + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + with server(success_script) as client: + manager = client.app.state.job_manager + manager.worker_manager.ensure_worker() + result = client.post( + "/api/validate?sizes=false", json={"workflow": valid_workflow("v")} + ).json() + assert result["plan"]["cached_steps"] == 0 + assert all(c["type"] != "probe_cache" for c in manager.worker_manager.commands) ``` - [ ] **Step 2: Run the tests to verify they fail** @@ -768,7 +786,10 @@ class TestAcknowledgementRecord: manager = client.app.state.job_manager bound = {"fingerprint": "sha256:abc", "minutes": 3.0, "downloads": []} job = manager.submit( - workflow=valid_workflow(), arguments={}, acknowledged="bound", acknowledged_cost=bound + workflow=valid_workflow(), + arguments={}, + acknowledged="bound", + acknowledged_cost=bound, ) detail = manager.describe(job) assert detail["acknowledged"] == "bound" @@ -778,15 +799,24 @@ class TestAcknowledgementRecord: def test_history_keeps_the_form_and_the_object(self, server, tmp_path): with server(success_script) as client: manager = client.app.state.job_manager - bound = {"fingerprint": "sha256:abc", "minutes": 3.0, "downloads": ["org/x"]} + bound = { + "fingerprint": "sha256:abc", + "minutes": 3.0, + "downloads": ["org/x"], + } job = manager.submit( - workflow=valid_workflow(), arguments={}, acknowledged="bound", acknowledged_cost=bound + workflow=valid_workflow(), + arguments={}, + acknowledged="bound", + acknowledged_cost=bound, ) wait_for_status(client, job.id, TERMINAL_STATES) row = manager.history.get(job.id) assert row["acknowledged"] == "bound" assert row["spec"]["acknowledged_cost"] == bound - listed = [s for s in manager.history.recent_summaries() if s["id"] == job.id] + listed = [ + s for s in manager.history.recent_summaries() if s["id"] == job.id + ] assert listed[0]["acknowledged"] == "bound" def test_a_database_without_the_column_is_migrated(self, tmp_path): @@ -813,7 +843,10 @@ class TestAcknowledgementRecord: manager = client.app.state.job_manager bound = {"fingerprint": "sha256:abc", "minutes": 3.0, "downloads": []} job = manager.submit( - workflow=valid_workflow(), arguments={}, acknowledged="bound", acknowledged_cost=bound + workflow=valid_workflow(), + arguments={}, + acknowledged="bound", + acknowledged_cost=bound, ) wait_for_status(client, job.id, TERMINAL_STATES) rerun = manager.rerun(job.id) @@ -848,12 +881,10 @@ ACK_BOUND = "bound" Schema migration, after the `run_dir` block: ```python - # Which form of cost acknowledgement queued the job. Rows before - # the column are 'none' - nothing recorded is nothing recorded - if "acknowledged" not in columns: - connection.execute( - "ALTER TABLE jobs ADD COLUMN acknowledged TEXT DEFAULT 'none'" - ) +# Which form of cost acknowledgement queued the job. Rows before +# the column are 'none' - nothing recorded is nothing recorded +if "acknowledged" not in columns: + connection.execute("ALTER TABLE jobs ADD COLUMN acknowledged TEXT DEFAULT 'none'") ``` `record`: add `acknowledged` to the column list and `job.acknowledged` to the values (17 placeholders). `recent_summaries`: add `acknowledged` to the SELECT and `"acknowledged": row[9] or ACK_NONE` to each summary. `get`: add `acknowledged` to the SELECT; `_to_detail`: `"acknowledged": row[15] or ACK_NONE, "acknowledged_cost": (parse(row[7], {}) or {}).get("acknowledged_cost")` - reuse the parsed spec rather than parsing twice. @@ -963,7 +994,8 @@ class TestBoundAcknowledgement: with server(success_script) as client: plan = plan_for(client, list_workflow()) response = client.post( - "/api/jobs", json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)} + "/api/jobs", + json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)}, ) assert response.status_code == 201, response.json() assert response.json()["acknowledged"] == "bound" @@ -975,7 +1007,11 @@ class TestBoundAcknowledgement: longer = {"shots": [{"name": n, "prompt": n} for n in "abc"]} response = client.post( "/api/jobs", - json={"workflow": list_workflow(), "arguments": longer, "acknowledged_cost": bound(plan)}, + json={ + "workflow": list_workflow(), + "arguments": longer, + "acknowledged_cost": bound(plan), + }, ) assert response.status_code == 409 detail = response.json()["detail"] @@ -991,7 +1027,11 @@ class TestBoundAcknowledgement: plan = plan_for(client, list_workflow()) response = client.post( "/api/jobs", - json={"workflow": list_workflow(), "arguments": {"seed": 99}, "acknowledged_cost": bound(plan)}, + json={ + "workflow": list_workflow(), + "arguments": {"seed": 99}, + "acknowledged_cost": bound(plan), + }, ) assert response.status_code == 201 @@ -1001,28 +1041,39 @@ class TestBoundAcknowledgement: acknowledgement = bound(plan) acknowledgement["downloads"] = [] # the caller left the repo out response = client.post( - "/api/jobs", json={"workflow": list_workflow(), "acknowledged_cost": acknowledgement} + "/api/jobs", + json={ + "workflow": list_workflow(), + "acknowledged_cost": acknowledgement, + }, ) assert response.status_code == 409 detail = response.json()["detail"] assert detail["reason"] == "downloads" assert "m" in detail["message"] - def test_a_download_that_vanished_is_not_a_refusal(self, server, no_hub, monkeypatch): + def test_a_download_that_vanished_is_not_a_refusal( + self, server, no_hub, monkeypatch + ): with server(success_script) as client: plan = plan_for(client, list_workflow()) assert bound(plan)["downloads"] == ["m"] import dw.plan monkeypatch.setattr( - dw.plan, "scan_models", lambda cache_dir=None: {"repos": [{"repo_id": "m"}]} + dw.plan, + "scan_models", + lambda cache_dir=None: {"repos": [{"repo_id": "m"}]}, ) response = client.post( - "/api/jobs", json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)} + "/api/jobs", + json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)}, ) assert response.status_code == 201 - def test_an_unplannable_run_is_refused_not_passed(self, server, no_hub, monkeypatch): + def test_an_unplannable_run_is_refused_not_passed( + self, server, no_hub, monkeypatch + ): import dw.server.app as app_module with server(success_script) as client: @@ -1033,7 +1084,8 @@ class TestBoundAcknowledgement: monkeypatch.setattr(app_module, "build_plan", boom) response = client.post( - "/api/jobs", json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)} + "/api/jobs", + json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)}, ) assert response.status_code == 409 assert response.json()["detail"]["reason"] == "unplannable" @@ -1049,10 +1101,12 @@ class TestBoundAcknowledgement: with server(success_script) as client: plain = client.post("/api/jobs", json={"workflow": valid_workflow("p")}) flagged = client.post( - "/api/jobs", json={"workflow": valid_workflow("f"), "acknowledged_cost": True} + "/api/jobs", + json={"workflow": valid_workflow("f"), "acknowledged_cost": True}, ) off = client.post( - "/api/jobs", json={"workflow": valid_workflow("o"), "acknowledged_cost": False} + "/api/jobs", + json={"workflow": valid_workflow("o"), "acknowledged_cost": False}, ) assert plain.json()["acknowledged"] == "none" assert flagged.json()["acknowledged"] == "boolean" @@ -1061,11 +1115,17 @@ class TestBoundAcknowledgement: def test_a_bound_form_without_a_fingerprint_is_a_422(self, server): with server(success_script) as client: response = client.post( - "/api/jobs", json={"workflow": valid_workflow(), "acknowledged_cost": {"minutes": 3}} + "/api/jobs", + json={ + "workflow": valid_workflow(), + "acknowledged_cost": {"minutes": 3}, + }, ) assert response.status_code == 422 - def test_a_stored_prompt_edited_after_validation_is_refused(self, server, no_hub, tmp_path): + def test_a_stored_prompt_edited_after_validation_is_refused( + self, server, no_hub, tmp_path + ): (tmp_path / "prompts" / "p.json").write_text(json.dumps({"text": "before"})) workflow = valid_workflow("prompted") workflow["variables"]["prompt"] = "prompt:p" @@ -1073,18 +1133,22 @@ class TestBoundAcknowledgement: plan = plan_for(client, workflow) (tmp_path / "prompts" / "p.json").write_text(json.dumps({"text": "after"})) response = client.post( - "/api/jobs", json={"workflow": workflow, "acknowledged_cost": bound(plan)} + "/api/jobs", + json={"workflow": workflow, "acknowledged_cost": bound(plan)}, ) assert response.status_code == 409 assert response.json()["detail"]["reason"] == "fingerprint" class TestBoundRerun: - def test_a_rerun_with_the_original_plan_queues_even_with_a_new_seed(self, server, no_hub): + def test_a_rerun_with_the_original_plan_queues_even_with_a_new_seed( + self, server, no_hub + ): with server(success_script) as client: plan = plan_for(client, list_workflow()) first = client.post( - "/api/jobs", json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)} + "/api/jobs", + json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)}, ).json() wait_for_status(client, first["id"], TERMINAL_STATES) response = client.post( @@ -1100,7 +1164,8 @@ class TestBoundRerun: wait_for_status(client, first["id"], TERMINAL_STATES) other = plan_for(client, valid_workflow("other")) response = client.post( - f"/api/jobs/{first['id']}/rerun", json={"acknowledged_cost": bound(other)} + f"/api/jobs/{first['id']}/rerun", + json={"acknowledged_cost": bound(other)}, ) assert response.status_code == 409 assert response.json()["detail"]["reason"] == "fingerprint" @@ -1109,14 +1174,17 @@ class TestBoundRerun: with server(success_script) as client: first = client.post("/api/jobs", json={"workflow": list_workflow()}).json() wait_for_status(client, first["id"], TERMINAL_STATES) - response = client.post(f"/api/jobs/{first['id']}/rerun", json={"acknowledged_cost": True}) + response = client.post( + f"/api/jobs/{first['id']}/rerun", json={"acknowledged_cost": True} + ) assert response.status_code == 201 assert response.json()["acknowledged"] == "boolean" def test_an_unknown_job_is_still_404(self, server): with server(success_script) as client: response = client.post( - "/api/jobs/nope/rerun", json={"acknowledged_cost": {"fingerprint": "sha256:0"}} + "/api/jobs/nope/rerun", + json={"acknowledged_cost": {"fingerprint": "sha256:0"}}, ) assert response.status_code == 404 ``` @@ -1165,95 +1233,100 @@ ACKNOWLEDGED_COST_FIELD = Field( Helpers inside `create_app`, beside `_argument_reference_errors`: ```python - def _acknowledgement_form(value): - """none | boolean | bound - classified once, here, so the check and - the record agree.""" - if isinstance(value, AcknowledgedCost): - return ACK_BOUND - return ACK_BOOLEAN if value is True else ACK_NONE - - def _check_bound_acknowledgement(candidate, arguments, acknowledged, workspace): - """Refuse with 409 when the run `candidate` + `arguments` will - execute is not the one `acknowledged` was bound to: a different - fingerprint, or a download the caller did not acknowledge. The body - carries the current plan so the agent re-quotes from it without a - second validate call. A plan that cannot be built is a refusal too - - never a silent pass (#85). - """ - record = acknowledged.model_dump() - try: - from .. import get_device, get_device_type +def _acknowledgement_form(value): + """none | boolean | bound - classified once, here, so the check and + the record agree.""" + if isinstance(value, AcknowledgedCost): + return ACK_BOUND + return ACK_BOOLEAN if value is True else ACK_NONE + + +def _check_bound_acknowledgement(candidate, arguments, acknowledged, workspace): + """Refuse with 409 when the run `candidate` + `arguments` will + execute is not the one `acknowledged` was bound to: a different + fingerprint, or a download the caller did not acknowledge. The body + carries the current plan so the agent re-quotes from it without a + second validate call. A plan that cannot be built is a refusal too - + never a silent pass (#85). + """ + record = acknowledged.model_dump() + try: + from .. import get_device, get_device_type + + current = build_plan( + candidate, + arguments, + device=get_device_type(get_device()), + prompt_dir=workspace.prompts, + lookup_sizes=False, + ) + except Exception: + logger.exception("Plan could not be built for a bound acknowledgement") + raise HTTPException( + status_code=409, + detail={ + "message": "The run could not be planned, so a bound " + "acknowledgement cannot be checked; acknowledge with true " + "or validate again", + "reason": "unplannable", + "acknowledged": record, + "plan": None, + }, + ) + if current["fingerprint"] != acknowledged.fingerprint: + raise HTTPException( + status_code=409, + detail={ + "message": "The run's shape changed since it was acknowledged: " + "the workflow or its arguments differ from what was validated", + "reason": "fingerprint", + "acknowledged": record, + "plan": current, + }, + ) + missing = [ + entry["repo"] + for entry in current["downloads_required"] + if entry.get("repo") and entry["repo"] not in acknowledged.downloads + ] + if missing: + raise HTTPException( + status_code=409, + detail={ + "message": "The run's shape changed since it was acknowledged: " + f"it now has to download {', '.join(missing)} first", + "reason": "downloads", + "acknowledged": record, + "plan": current, + }, + ) - current = build_plan( - candidate, - arguments, - device=get_device_type(get_device()), - prompt_dir=workspace.prompts, - lookup_sizes=False, - ) - except Exception: - logger.exception("Plan could not be built for a bound acknowledgement") - raise HTTPException( - status_code=409, - detail={ - "message": "The run could not be planned, so a bound " - "acknowledgement cannot be checked; acknowledge with true " - "or validate again", - "reason": "unplannable", - "acknowledged": record, - "plan": None, - }, - ) - if current["fingerprint"] != acknowledged.fingerprint: - raise HTTPException( - status_code=409, - detail={ - "message": "The run's shape changed since it was acknowledged: " - "the workflow or its arguments differ from what was validated", - "reason": "fingerprint", - "acknowledged": record, - "plan": current, - }, - ) - missing = [ - entry["repo"] - for entry in current["downloads_required"] - if entry.get("repo") and entry["repo"] not in acknowledged.downloads - ] - if missing: - raise HTTPException( - status_code=409, - detail={ - "message": "The run's shape changed since it was acknowledged: " - f"it now has to download {', '.join(missing)} first", - "reason": "downloads", - "acknowledged": record, - "plan": current, - }, - ) - def _candidate_for(workflow_path, workflow, base_dir, output_dir, workflow_dir): - """The Workflow a job spec names, built as the worker will build it.""" - if workflow_path is not None: - return workflow_from_file(workflow_path, output_dir, workflow_dir) - return workflow_from_definition( - copy.deepcopy(workflow), output_dir, base_dir, workflow_dir - ) +def _candidate_for(workflow_path, workflow, base_dir, output_dir, workflow_dir): + """The Workflow a job spec names, built as the worker will build it.""" + if workflow_path is not None: + return workflow_from_file(workflow_path, output_dir, workflow_dir) + return workflow_from_definition( + copy.deepcopy(workflow), output_dir, base_dir, workflow_dir + ) ``` In `submit_job`, after the `reference_problems` check and before `manager.submit(...)`: ```python - form = _acknowledgement_form(request.acknowledged_cost) - if form == ACK_BOUND: - confinement = source.root if source else workspace.workflows - candidate = _candidate_for( - resolved, request.workflow, request.base_dir, - workspace.outputs, confinement, - ) - _check_bound_acknowledgement( - candidate, request.arguments, request.acknowledged_cost, workspace - ) +form = _acknowledgement_form(request.acknowledged_cost) +if form == ACK_BOUND: + confinement = source.root if source else workspace.workflows + candidate = _candidate_for( + resolved, + request.workflow, + request.base_dir, + workspace.outputs, + confinement, + ) + _check_bound_acknowledgement( + candidate, request.arguments, request.acknowledged_cost, workspace + ) ``` and pass to `manager.submit`: `acknowledged=form, acknowledged_cost=(request.acknowledged_cost.model_dump() if form == ACK_BOUND else None)`. The `except HTTPException: raise` already precedes the catch-all, so the 409 passes through. @@ -1261,36 +1334,41 @@ and pass to `manager.submit`: `acknowledged=form, acknowledged_cost=(request.ack In `rerun_job`: ```python - form = _acknowledgement_form(body.acknowledged_cost) - if form == ACK_BOUND: - prepared = manager.rerun_spec(job_id) - if prepared is None: - raise HTTPException(status_code=404, detail="Unknown job") - spec, arguments = prepared - try: - candidate = _candidate_for( - spec.get("workflow_path"), spec.get("workflow"), spec.get("base_dir"), - spec.get("output_dir") or manager.output_dir, spec.get("workflow_dir"), - ) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - _check_bound_acknowledgement( - candidate, arguments, body.acknowledged_cost, - _workspace_for(spec.get("workspace")), - ) - try: - job = manager.rerun( - job_id, - new_seed=body.new_seed, - acknowledged=form, - acknowledged_cost=( - body.acknowledged_cost.model_dump() if form == ACK_BOUND else None - ), - ) - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) +form = _acknowledgement_form(body.acknowledged_cost) +if form == ACK_BOUND: + prepared = manager.rerun_spec(job_id) + if prepared is None: + raise HTTPException(status_code=404, detail="Unknown job") + spec, arguments = prepared + try: + candidate = _candidate_for( + spec.get("workflow_path"), + spec.get("workflow"), + spec.get("base_dir"), + spec.get("output_dir") or manager.output_dir, + spec.get("workflow_dir"), + ) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + _check_bound_acknowledgement( + candidate, + arguments, + body.acknowledged_cost, + _workspace_for(spec.get("workspace")), + ) +try: + job = manager.rerun( + job_id, + new_seed=body.new_seed, + acknowledged=form, + acknowledged_cost=( + body.acknowledged_cost.model_dump() if form == ACK_BOUND else None + ), + ) +except HTTPException: + raise +except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) ``` (`_workspace_for(None)` is the default workspace - see its first line.) Update the route docstring: `Pass acknowledged_cost as on POST /api/jobs; a bound one is checked against the stored spec's plan - the fresh seed of new_seed does not change a fingerprint.` @@ -1350,7 +1428,9 @@ def test_run_does_not_send_a_bare_true(): def test_run_refuses_a_bound_form_without_a_fingerprint(): client, seen = submitting() with pytest.raises(DwApiError, match="fingerprint"): - diagnose.run_workflow(client, workflow_path="w.json", acknowledged_cost={"minutes": 4}) + diagnose.run_workflow( + client, workflow_path="w.json", acknowledged_cost={"minutes": 4} + ) assert seen == [] @@ -1377,7 +1457,13 @@ def test_a_409_surfaces_with_the_new_estimate(): "list_entries": {"shots": 5}, "cached_steps": None, "downloads_required": [{"repo": "org/y", "gb": 3.5}], - "estimate": {"minutes": 19.0, "basis": "per_entry", "device": "cuda", "measured_on": "card", "partial": False}, + "estimate": { + "minutes": 19.0, + "basis": "per_entry", + "device": "cuda", + "measured_on": "card", + "partial": False, + }, }, } }, @@ -1427,8 +1513,8 @@ COST_REFUSAL = ( "with (free): its `plan` says what will execute - `estimate.minutes` with " "its `basis`, and any weights in `downloads_required` this box has to " "fetch first. Tell the user that number, get their go-ahead, then call " - "again with acknowledged_cost bound to the plan: {\"fingerprint\": " - "plan.fingerprint, \"minutes\": plan.estimate.minutes, \"downloads\": " + 'again with acknowledged_cost bound to the plan: {"fingerprint": ' + 'plan.fingerprint, "minutes": plan.estimate.minutes, "downloads": ' "[each downloads_required repo]} - the server then refuses (409) if the " "run's shape changed since. Pass true instead only when `plan` was null." ) diff --git a/docs/superpowers/specs/2026-03-26-ecosystem-utilities-design.md b/docs/superpowers/specs/2026-03-26-ecosystem-utilities-design.md index 1c28fb0d..8ccdbcb7 100644 --- a/docs/superpowers/specs/2026-03-26-ecosystem-utilities-design.md +++ b/docs/superpowers/specs/2026-03-26-ecosystem-utilities-design.md @@ -46,6 +46,7 @@ A new `segment` task command that takes an image + text prompt and returns a bin # In task.py from .segment import segment_image + @register_command("segment") def _handle_segment(task, arguments, previous_pipelines): """Segment objects in an image using text prompt""" @@ -160,6 +161,7 @@ A new `interpolate_frames` task that takes video frames and returns interpolated # In task.py from .interpolate_frames import interpolate_frames + @register_command("interpolate_frames") def _handle_interpolate_frames(task, arguments, previous_pipelines): """Interpolate video frames to increase frame rate""" diff --git a/docs/superpowers/specs/2026-09-04-remote-gpu-server-design.md b/docs/superpowers/specs/2026-09-04-remote-gpu-server-design.md index 1df0dd20..5e9416e0 100644 --- a/docs/superpowers/specs/2026-09-04-remote-gpu-server-design.md +++ b/docs/superpowers/specs/2026-09-04-remote-gpu-server-design.md @@ -110,19 +110,23 @@ there is no UI-style "paste the token" gate in front of it. ```python def mount_mcp(app, *, port, token): - from dw_mcp.client import DwClient # dw_mcp is a pure HTTP client - from dw_mcp.server import build_server # only module importing the SDK + from dw_mcp.client import DwClient # dw_mcp is a pure HTTP client + from dw_mcp.server import build_server # only module importing the SDK from mcp.server.transport_security import TransportSecuritySettings + client = DwClient(base_url=f"http://127.0.0.1:{port}", token=token) server = build_server(client) asgi = server.streamable_http_app( - streamable_http_path="/", # the SDK app routes at "/" ... + streamable_http_path="/", # the SDK app routes at "/" ... stateless_http=True, transport_security=TransportSecuritySettings( - enable_dns_rebinding_protection=False), # app.py's checks own this + enable_dns_rebinding_protection=False + ), # app.py's checks own this ) - app.mount("/mcp", asgi) # ... so the mount makes it "/mcp" (verified against mcp 2.1.1) - return client # closed in the app's lifespan + app.mount( + "/mcp", asgi + ) # ... so the mount makes it "/mcp" (verified against mcp 2.1.1) + return client # closed in the app's lifespan ``` The import of `mcp` is inside the function; a missing package produces diff --git a/docs/superpowers/specs/2026-09-08-job-record-and-export-design.md b/docs/superpowers/specs/2026-09-08-job-record-and-export-design.md index 976c670f..1cf0a030 100644 --- a/docs/superpowers/specs/2026-09-08-job-record-and-export-design.md +++ b/docs/superpowers/specs/2026-09-08-job-record-and-export-design.md @@ -38,8 +38,9 @@ that workflow and can export one job as a git-ready directory and a zip. New `dw/realize.py`: ```python -def realize_workflow(definition, arguments, seed, base_dir=None, - prompt_dir=None, output_root=None): +def realize_workflow( + definition, arguments, seed, base_dir=None, prompt_dir=None, output_root=None +): """A copy of `definition` with every mutable input pinned. Returns (realized, annotations).""" ``` @@ -77,8 +78,12 @@ In `Workflow.run`, immediately after the run directory is chosen ```python realized, annotations = realize_workflow( - self.workflow_definition, arguments, resolved_seed, - base_dir=self.base_dir, output_root=self.output_dir) + self.workflow_definition, + arguments, + resolved_seed, + base_dir=self.base_dir, + output_root=self.output_dir, +) write_realized_workflow(self._run_dir, realized) ``` diff --git a/docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md b/docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md index 6cf5f3d0..54c4b84e 100644 --- a/docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md +++ b/docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md @@ -65,7 +65,7 @@ def build_plan( workflow_dir=None, device, cache_dir=None, - cache_probe=None, # stage 2 + cache_probe=None, # stage 2 lookup_sizes=True, ): """What a run of `definition` with `arguments` will execute and cost.""" @@ -281,9 +281,9 @@ acknowledged_cost: bool | AcknowledgedCost | None = None ```python class AcknowledgedCost(BaseModel): - fingerprint: str # "sha256:…" from plan.fingerprint - minutes: float | None = None # plan.estimate.minutes, recorded only - downloads: list[str] = [] # plan.downloads_required[*].repo, non-null ones + fingerprint: str # "sha256:…" from plan.fingerprint + minutes: float | None = None # plan.estimate.minutes, recorded only + downloads: list[str] = [] # plan.downloads_required[*].repo, non-null ones ``` The form is classified once, in the route: diff --git a/dw/arguments.py b/dw/arguments.py index 4d57ff4b..a0da8751 100644 --- a/dw/arguments.py +++ b/dw/arguments.py @@ -906,7 +906,7 @@ def fetch_image(img_spec, base_dir=None): # If already a PIL Image, return as-is (allows multiple realize_args calls) if hasattr(img_spec, "mode") and hasattr(img_spec, "size"): - logger.debug(f"Image already loaded, returning as-is") + logger.debug("Image already loaded, returning as-is") return img_spec # Handle dict format: {"location": "url_or_path"} @@ -997,12 +997,12 @@ def fetch_video(video_spec, base_dir=None): return [fetch_video(vid, base_dir) for vid in video_spec] # Otherwise assume it's already loaded video frames else: - logger.debug(f"Video frames already loaded, returning as-is") + logger.debug("Video frames already loaded, returning as-is") return video_spec # If already loaded video frames (tuple), return as-is if isinstance(video_spec, tuple): - logger.debug(f"Video frames already loaded, returning as-is") + logger.debug("Video frames already loaded, returning as-is") return video_spec # Handle dict format: {"location": "url_or_path"} diff --git a/dw/repl.py b/dw/repl.py index ca52c4bd..75412691 100644 --- a/dw/repl.py +++ b/dw/repl.py @@ -261,7 +261,7 @@ def _print_memory_info(self, info): self._print_host_memory(info) return - print(f"\nGPU Memory Status:") + print("\nGPU Memory Status:") print(f" Device: {info.get('gpu_device_name', 'Unknown')}") print(f" Allocated: {info.get('gpu_memory_allocated_mb', 0):.1f} MB") print(f" Reserved: {info.get('gpu_memory_reserved_mb', 0):.1f} MB") @@ -279,7 +279,7 @@ def _print_host_memory(self, info): weights the card says very little about what a run is holding.""" if "host_memory_rss_mb" not in info: return - print(f"\nHost Memory:") + print("\nHost Memory:") print(f" Worker RSS: {info['host_memory_rss_mb']:.1f} MB") if "host_memory_peak_rss_mb" in info: print(f" Worker peak RSS: {info['host_memory_peak_rss_mb']:.1f} MB") diff --git a/dw/serve.py b/dw/serve.py index caed92cf..8c471eac 100644 --- a/dw/serve.py +++ b/dw/serve.py @@ -38,13 +38,12 @@ def main(): parser.add_argument( "--workflow-dir", default=None, - help="Directory of workflow JSON files (default: the workspace's " - "workflows/)", + help="Directory of workflow JSON files (default: the workspace's workflows/)", ) parser.add_argument( "--output-dir", default=None, - help="Directory results are written to (default: the workspace's " "outputs/)", + help="Directory results are written to (default: the workspace's outputs/)", ) parser.add_argument( "--prompt-dir", diff --git a/dw/server/app.py b/dw/server/app.py index 48a2b263..ac297b29 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -2108,8 +2108,6 @@ def enhance(request: EnhanceRequest, ws: Workspace = Depends(selected_workspace) # exactly once - the gallery had already drifted (.bmp, .mkv, .mov) from ..security import ( ALLOWED_AUDIO_EXTENSIONS, - ALLOWED_IMAGE_EXTENSIONS, - ALLOWED_VIDEO_EXTENSIONS, ) MEDIA_KINDS = { @@ -2267,8 +2265,12 @@ def _iter_gallery_files(root, group_runs=True): folder, _run_id, subfolder = split_run_path(relative_name) else: folder, subfolder = directory, "" - yield relative_name, folder, subfolder, kind, os.path.join( - current, name + yield ( + relative_name, + folder, + subfolder, + kind, + os.path.join(current, name), ) def _gallery_entries(root, ws): diff --git a/dw/server/guides.py b/dw/server/guides.py index 69a799c6..d0660e48 100644 --- a/dw/server/guides.py +++ b/dw/server/guides.py @@ -63,7 +63,7 @@ ), "prompt-weighting": ( "PROMPT_WEIGHTING.md", - "Emphasis and de-emphasis syntax in prompts, and which pipelines " "honour it.", + "Emphasis and de-emphasis syntax in prompts, and which pipelines honour it.", ), "ip-adapter": ( "IP_ADAPTER.md", diff --git a/dw/tasks/audio_utils.py b/dw/tasks/audio_utils.py index 29614f9e..89d101a5 100644 --- a/dw/tasks/audio_utils.py +++ b/dw/tasks/audio_utils.py @@ -16,8 +16,6 @@ from ..events import emit_warning from ..security import ( - validate_path, - validate_url, validate_file_extension, ALLOWED_AUDIO_EXTENSIONS, ) @@ -677,8 +675,7 @@ def match_levels(waveforms, measure, target_dbfs=None, command="concat_videos"): """ if measure not in MATCH_MEASURES: raise ValueError( - f"{command} 'match_levels' must be one of {MATCH_MEASURES}, " - f"got '{measure}'" + f"{command} 'match_levels' must be one of {MATCH_MEASURES}, got '{measure}'" ) if target_dbfs is None: target_dbfs = DEFAULT_MATCH_DBFS[measure] diff --git a/dw/tasks/image_utils.py b/dw/tasks/image_utils.py index 7ca0dd54..e31647b7 100644 --- a/dw/tasks/image_utils.py +++ b/dw/tasks/image_utils.py @@ -498,8 +498,8 @@ def add_watermark( "add_border_and_mask": lambda image, device, kwargs: add_border_and_mask( image, **kwargs ), - "add_border_and_mask_with_size": lambda image, device, kwargs: add_border_and_mask_with_size( - image, **kwargs + "add_border_and_mask_with_size": lambda image, device, kwargs: ( + add_border_and_mask_with_size(image, **kwargs) ), "remove_background": _remove_background_handler, # Raw cv2 Canny at native resolution - see image_to_canny() docstring diff --git a/dw/teacache.py b/dw/teacache.py index 0c4f8a32..c919809a 100644 --- a/dw/teacache.py +++ b/dw/teacache.py @@ -110,7 +110,12 @@ def teacache_forward( return_dict: bool = True, controlnet_blocks_repeat: bool = False, ) -> typing.Union[torch.FloatTensor, Transformer2DModelOutput]: - nonlocal cnt, accumulated_rel_l1_distance, previous_modulated_input, previous_residual, previous_timestep + nonlocal \ + cnt, \ + accumulated_rel_l1_distance, \ + previous_modulated_input, \ + previous_residual, \ + previous_timestep # TeaCache assumes exactly one transformer forward call per denoising # step. Pipelines running true classifier-free guidance (e.g. Flux with diff --git a/dw/worker.py b/dw/worker.py index 8e0533a5..52317726 100644 --- a/dw/worker.py +++ b/dw/worker.py @@ -19,7 +19,6 @@ from dw.assets import activate_asset_dir, deactivate_asset_dir from dw.log_setup import setup_logging, set_log_level from dw.settings import load_settings, resolve_path -from dw.security import validate_output_path from dw.events import RunContext, WorkflowCancelled from dw import get_device_type, empty_device_cache, device_memory_stats from dw.host_memory import ( diff --git a/dw/workflow.py b/dw/workflow.py index c81f5eb0..aebe8ee2 100644 --- a/dw/workflow.py +++ b/dw/workflow.py @@ -1009,7 +1009,7 @@ def run( # Execute each step in sequence for i, step_data in enumerate(steps): run_context.check_cancelled() - logger.debug(f"Running step {i+1}/{len(steps)}: {step_data['name']}") + logger.debug(f"Running step {i + 1}/{len(steps)}: {step_data['name']}") run_context.emit( "step_start", workflow=workflow_id, @@ -1364,11 +1364,11 @@ def create_step_action( logger.debug( "Setting up generator for cached pipeline with new arguments" ) - new_pipeline_wrapper.argument_template[ - "generator" - ] = torch.Generator(new_pipeline_wrapper.device).manual_seed( - new_pipeline_wrapper.pipeline_definition.get( - "seed", default_seed + new_pipeline_wrapper.argument_template["generator"] = ( + torch.Generator(new_pipeline_wrapper.device).manual_seed( + new_pipeline_wrapper.pipeline_definition.get( + "seed", default_seed + ) ) ) diff --git a/dw_mcp/diagnose.py b/dw_mcp/diagnose.py index 13a5d09c..75b39aed 100644 --- a/dw_mcp/diagnose.py +++ b/dw_mcp/diagnose.py @@ -256,8 +256,7 @@ def wait_for_job(client, job_id, timeout_seconds=20): remaining = deadline - time.monotonic() if remaining <= 0: next_step = ( - "Call wait_for_job again, or get_job_events for incremental " - "progress." + "Call wait_for_job again, or get_job_events for incremental progress." ) if capped: next_step = ( diff --git a/pyproject.toml b/pyproject.toml index 99177a80..11b1a16b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ dev = [ "pytest-mock>=3.15.1", "pytest-asyncio>=1.4.0", "httpx>=0.28.1", - "black>=26.5.1", + "ruff>=0.15.0", "build", "mcp>=2.1.1", ] @@ -111,8 +111,24 @@ dw-serve = "dw.serve:main" dw-test = "dw.test:main" dw-mcp = "dw_mcp.__main__:main" -[tool.black] -target-version = ["py310"] +[tool.ruff] +target-version = "py310" + +[tool.ruff.lint] +# Start conservative: pyflakes + pycodestyle only. Widen (e.g. add "I" for +# import sorting) once the existing codebase is clean under those too. +select = ["E", "F"] +# The formatter already wraps code to the line length; E501 mostly flags +# comments/strings/URLs that can't be split, so it's noise on top of that. +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +# These import a pytest fixture by name and reuse that name as a test +# function's parameter (the idiomatic way to share a fixture across +# modules) - pyflakes/ruff can't tell that from a genuine redefinition. +"tests/test_jobs_listing.py" = ["F811"] +"tests/test_server_downloads.py" = ["F811"] +"tests/test_server_exports.py" = ["F811"] [tool.setuptools.packages.find] # tasks/, pipeline_processors/ and community_pipelines/ are namespace diff --git a/requirements-test.txt b/requirements-test.txt index 410a50b8..db1d71aa 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,4 +1,4 @@ -# Test and development tooling (pytest and friends, black, flake8, build) +# Test and development tooling (pytest and friends, ruff, build) # # The list lives in pyproject.toml's [project.optional-dependencies] dev # extra - this file just points pip at it. diff --git a/tests/README.md b/tests/README.md index 2fe25d7e..be3d87bb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -141,11 +141,12 @@ Example: import pytest from dw.my_module import my_function + class TestMyFunction: def test_normal_case(self): result = my_function("input") assert result == "expected" - + def test_error_case(self): with pytest.raises(ValueError): my_function("invalid") diff --git a/tests/conftest.py b/tests/conftest.py index 38affd26..4d4db639 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ import pytest import os -import json import tempfile import warnings from PIL import Image diff --git a/tests/run_tests.py b/tests/run_tests.py index f216630a..5c99e279 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -4,7 +4,6 @@ """ import sys -import subprocess def main(): @@ -33,7 +32,7 @@ def main(): # Add coverage if pytest-cov is available try: - import pytest_cov + import pytest_cov # noqa: F401 - presence is the check args.extend( [ diff --git a/tests/test_argument_updates.py b/tests/test_argument_updates.py index 2c3b98d6..c6055914 100644 --- a/tests/test_argument_updates.py +++ b/tests/test_argument_updates.py @@ -7,7 +7,7 @@ import os import sys import logging -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import patch, MagicMock # Add parent directory to path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -52,8 +52,6 @@ def test_cached_pipeline_uses_new_arguments(): captured_arguments = [] original_pipeline_init = Pipeline.__init__ - original_pipeline_load = Pipeline.load - original_pipeline_run = Pipeline.run def mock_pipeline_init(self, *args, **kwargs): original_pipeline_init(self, *args, **kwargs) @@ -73,7 +71,6 @@ def mock_pipeline_run(self, arguments, *args, **kwargs): with patch.object(Pipeline, "__init__", mock_pipeline_init): with patch.object(Pipeline, "load", mock_pipeline_load): with patch.object(Pipeline, "run", mock_pipeline_run): - pipeline_cache = {} # First run - should create and cache pipeline @@ -88,7 +85,7 @@ def mock_pipeline_run(self, arguments, *args, **kwargs): # Simulate step.run() calling action.run() action1.run({"prompt": "a cat", "num_inference_steps": 20}, {}) - logger.info(f"✅ Run 1 complete") + logger.info("✅ Run 1 complete") logger.info(f" Prompt passed: '{captured_arguments[-1]['prompt']}'") logger.info( f" Steps passed: {captured_arguments[-1]['num_inference_steps']}" @@ -112,7 +109,7 @@ def mock_pipeline_run(self, arguments, *args, **kwargs): # Simulate step.run() calling action.run() action2.run({"prompt": "a dog", "num_inference_steps": 30}, {}) - logger.info(f"✅ Run 2 complete") + logger.info("✅ Run 2 complete") logger.info(f" Prompt passed: '{captured_arguments[-1]['prompt']}'") logger.info( f" Steps passed: {captured_arguments[-1]['num_inference_steps']}" @@ -123,28 +120,28 @@ def mock_pipeline_run(self, arguments, *args, **kwargs): logger.info("VERIFICATION") logger.info("=" * 60) - assert ( - len(captured_arguments) == 2 - ), f"Expected 2 runs, got {len(captured_arguments)}" - logger.info(f"✅ Both runs executed") + assert len(captured_arguments) == 2, ( + f"Expected 2 runs, got {len(captured_arguments)}" + ) + logger.info("✅ Both runs executed") run1_prompt = captured_arguments[0].get("prompt") run2_prompt = captured_arguments[1].get("prompt") - assert ( - run1_prompt == "a cat" - ), f"Run 1 should have 'a cat', got '{run1_prompt}'" + assert run1_prompt == "a cat", ( + f"Run 1 should have 'a cat', got '{run1_prompt}'" + ) logger.info(f"✅ Run 1 used correct prompt: '{run1_prompt}'") - assert ( - run2_prompt == "a dog" - ), f"Run 2 should have 'a dog', got '{run2_prompt}'" + assert run2_prompt == "a dog", ( + f"Run 2 should have 'a dog', got '{run2_prompt}'" + ) logger.info(f"✅ Run 2 used NEW prompt: '{run2_prompt}'") - assert ( - run1_prompt != run2_prompt - ), "Arguments should be different between runs!" - logger.info(f"✅ Arguments changed between runs") + assert run1_prompt != run2_prompt, ( + "Arguments should be different between runs!" + ) + logger.info("✅ Arguments changed between runs") run1_steps = captured_arguments[0].get("num_inference_steps") run2_steps = captured_arguments[1].get("num_inference_steps") @@ -184,7 +181,6 @@ def test_generator_seed_updates(): pipeline_cache = {} original_pipeline_init = Pipeline.__init__ - original_pipeline_load = Pipeline.load def mock_pipeline_init(self, *args, **kwargs): original_pipeline_init(self, *args, **kwargs) @@ -195,13 +191,12 @@ def mock_pipeline_load(self, *args, **kwargs): with patch.object(Pipeline, "__init__", mock_pipeline_init): with patch.object(Pipeline, "load", mock_pipeline_load): - logger.info("\n" + "=" * 60) logger.info("SEED TEST") logger.info("=" * 60) # Run 1 with seed 100 - action1 = workflow.create_step_action( + workflow.create_step_action( workflow_def["steps"][0], {}, pipeline_cache, 42, get_device() ) seed1 = workflow_def["steps"][0]["pipeline"].get("seed", 42) @@ -216,9 +211,9 @@ def mock_pipeline_load(self, *args, **kwargs): logger.info(f"Run 2: seed={seed2}") # Check that action2 has the new pipeline definition with seed 200 - assert ( - action2.pipeline_definition["seed"] == 200 - ), f"Expected seed 200, got {action2.pipeline_definition.get('seed')}" + assert action2.pipeline_definition["seed"] == 200, ( + f"Expected seed 200, got {action2.pipeline_definition.get('seed')}" + ) logger.info("✅ Pipeline wrapper gets updated seed") diff --git a/tests/test_cache_blocks.py b/tests/test_cache_blocks.py index e57d9ce1..c068a896 100644 --- a/tests/test_cache_blocks.py +++ b/tests/test_cache_blocks.py @@ -7,7 +7,6 @@ """ import inspect -import json import os import sys @@ -236,7 +235,6 @@ def _state_manager(model): def test_cache_state_needs_a_context(): """Without the context, the hook raises - the failure the wrapper prevents.""" - from dw.pipeline_processors.pipeline import stateful_cache_context manager = _state_manager(_cached_tiny_model()) diff --git a/tests/test_catalog_structure.py b/tests/test_catalog_structure.py index c11a107d..e7804cbf 100644 --- a/tests/test_catalog_structure.py +++ b/tests/test_catalog_structure.py @@ -54,9 +54,9 @@ def test_every_model_config_names_the_template_it_configures(path): assert configures, f"{path} has no 'configures'" target = os.path.join(REPO_ROOT, "workflows", f"{configures}.json") - assert os.path.isfile( - target - ), f"{path} configures '{configures}', which is not a workflow ({target})" + assert os.path.isfile(target), ( + f"{path} configures '{configures}', which is not a workflow ({target})" + ) def load(path): @@ -130,13 +130,13 @@ def test_the_rules_read_these_templates_as_expected(path, expected): def test_no_template_falls_through_to_utility(path): meta = derive_catalog_metadata(load(path)) if meta["shape"] == "utility": - assert ( - path in UTILITIES - ), f"{path} derived 'utility' - a rule missed it, or add it to UTILITIES" + assert path in UTILITIES, ( + f"{path} derived 'utility' - a rule missed it, or add it to UTILITIES" + ) else: - assert ( - path not in UTILITIES - ), f"{path} is listed as a utility but derives {meta['shape']}" + assert path not in UTILITIES, ( + f"{path} is listed as a utility but derives {meta['shape']}" + ) @pytest.mark.parametrize("path", TEMPLATES + MODEL_CONFIGS) @@ -150,9 +150,9 @@ def test_a_declaration_must_differ_from_the_derivation(path): } derived = derive_catalog_metadata(stripped) for key in meta["declared"]: - assert ( - meta[key] != derived[key] - ), f"{path} declares {key}={meta[key]!r}, which derivation already produces" + assert meta[key] != derived[key], ( + f"{path} declares {key}={meta[key]!r}, which derivation already produces" + ) @pytest.mark.parametrize("path", TEMPLATES) @@ -160,9 +160,9 @@ def test_every_template_has_a_summary_that_fits(path): meta = derive_catalog_metadata(load(path)) assert meta["summary"], f"{path}: description has no first sentence" assert len(meta["summary"]) <= SUMMARY_LIMIT - assert not meta[ - "summary_truncated" - ], f"{path}: first sentence runs past {SUMMARY_LIMIT} chars - shorten it or declare 'summary': {meta['summary']!r}" + assert not meta["summary_truncated"], ( + f"{path}: first sentence runs past {SUMMARY_LIMIT} chars - shorten it or declare 'summary': {meta['summary']!r}" + ) BUILTINS = sorted( @@ -179,9 +179,9 @@ def test_workflow_ids_are_unique_across_the_catalog(): for path in TEMPLATES + MODEL_CONFIGS + BUILTINS: identity = load(path).get("id") assert identity, f"{path} has no id" - assert ( - identity not in seen - ), f"{path} and {seen[identity]} share id {identity!r}" + assert identity not in seen, ( + f"{path} and {seen[identity]} share id {identity!r}" + ) seen[identity] = path @@ -190,17 +190,17 @@ def test_a_declared_cost_is_well_formed(path): cost = load(path).get("cost") if cost is None: return - assert ( - isinstance(cost, list) and cost - ), f"{path}: cost must be a non-empty list or absent" + assert isinstance(cost, list) and cost, ( + f"{path}: cost must be a non-empty list or absent" + ) for entry in cost: assert entry["device"] in ("cuda", "mps", "cpu"), path - assert ( - isinstance(entry["vram_gb"], (int, float)) and entry["vram_gb"] >= 0 - ), path - assert ( - isinstance(entry["minutes"], (int, float)) and entry["minutes"] >= 0 - ), path + assert isinstance(entry["vram_gb"], (int, float)) and entry["vram_gb"] >= 0, ( + path + ) + assert isinstance(entry["minutes"], (int, float)) and entry["minutes"] >= 0, ( + path + ) def per_entry_problems(definition): @@ -318,9 +318,9 @@ def test_a_description_names_only_variables_the_workflow_declares(path): catalog_variables = _variable_names_in_catalog() allowed = LEGITIMATE_MENTIONS.get(path, set()) undeclared = (_mentioned(definition) & catalog_variables) - declared - allowed - assert ( - not undeclared - ), f"{path} describes {sorted(undeclared)} but declares no such variable" + assert not undeclared, ( + f"{path} describes {sorted(undeclared)} but declares no such variable" + ) def test_the_drift_check_actually_matches_something(): @@ -331,19 +331,19 @@ def test_the_drift_check_actually_matches_something(): for path in TEMPLATES if _mentioned(load(path)) & _variable_names_in_catalog() ] - assert ( - matched - ), "no template description quotes a catalog variable name - the pattern is wrong" + assert matched, ( + "no template description quotes a catalog variable name - the pattern is wrong" + ) def test_no_stale_entry_in_the_allowlist(): for path, names in LEGITIMATE_MENTIONS.items(): - assert ( - path in TEMPLATES + MODEL_CONFIGS - ), f"{path} is allowlisted but not in the catalog" - assert names <= _mentioned( - load(path) - ), f"{path} no longer mentions {sorted(names - _mentioned(load(path)))}" + assert path in TEMPLATES + MODEL_CONFIGS, ( + f"{path} is allowlisted but not in the catalog" + ) + assert names <= _mentioned(load(path)), ( + f"{path} no longer mentions {sorted(names - _mentioned(load(path)))}" + ) # Spec targets, as chars / 4. The listing is the first thing an agent reads; @@ -363,15 +363,15 @@ def test_the_compact_listing_fits_the_budget(): details = workflow_details(found) compact = project_listing(details, view="compact") - assert ( - _tokens(compact) <= COMPACT_BUDGET - ), f"compact listing is {_tokens(compact):.0f} tokens" + assert _tokens(compact) <= COMPACT_BUDGET, ( + f"compact listing is {_tokens(compact):.0f} tokens" + ) sequences = project_listing(details, view="compact", shape="sequence") assert sequences, "no template derives 'sequence'" - assert ( - _tokens(sequences) <= FILTERED_BUDGET - ), f"shape=sequence is {_tokens(sequences):.0f} tokens" + assert _tokens(sequences) <= FILTERED_BUDGET, ( + f"shape=sequence is {_tokens(sequences):.0f} tokens" + ) def _walk(value): @@ -490,9 +490,9 @@ def test_every_readme_link_resolves(path): if target.startswith(("http://", "https://", "#")): continue target = target.split("#", 1)[0] - assert os.path.exists( - os.path.join(base, target) - ), f"{path} links to {target}, which does not exist" + assert os.path.exists(os.path.join(base, target)), ( + f"{path} links to {target}, which does not exist" + ) COSTED = { diff --git a/tests/test_chain.py b/tests/test_chain.py index 102caaaf..b504ce82 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -10,14 +10,12 @@ from dataclasses import dataclass from types import SimpleNamespace -import numpy import pytest import torch from PIL import Image from dw.pipeline_processors.chain import ( ChainConfig, - Segment, plan_segments, run_chain, snap_frames, diff --git a/tests/test_configuration_schema.py b/tests/test_configuration_schema.py index 07da0355..c13cb271 100644 --- a/tests/test_configuration_schema.py +++ b/tests/test_configuration_schema.py @@ -61,7 +61,9 @@ def configuration_keys_read_by_the_code(): name = ( target.id if isinstance(target, ast.Name) - else target.attr if isinstance(target, ast.Attribute) else None + else target.attr + if isinstance(target, ast.Attribute) + else None ) if name not in ("configuration", "component_configuration"): continue diff --git a/tests/test_device.py b/tests/test_device.py index 729f25ef..860ec66f 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -3,7 +3,6 @@ Tests the configured device override and device type resolution """ -import pytest import dw diff --git a/tests/test_diffusion_upscale.py b/tests/test_diffusion_upscale.py index b7c3d830..591c1896 100644 --- a/tests/test_diffusion_upscale.py +++ b/tests/test_diffusion_upscale.py @@ -48,9 +48,7 @@ def test_default_mode_is_x4(self, mock_diffusers): @patch("dw.tasks.diffusion_upscale.diffusers") def test_x2_mode_uses_latent_pipeline(self, mock_diffusers): mock_pipe = self._make_mock_pipeline() - mock_diffusers.StableDiffusionLatentUpscalePipeline.from_pretrained.return_value = ( - mock_pipe - ) + mock_diffusers.StableDiffusionLatentUpscalePipeline.from_pretrained.return_value = mock_pipe diffusion_upscale(self._make_image(), device="cpu", mode="x2") @@ -72,9 +70,7 @@ def test_x4_includes_noise_level(self, mock_diffusers): @patch("dw.tasks.diffusion_upscale.diffusers") def test_x2_excludes_noise_level(self, mock_diffusers): mock_pipe = self._make_mock_pipeline() - mock_diffusers.StableDiffusionLatentUpscalePipeline.from_pretrained.return_value = ( - mock_pipe - ) + mock_diffusers.StableDiffusionLatentUpscalePipeline.from_pretrained.return_value = mock_pipe diffusion_upscale(self._make_image(), device="cpu", mode="x2") diff --git a/tests/test_gather.py b/tests/test_gather.py index 957f230a..a084a630 100644 --- a/tests/test_gather.py +++ b/tests/test_gather.py @@ -6,7 +6,7 @@ import pytest import os import tempfile -from unittest.mock import patch, MagicMock +from unittest.mock import patch from PIL import Image from dw.tasks.gather import gather_images, gather_videos, gather_inputs from dw.result import AudioVideo @@ -207,7 +207,7 @@ def test_a_gathered_video_is_one_artifact(self, tmp_path): step that consumed them out over frames instead of videos.""" from dw.result import get_artifact_list - path = write_video(tmp_path / "shot.mp4") + write_video(tmp_path / "shot.mp4") videos = gather_videos(glob=os.path.join(str(tmp_path), "*.mp4")) @@ -218,7 +218,7 @@ def test_a_gathered_video_is_one_artifact(self, tmp_path): def test_a_gathered_video_keeps_its_audio(self, tmp_path): """The audio muxed into the file is what an earlier run generated alongside the picture - gathering it silent loses that run's work.""" - path = write_video(tmp_path / "shot.mp4", sample_rate=8000) + write_video(tmp_path / "shot.mp4", sample_rate=8000) video = gather_videos(glob=os.path.join(str(tmp_path), "*.mp4"))[0] diff --git a/tests/test_integration.py b/tests/test_integration.py index 0d22f501..612663d8 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -8,7 +8,6 @@ import json import tempfile from dw.workflow import Workflow, workflow_from_file -from dw.result import Result @pytest.fixture diff --git a/tests/test_introspection.py b/tests/test_introspection.py index eb4f1024..9e7616b3 100644 --- a/tests/test_introspection.py +++ b/tests/test_introspection.py @@ -87,8 +87,7 @@ def test_a_reference_naming_no_variable_is_warned_about_before_anything_loads(): warnings = workflow_argument_warnings(workflow) assert len(warnings) == 2 assert warnings[0] == ( - "seed: 'variable:seed' names no declared variable; " - "declared: base_prompt, steps" + "seed: 'variable:seed' names no declared variable; declared: base_prompt, steps" ) assert warnings[1].startswith( "steps[0].pipeline.arguments.prompt: 'variable:base_prompt, clear sky' " diff --git a/tests/test_ltx_prompt_library.py b/tests/test_ltx_prompt_library.py index b0f091c4..eb72c457 100644 --- a/tests/test_ltx_prompt_library.py +++ b/tests/test_ltx_prompt_library.py @@ -45,9 +45,9 @@ def test_a_prompt_is_one_paragraph_of_caption_length(path): assert "\n" not in text.strip(), f"{path} is more than one paragraph" words = len(text.split()) - assert ( - 140 <= words <= 240 - ), f"{path} is {words} words; the trained caption is 150-220" + assert 140 <= words <= 240, ( + f"{path} is {words} words; the trained caption is 150-220" + ) @pytest.mark.parametrize("path", PROMPTS, ids=os.path.basename) diff --git a/tests/test_mcp_main.py b/tests/test_mcp_main.py index f900f813..e1cd1944 100644 --- a/tests/test_mcp_main.py +++ b/tests/test_mcp_main.py @@ -1,7 +1,5 @@ """`dw-mcp` startup: what it refuses, what it checks, what it prints.""" -import json - import httpx import pytest diff --git a/tests/test_mcp_media.py b/tests/test_mcp_media.py index b207bdcb..65388ed4 100644 --- a/tests/test_mcp_media.py +++ b/tests/test_mcp_media.py @@ -3,6 +3,7 @@ import base64 import io +import os import httpx import numpy as np @@ -11,7 +12,7 @@ import dw_mcp.media as media from dw_mcp.client import DwApiError, DwClient -from dw_mcp.media import MAX_RETURNED_BYTES, get_output_image +from dw_mcp.media import MAX_RETURNED_BYTES, download_output, get_output_image def noise_png_bytes(width, height, seed=0): @@ -380,11 +381,6 @@ def handler(request): # --------------------------------------------------------- output download -import os - -from dw_mcp.media import download_output - - def test_download_output_writes_bytes_to_explicit_file_path(tmp_path): client = serving(png_bytes(64, 48), "image/png") destination = tmp_path / "saved.png" diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index cce69812..45231dd2 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -590,9 +590,9 @@ async def test_optional_parameters_are_declared_nullable(): for name, tool in tools.items(): for parameter, schema in tool.input_schema["properties"].items(): if schema.get("default", "missing") is None: - assert ( - "anyOf" in schema or schema.get("type") == "null" - ), f"{name}.{parameter} defaults to null but is not nullable" + assert "anyOf" in schema or schema.get("type") == "null", ( + f"{name}.{parameter} defaults to null but is not nullable" + ) @pytest.mark.asyncio diff --git a/tests/test_pipeline_caching.py b/tests/test_pipeline_caching.py index f51413ca..5a53dacb 100644 --- a/tests/test_pipeline_caching.py +++ b/tests/test_pipeline_caching.py @@ -8,7 +8,7 @@ import sys import logging import pytest -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import patch, MagicMock # Add parent directory to path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -57,7 +57,6 @@ def test_pipeline_caching(): loaded_models = {} # Track loaded models by step name original_pipeline_init = Pipeline.__init__ - original_pipeline_load = Pipeline.load def mock_pipeline_init(self, *args, **kwargs): # Extract the pipeline argument before calling original init @@ -116,22 +115,22 @@ def mock_pipeline_load(self, *args, **kwargs): logger.info("VERIFICATION") logger.info("=" * 60) - assert ( - first_load_count == 1 - ), f"Expected 1 load on first run, got {first_load_count}" - logger.info(f"✅ First run loaded exactly once") + assert first_load_count == 1, ( + f"Expected 1 load on first run, got {first_load_count}" + ) + logger.info("✅ First run loaded exactly once") - assert ( - second_load_count == 1 - ), f"Expected no additional loads on second run, got {second_load_count}" - logger.info(f"✅ Second run reused cached pipeline (no reload)") + assert second_load_count == 1, ( + f"Expected no additional loads on second run, got {second_load_count}" + ) + logger.info("✅ Second run reused cached pipeline (no reload)") # Note: We now create a new wrapper but reuse the underlying model - assert ( - action1.pipeline is action2.pipeline - ), "Expected same underlying pipeline model to be reused" + assert action1.pipeline is action2.pipeline, ( + "Expected same underlying pipeline model to be reused" + ) logger.info( - f"✅ Both runs reused the same underlying model (pipeline.pipeline)" + "✅ Both runs reused the same underlying model (pipeline.pipeline)" ) logger.info("\n" + "=" * 60) @@ -170,7 +169,6 @@ def test_pipeline_caching_different_steps(): load_call_count = 0 original_pipeline_init = Pipeline.__init__ - original_pipeline_load = Pipeline.load def mock_pipeline_init(self, *args, **kwargs): # Check if pipeline is being reused @@ -210,15 +208,15 @@ def mock_pipeline_load(self, *args, **kwargs): ) logger.info(f"✅ Step1 reused: load_count={load_call_count}") - assert ( - load_call_count == 2 - ), f"Expected 2 loads (one per step), got {load_call_count}" - assert ( - action1.pipeline is action1_reuse.pipeline - ), "Step1 underlying model should be reused from cache" - assert ( - action1.pipeline is not action2.pipeline - ), "Step1 and step2 should have different underlying models" + assert load_call_count == 2, ( + f"Expected 2 loads (one per step), got {load_call_count}" + ) + assert action1.pipeline is action1_reuse.pipeline, ( + "Step1 underlying model should be reused from cache" + ) + assert action1.pipeline is not action2.pipeline, ( + "Step1 and step2 should have different underlying models" + ) logger.info("\n" + "=" * 60) logger.info("🎉 MULTI-STEP TEST PASSED!") @@ -441,9 +439,9 @@ def mock_load(self, shared_components): with patch.object(Pipeline, "load", mock_load): workflow.create_step_action(new_step, {}, cache, 1, "cpu") - assert ( - seen_at_load["old_still_cached"] is False - ), "the redefined step's previous model must be evicted before load" + assert seen_at_load["old_still_cached"] is False, ( + "the redefined step's previous model must be evicted before load" + ) def test_pipeline_released_is_reported_on_the_event_stream(): diff --git a/tests/test_pipeline_components.py b/tests/test_pipeline_components.py index ba0a2ab1..f254f9ec 100644 --- a/tests/test_pipeline_components.py +++ b/tests/test_pipeline_components.py @@ -94,7 +94,9 @@ def test_callback_is_wired_to_the_pipeline(self): def test_an_explicit_callback_is_left_alone(self): from diffusers import FasterCacheConfig - callback = lambda: 3 + def callback(): + return 3 + config = FasterCacheConfig(current_timestep_callback=callback) enable_cache_on_transformer(MagicMock(), config) diff --git a/tests/test_plan.py b/tests/test_plan.py index 4a853706..d9cfaae5 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -448,9 +448,9 @@ def test_a_local_path_is_not_a_download_and_is_not_probed( names = (str(tmp_path / "weights"), "/Users/someone/.ssh", "./weights") for local in names: spec = definition() - spec["steps"][0]["pipeline"]["from_pretrained_arguments"][ - "model_name" - ] = local + spec["steps"][0]["pipeline"]["from_pretrained_arguments"]["model_name"] = ( + local + ) assert plan(spec)["downloads_required"] == [], local assert not set(names) & set(probed) diff --git a/tests/test_plugin_skills.py b/tests/test_plugin_skills.py index ba3fd663..e05e3533 100644 --- a/tests/test_plugin_skills.py +++ b/tests/test_plugin_skills.py @@ -90,9 +90,9 @@ def test_a_skill_has_a_triggering_description_under_the_size_cap(path): assert fields["name"] == os.path.basename(os.path.dirname(path)) assert "description" in fields and len(fields["description"]) > 40 - assert ( - len(text.encode("utf-8")) <= SKILL_SIZE_LIMIT - ), f"{path} is over {SKILL_SIZE_LIMIT} bytes" + assert len(text.encode("utf-8")) <= SKILL_SIZE_LIMIT, ( + f"{path} is over {SKILL_SIZE_LIMIT} bytes" + ) @pytest.mark.parametrize( @@ -107,9 +107,9 @@ def test_every_catalog_name_a_skill_quotes_resolves(path): target = os.path.join( REPO_ROOT, "workflows", name.removesuffix(".json") + ".json" ) - assert os.path.isfile( - target - ), f"{path} quotes {name}, which is not a workflow ({target})" + assert os.path.isfile(target), ( + f"{path} quotes {name}, which is not a workflow ({target})" + ) H3_SKILL = os.path.join(PLUGIN_DIR, "skills", "minimax-h3", "SKILL.md") @@ -241,9 +241,9 @@ def test_the_lora_coupling_is_scoped_to_the_turbo_templates(self): ) spec = open(path, encoding="utf-8").read() assert "lora_model_name" not in spec, f"{name} now loads a LoRA" - assert ( - '"num_inference_steps": 20' in spec - ), f"{name} no longer runs 20 steps" + assert '"num_inference_steps": 20' in spec, ( + f"{name} no longer runs 20 steps" + ) json.loads(spec) def test_the_skill_defers_prompt_format_to_minimax(self): @@ -431,9 +431,9 @@ def test_a_skill_states_the_subfolder_convention(path): composing a new workflow needs to keep it.""" text = skill_text(path) assert "`subfolder`" in text, f"{path} does not name the subfolder field" - assert ( - "`final`" in text and "`intermediate`" in text - ), f"{path} does not state the final/intermediate convention" + assert "`final`" in text and "`intermediate`" in text, ( + f"{path} does not state the final/intermediate convention" + ) # the convention is stated where the manifest is read assert text.index("`subfolder`") > text.index("## Run and judge") @@ -455,6 +455,6 @@ def test_the_h3_skill_names_each_cut_templates_final_step(): if (step.get("result") or {}).get("subfolder") == "final" ] assert len(finals) == 1, (name, finals) - assert ( - f"`{finals[0]}`" in text - ), f"the skill does not name {name}'s final step {finals[0]}" + assert f"`{finals[0]}`" in text, ( + f"the skill does not name {name}'s final step {finals[0]}" + ) diff --git a/tests/test_realize.py b/tests/test_realize.py index 6740d8f9..cec60148 100644 --- a/tests/test_realize.py +++ b/tests/test_realize.py @@ -4,7 +4,6 @@ import copy import hashlib import json -import os import pytest @@ -101,9 +100,9 @@ def test_a_stored_prompt_is_inlined_and_annotated(self, prompt_library): def test_a_name_is_annotated_once_in_first_seen_order(self, prompt_library): source = definition() source["steps"][0]["pipeline"]["arguments"]["prompt"] = "prompt:scenic/dusk" - source["steps"][0]["pipeline"]["arguments"][ - "negative_prompt" - ] = "prompt:scenic/dusk" + source["steps"][0]["pipeline"]["arguments"]["negative_prompt"] = ( + "prompt:scenic/dusk" + ) _, annotations = realize_workflow(source, {}, 7, prompt_dir=prompt_library) @@ -127,9 +126,9 @@ class TestOutputReferences: def test_latest_is_pinned_to_the_run_it_resolved_to(self, output_root): root, run_id = output_root source = definition() - source["steps"][0]["pipeline"]["arguments"][ - "image" - ] = "output:ltx2/Gyre/latest/still.png" + source["steps"][0]["pipeline"]["arguments"]["image"] = ( + "output:ltx2/Gyre/latest/still.png" + ) realized, _ = realize_workflow(source, {}, 7, output_root=root) @@ -248,9 +247,9 @@ class TestUnpinnedOutputs: def test_pin_outputs_false_leaves_latest_as_written(self, output_root): root, _ = output_root spec = definition() - spec["steps"][0]["pipeline"]["arguments"][ - "image" - ] = "output:ltx2/Gyre/latest/still.png" + spec["steps"][0]["pipeline"]["arguments"]["image"] = ( + "output:ltx2/Gyre/latest/still.png" + ) realized, _ = realize_workflow(spec, {}, 7, output_root=root, pin_outputs=False) assert ( realized["steps"][0]["pipeline"]["arguments"]["image"] @@ -269,9 +268,9 @@ def test_pin_outputs_false_still_inlines_prompts(self, prompt_library): def test_the_default_still_pins(self, output_root): root, run_id = output_root spec = definition() - spec["steps"][0]["pipeline"]["arguments"][ - "image" - ] = "output:ltx2/Gyre/latest/still.png" + spec["steps"][0]["pipeline"]["arguments"]["image"] = ( + "output:ltx2/Gyre/latest/still.png" + ) realized, _ = realize_workflow(spec, {}, 7, output_root=root) assert ( realized["steps"][0]["pipeline"]["arguments"]["image"] diff --git a/tests/test_repl_commands.py b/tests/test_repl_commands.py index 4bf046ae..429b6038 100644 --- a/tests/test_repl_commands.py +++ b/tests/test_repl_commands.py @@ -36,10 +36,10 @@ def test_repl_commands(): print("=" * 70) for cmd, description in test_commands: - print(f"\n{'='*70}") + print(f"\n{'=' * 70}") print(f"Test: {description}") print(f"Command: {cmd}") - print(f"{'='*70}") + print(f"{'=' * 70}") repl.onecmd(cmd) print("\n" + "=" * 70) diff --git a/tests/test_repl_reorganization.py b/tests/test_repl_reorganization.py index 3e0869ed..f50408da 100644 --- a/tests/test_repl_reorganization.py +++ b/tests/test_repl_reorganization.py @@ -32,11 +32,11 @@ def test_command_equivalence(): for test in tests: repl = DiffusersWorkflowREPL() # Fresh REPL for each test - print(f"\n{'-'*70}") + print(f"\n{'-' * 70}") print(f"Test: {test['description']}") print(f"Old command: {test['old']}") print(f"New command: {test['new']}") - print(f"{'-'*70}") + print(f"{'-' * 70}") # The output should be the same print("Old command output:") @@ -64,9 +64,9 @@ def test_help_system(): commands = ["workflow", "arg", "model", "memory", "config"] for cmd in commands: - print(f"\n{'-'*70}") + print(f"\n{'-' * 70}") print(f"Testing: {cmd} ?") - print(f"{'-'*70}") + print(f"{'-' * 70}") repl.onecmd(f"{cmd} ?") print(f"✅ Help for '{cmd}' works") @@ -93,10 +93,10 @@ def test_command_flow(): ] for cmd, description in flow: - print(f"\n{'-'*70}") + print(f"\n{'-' * 70}") print(f"Step: {description}") print(f"Command: {cmd}") - print(f"{'-'*70}") + print(f"{'-' * 70}") repl.onecmd(cmd) print(f"✅ {description} - OK") diff --git a/tests/test_result_output_naming.py b/tests/test_result_output_naming.py index fa251a1b..70d0cfa7 100644 --- a/tests/test_result_output_naming.py +++ b/tests/test_result_output_naming.py @@ -1,5 +1,3 @@ -import os - from dw.result import output_file_path diff --git a/tests/test_segment.py b/tests/test_segment.py index fb1bba5c..854a42bd 100644 --- a/tests/test_segment.py +++ b/tests/test_segment.py @@ -1,4 +1,3 @@ -import pytest from unittest.mock import patch, MagicMock import torch import numpy as np diff --git a/tests/test_server.py b/tests/test_server.py index 3f3bc917..4cec7dbb 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -112,9 +112,7 @@ class DyingWorkerManager(ScriptedWorkerManager): """A worker killed by a signal: it sends nothing, and get_result raises the way the real WorkerManager's liveness poll does.""" - def __init__( - self, detail="killed by SIGKILL (typically the " "out-of-memory killer)" - ): + def __init__(self, detail="killed by SIGKILL (typically the out-of-memory killer)"): super().__init__(script=lambda command: []) self.detail = detail self.crashed = False @@ -514,9 +512,9 @@ def test_validate_accepts_a_stored_workflow_name(server, tmp_path): # the stored file is what gets checked, warnings and all typo = valid_workflow("typo") - typo["steps"][0]["pipeline"]["configuration"][ - "component_type" - ] = "ZImagePipeline" + typo["steps"][0]["pipeline"]["configuration"]["component_type"] = ( + "ZImagePipeline" + ) typo["steps"][0]["pipeline"]["arguments"]["guidance_scael"] = 3 (tmp_path / "workflows" / "Typo.json").write_text(json.dumps(typo)) @@ -863,9 +861,9 @@ def test_introspection_endpoints(server): def test_validate_endpoint_flags_signature_typos(server): with server(success_script) as client: workflow = valid_workflow() - workflow["steps"][0]["pipeline"]["configuration"][ - "component_type" - ] = "ZImagePipeline" + workflow["steps"][0]["pipeline"]["configuration"]["component_type"] = ( + "ZImagePipeline" + ) workflow["steps"][0]["pipeline"]["arguments"]["guidance_scael"] = 3 result = client.post("/api/validate", json={"workflow": workflow}).json() @@ -897,9 +895,9 @@ def test_validate_explains_why_an_unseeded_workflow_caches_nothing(server): def test_submission_carries_argument_warnings(server): with server(success_script) as client: workflow = valid_workflow() - workflow["steps"][0]["pipeline"]["configuration"][ - "component_type" - ] = "ZImagePipeline" + workflow["steps"][0]["pipeline"]["configuration"]["component_type"] = ( + "ZImagePipeline" + ) workflow["steps"][0]["pipeline"]["arguments"]["guidance_scael"] = 3 job = client.post("/api/jobs", json={"workflow": workflow}).json() assert any("guidance_scael" in w for w in job["warnings"]) diff --git a/tests/test_server_exports.py b/tests/test_server_exports.py index 6727648a..ef23ebe6 100644 --- a/tests/test_server_exports.py +++ b/tests/test_server_exports.py @@ -17,7 +17,7 @@ from .test_server import ( ScriptedWorkerManager, hanging_script, - server as workspace_less_server, + server as workspace_less_server, # noqa: F401 - used as a fixture by name success_script, valid_workflow, wait_for_status, diff --git a/tests/test_server_workspaces.py b/tests/test_server_workspaces.py index 2038d962..1408e271 100644 --- a/tests/test_server_workspaces.py +++ b/tests/test_server_workspaces.py @@ -2,7 +2,6 @@ root, each with its own workflows, assets and outputs, all sharing the one prompt library.""" -import json import os import pytest diff --git a/tests/test_step.py b/tests/test_step.py index 5b893037..78b8e015 100644 --- a/tests/test_step.py +++ b/tests/test_step.py @@ -4,7 +4,7 @@ """ import pytest -from unittest.mock import Mock, MagicMock +from unittest.mock import Mock from dw.step import Step from dw.result import Result @@ -141,7 +141,7 @@ def capture_args(args, pipelines): previous_results = {"images": images_result, "prompts": prompts_result} previous_pipelines = {} - result = step.run(previous_results, previous_pipelines, mock_action) + step.run(previous_results, previous_pipelines, mock_action) # Should create 2x2 = 4 combinations assert mock_action.run.call_count == 4 diff --git a/tests/test_task.py b/tests/test_task.py index 707e03d6..1fd38bb1 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -98,9 +98,9 @@ def test_format_chat_message_task(): result = task.run(task_def["arguments"]) # Check the overall structure - assert isinstance( - result, dict - ), "Expected a formatted dict from format_chat_message" + assert isinstance(result, dict), ( + "Expected a formatted dict from format_chat_message" + ) assert "text_inputs" in result, "Result should contain text_inputs key" # Check the text_inputs array structure @@ -110,9 +110,9 @@ def test_format_chat_message_task(): # Check system message assert text_inputs[0]["role"] == "system", "First message should have role 'system'" - assert ( - text_inputs[0]["content"] == "Hello, world!" - ), "System message content mismatch" + assert text_inputs[0]["content"] == "Hello, world!", ( + "System message content mismatch" + ) # Check user message assert text_inputs[1]["role"] == "user", "Second message should have role 'user'" diff --git a/tests/test_template_subfolders.py b/tests/test_template_subfolders.py index 860a696f..c366f42c 100644 --- a/tests/test_template_subfolders.py +++ b/tests/test_template_subfolders.py @@ -60,9 +60,9 @@ def test_every_saving_step_of_a_multi_step_template_names_its_role(template): roles = {step["name"]: step["result"].get("subfolder") for step in steps} unmarked = sorted(name for name, role in roles.items() if role not in CONVENTION) - assert ( - not unmarked - ), f"{template}: saving steps without a final/intermediate subfolder: {unmarked}" + assert not unmarked, ( + f"{template}: saving steps without a final/intermediate subfolder: {unmarked}" + ) assert "final" in roles.values(), f"{template}: no step is marked final" @@ -70,9 +70,9 @@ def test_every_saving_step_of_a_multi_step_template_names_its_role(template): def test_the_subfolder_is_the_last_key_of_the_result(template): """One added line per step, and every template reads alike.""" for step in saving_steps(load(template)): - assert ( - list(step["result"])[-1] == "subfolder" - ), f"{template}: step {step['name']!r} does not end its result with subfolder" + assert list(step["result"])[-1] == "subfolder", ( + f"{template}: step {step['name']!r} does not end its result with subfolder" + ) @pytest.mark.parametrize( @@ -87,6 +87,6 @@ def test_the_packaged_builtins_stay_unmarked(builtin): for step in definition.get("steps", []) if "subfolder" in (step.get("result") or {}) ] - assert ( - not marked - ), f"dw/workflows/{builtin} marks {marked}; a role is the parent's to assign" + assert not marked, ( + f"dw/workflows/{builtin} marks {marked}; a role is the parent's to assign" + ) diff --git a/tests/test_tensor_image.py b/tests/test_tensor_image.py index 647f875a..20ad2a60 100644 --- a/tests/test_tensor_image.py +++ b/tests/test_tensor_image.py @@ -1,5 +1,4 @@ import numpy as np -import pytest import torch from PIL import Image diff --git a/tests/test_type_helpers.py b/tests/test_type_helpers.py index 57ad8782..c8208024 100644 --- a/tests/test_type_helpers.py +++ b/tests/test_type_helpers.py @@ -6,7 +6,6 @@ import pytest from dw.type_helpers import ( get_type, - load_type_from_name, load_type_from_full_name, has_method, ) @@ -18,7 +17,6 @@ class TestGetType: def test_get_type_from_diffusers(self): # This would work if diffusers is installed # For testing, we'll use a built-in type - import sys result = get_type("sys", "version") assert result is not None diff --git a/tests/test_video_utils.py b/tests/test_video_utils.py index 86b3684d..fd59d335 100644 --- a/tests/test_video_utils.py +++ b/tests/test_video_utils.py @@ -53,8 +53,9 @@ def test_get_last_frame(self, video): def test_get_last_frame_ignores_a_frame_index(self, video): # get_last_frame computes its own index; a stray argument must not win - assert process_video(video, "get_last_frame", "cpu", {"frame_index": 0}) is ( - video[3] + assert ( + process_video(video, "get_last_frame", "cpu", {"frame_index": 0}) + is (video[3]) ) @pytest.mark.parametrize("name", ["GET_LAST_FRAME", "Get_Last_Frame"]) diff --git a/tests/test_workflow_step_cache.py b/tests/test_workflow_step_cache.py index 5c80250f..35862e0a 100644 --- a/tests/test_workflow_step_cache.py +++ b/tests/test_workflow_step_cache.py @@ -7,7 +7,6 @@ import os from unittest.mock import MagicMock, patch -import pytest from dw.events import RunContext from dw.step_cache import step_cache From 9bf383c193744a182e608bc8ba9b0b56d3c9ee6e Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 19:50:47 -0500 Subject: [PATCH 16/17] feat: add preflight script for automated checks --- preflight.sh | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100755 preflight.sh diff --git a/preflight.sh b/preflight.sh new file mode 100755 index 00000000..4c0f1570 --- /dev/null +++ b/preflight.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -uo pipefail + +cd "$(dirname "$0")" || exit 1 + +failures=() + +run_step() { + local name="$1" + shift + echo "==> $name" + if ! "$@"; then + failures+=("$name") + fi +} + +run_step "ruff format" ruff format . +run_step "ruff check" ruff check . --fix +run_step "pytest" python -m pytest +run_step "ui preflight" bash -c 'cd ui && npm run preflight' + +echo +if [ ${#failures[@]} -eq 0 ]; then + echo "All preflight checks passed." + exit 0 +else + echo "Preflight failures:" + for f in "${failures[@]}"; do + echo " - $f" + done + exit 1 +fi From 25ed77746a2e515af9eacbc430e63d055f0e2348 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 20:11:49 -0500 Subject: [PATCH 17/17] fix: #139 #140 - declared numeric domains on task arguments A task command's argument schema is its implementation's signature, which carries no range, so validate_workflow had nothing to check a number against. Both defects that surfaced were silent successes, not failures: slice_audio(num_frames=-10) reached Python's slice semantics and returned the track minus its last ten frames (#139), and resample_audio(target_sample_rate=0) left the samples untouched and then hit the 44100 Hz save default, writing a header 38% off over a 32 kHz waveform (#140). dw/task_domains.py declares the domains that are not a judgement call - counts and rates above zero, offsets zero or above - for the audio and video-join commands, and checks them in two places: statically in validation_errors, so a literal is a free pre-flight error at its JSON path, and inside the commands, which is the only layer that sees a value arriving from a variable: or an earlier step. _as_track now refuses a non-positive rate outright, since relabelling a waveform changes its speed and pitch and the save default makes a missing rate look valid, and resample_waveform refuses one too. get_task reports the domain beside the parameter it constrains, so an agent composing a call can read it. tests/test_task_domains.py pins every registry entry to a real parameter of a real command, so a rename cannot leave a domain checking nothing. --- CLAUDE.md | 18 +++ docs/TASKS.md | 11 ++ dw/introspection.py | 12 ++ dw/task_domains.py | 218 ++++++++++++++++++++++++++++ dw/tasks/audio_utils.py | 78 ++++++++-- dw/workflow.py | 7 + tests/test_task_domains.py | 286 +++++++++++++++++++++++++++++++++++++ 7 files changed, 617 insertions(+), 13 deletions(-) create mode 100644 dw/task_domains.py create mode 100644 tests/test_task_domains.py diff --git a/CLAUDE.md b/CLAUDE.md index bcdc072a..26c7053c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -350,6 +350,24 @@ same reason - default setup cannot load a pack. items beside the `shots` list change. Gallery names for a template's runs now read `