diff --git a/.github/codeql/extensions/dw-models/codeql-pack.yml b/.github/codeql/extensions/dw-models/codeql-pack.yml new file mode 100644 index 00000000..e3b85c0f --- /dev/null +++ b/.github/codeql/extensions/dw-models/codeql-pack.yml @@ -0,0 +1,16 @@ +# A CodeQL model pack describing this project's own security barriers. +# +# CodeQL's dataflow library cannot see that dw/security.py validates a path +# and *raises* rather than returning a rewritten one, so py/path-injection +# reports every os.* call downstream of a validated path. These rows tell it +# the barrier is there. +# +# Placed under .github/codeql/extensions/, this is picked up automatically by +# code scanning default setup - no workflow changes, nothing to publish. +name: dkackman/dw-models +version: 0.0.1 +library: true +extensionTargets: + codeql/python-all: "*" +dataExtensions: + - models/**/*.yml diff --git a/.github/codeql/extensions/dw-models/models/dw-security.model.yml b/.github/codeql/extensions/dw-models/models/dw-security.model.yml new file mode 100644 index 00000000..26e9a32f --- /dev/null +++ b/.github/codeql/extensions/dw-models/models/dw-security.model.yml @@ -0,0 +1,23 @@ +extensions: + # validate_path() resolves a path with realpath and raises PathTraversalError + # unless it lands inside base_dir. Its return value is contained by + # construction, so taint stops there. + - addsTo: + pack: codeql/python-all + extensible: barrierModel + data: + - ["dw.security", "Member[validate_path].ReturnValue", "path-injection"] + - ["dw.security", "Member[validate_workflow_path].ReturnValue", "path-injection"] + - ["dw.security", "Member[validate_output_path].ReturnValue", "path-injection"] + - ["dw.security", "Member[validate_prompt_path].ReturnValue", "path-injection"] + + # The name validators are regex whitelists that raise InvalidInputError: + # a workspace name is one path segment (^[\w][\w.-]*\Z - no separator, + # and '..' cannot match a leading \w), an asset or output reference is + # bounded, separator-limited segments of the same shape. Each returns the + # name it validated, so the return value is the safe one to use. + - ["dw.security", "Member[validate_workspace_name].ReturnValue", "path-injection"] + - ["dw.security", "Member[validate_asset_reference].ReturnValue", "path-injection"] + - ["dw.security", "Member[validate_output_reference].ReturnValue", "path-injection"] + - ["dw.security", "Member[validate_prompt_reference].ReturnValue", "path-injection"] + - ["dw.security", "Member[validate_variable_name].ReturnValue", "path-injection"] diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1422a4b1..41528ea0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,12 +13,12 @@ name: "CodeQL" on: push: - branches: [ "main" ] + branches: ["master"] pull_request: # The branches below must be a subset of the branches above - branches: [ "main" ] + branches: ["master"] schedule: - - cron: '26 12 * * 6' + - cron: "26 12 * * 6" jobs: analyze: @@ -32,43 +32,42 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'python' ] + language: ["python"] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@v7 + - name: Checkout repository + uses: actions/checkout@v7 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v4 - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v4 + # ℹ️ Command-line programs to run using the OS shell. + # πŸ“š See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # ℹ️ Command-line programs to run using the OS shell. - # πŸ“š See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore index 8eeb3768..47c8200f 100644 --- a/.gitignore +++ b/.gitignore @@ -153,5 +153,12 @@ venv # generated media - regenerable, archived outside the repo local_inputs/ workflows/*/assets/ +# the workspace's asset library: input media, and where browser uploads land +# when the checkout is the workspace (dw/workspace.py). Anchored at the root +# so the SPA's own assets/ folders are not swept up. Generated output is +# covered by the outputs/ rule above, run directories and manifests included +/assets/ +# a run directory a test or a bare `dw.run -o output` leaves at the root +/output/ /*.mp4 /*.wav diff --git a/CLAUDE.md b/CLAUDE.md index 9e952fea..a4cc6676 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,51 @@ The REPL (`dw/repl.py`) uses a **persistent worker subprocess** (`dw/worker.py`) **Critical**: Uses `multiprocessing.set_start_method("spawn")` for CUDA/MPS compatibility. +### Workspaces on the server + +`dw.serve` can hold several workspaces under one root: the root's own +`workflows/assets/outputs` are the `default` workspace, a named one is a +subdirectory beside them (`named_workspace`, `create_workspace` in +`dw/workspace.py`), and `prompts/` at the root is shared by all of them - there +is one prompt library, because `prompt:` is shared by reference. Routes take an +optional `workspace`; omitting it means the default, so pre-workspace calls are +unchanged. A job carries its own `output_dir`, `asset_dir` and `workflow_dir` +(`JobManager.submit`), so it stays in its workspace whatever the manager serves +next; the worker activates the asset root per job (`activate_asset_dir`), which +is the one root that could not stay process-wide. `jobs.sqlite` has a +`workspace` column, backfilled to `default`. Reserved names: `workflows`, +`prompts`, `assets`, `outputs`. + +### Workflow sources + +`dw/workflow_sources.py` is the server's workflow search path: the writable +directory first (the workspace's `workflows/`), then any `--examples-dir`, each +read-only. Reads (`listing`, `find_workflow`) span every root front-to-back so an +earlier name shadows a later one; `PUT /api/workflows` always resolves through +`writable_source`, so saving something opened from a read-only root writes a copy +rather than overwriting it, and `DELETE` on a read-only root answers 403. A job +carries the root it is confined to (`JobManager.submit(workflow_dir=...)`), so an +examples workflow runs confined to the examples directory rather than to the +writable one. Packaged `dw/workflows/` is off the path - it is what `builtin:` +sub-workflow steps name, resolved in `dw/workflow.py`. + +### Workspaces + +`dw/workspace.py` resolves the one directory a run's content belongs to - +`workflows/`, `prompts/`, `assets/`, `outputs/`. Order: `--workspace` > +`DW_WORKSPACE` > the `workspace` setting > the working directory when it holds +any of `workflows/`, `prompts/` or `outputs/` > `~/diffusers-workspace`. A +checkout satisfies rule four, so every default lands where it did before +workspaces existed. Resolution creates nothing; an entry point about to write +calls `ensure()` (or creates the one folder it needs). `set_workspace` pins the +root *and* how it was chosen into the environment, so a spawned worker does not +read an inferred workspace back as one the user named - `get_prompt_dir` yields +to its older discovery (`./prompts`, then the walk up from the workflow file) +for an inferred workspace but not for an explicit one. `--workflow-dir`, +`--output-dir` and `--prompt-dir` each still override one folder. See +docs/WORKSPACES.md, and docs/proposals/workspaces.md for the later stages +(workflow search path, run directories, `asset:`/`output:` references). + ### Type System `arguments.py` + `type_helpers.py` handle dynamic type conversion during workflow loading: @@ -60,6 +105,23 @@ The REPL (`dw/repl.py`) uses a **persistent worker subprocess** (`dw/worker.py`) - Values prefixed with `constant:` read a value declared in python rather than copying it into JSON: `"constant:diffusers.pipelines.ltx2.utils.DISTILLED_SIGMA_VALUES"`. Resolved in `realize_args`, validated by `validate_constant_name()`; anything callable is refused +- Values prefixed with `asset:` resolve to the path of a file in the asset library: + `"asset:iris.png"` or `"asset:gyre/frames/web.mp4"`. Resolved in `realize_args` before + every other convention (`dw/assets.py`), rooted at the library rather than the workflow + file, confined to it, and then loaded by whatever would have loaded a path written + there. The library is `DW_ASSET_DIR` / `--asset-dir`, else the workspace's `assets/` + when a workspace was named, else `./assets` if it exists, else found by walking up + from the workflow file's directory +- Values prefixed with `output:` resolve to the path of a file an earlier run wrote: + `"output:ltx2/Gyre/latest/still.png"`. The name is `//` + under the output root, and `latest` in the run-id position picks the newest run that + holds the file (run ids sort by their UTC timestamp; a failed or fully-cached run holds + only a manifest and is skipped). Resolved in `realize_args` beside `asset:` (`dw/runs.py`), + against the output root `Workflow.run` activates, and confined to it +- A generated file becomes a stable input with `POST /api/assets/keep` (gallery "Keep as + asset", MCP `keep_output`): it is hard-linked, else copied, from the workspace's outputs + into its assets under a chosen name, so later workflows reference `asset:name` rather + than a run id that pruning would break - Values prefixed with `prompt:` load a stored prompt's `text` from the prompt library: `"prompt:name"` or `"prompt:folder/name"`. Resolved in `realize_args` (`dw/prompts.py`), rooted at the library rather than the workflow file. The library is `DW_PROMPT_DIR` / @@ -107,9 +169,19 @@ All entry points use `dw/security.py`. When adding features: - **Built-in workflows** need explicit argument mapping: `"prompt": "variable:prompt"` - **MPS differences from CUDA**: no autocast, no bitsandbytes, no flash_attn, no triton, no torch.compile. Model offloading has less benefit on unified memory, and `"offload": "sequential"` is downgraded to `"model"` with a warning there (`place_component`) β€” per-submodule streaming hands back no residency when the CPU and the accelerator share one pool. `exclude_from_cpu_offload` is sequential-only and does not survive the downgrade. - **`{}`-escaped strings** in JSON arguments: `"{nf4}"` stays as string `"nf4"`, without braces it would try to load as a type -- **A stored prompt's `text` may not begin with a reference prefix** (`variable:`, `previous_result:`, `constant:`, `prompt:`) β€” the engine rejects it to prevent double resolution or iteration expansion +- **A stored prompt's `text` may not begin with a reference prefix** (`variable:`, `previous_result:`, `constant:`, `asset:`, `output:`, `prompt:`) β€” the engine rejects it to prevent double resolution or iteration expansion - **Audio+video muxing**: pipelines that generate audio alongside video (LTX-2) have the two muxed into one `video/mp4` file with PyAV in `result.py` -- **Step cache**: a process-wide singleton (`dw/step_cache.py`) consulted by every `Workflow.run`, including server jobs; entries are keyed by `(workflow id, step name)`; disabled entirely when the workflow sets no `seed`; a hit reports the earlier run's files with `reused: true` and writes nothing new; `memory clear` drops it +- **Run directories**: each execution writes `///` + with a `manifest.json` beside its files (`dw/runs.py`, `Workflow.effective_output_dir`). + Identity is the workflow's path under a `workflows/` tree, else its file name, else its + `id`; the run id is `-<8 hex of the spec>`, with a `-N` counter if taken. + A sub-workflow inherits the parent's run directory and writes no manifest of its own. + `--output-layout flat` / `DW_OUTPUT_LAYOUT` / the `output_layout` setting restores the + old layout. The gallery groups a workflow's runs under one folder by stripping the run + id (`strip_run_id`) +- **Step cache**: a process-wide singleton (`dw/step_cache.py`) consulted by every `Workflow.run`, including server jobs; entries are keyed by `(workflow id, step name)` and validated against the output + *root*, never the per-run directory - a run directory is new every execution and would + defeat the cache; disabled entirely when the workflow sets no `seed`; a hit reports the earlier run's files with `reused: true` and writes nothing new; `memory clear` drops it ## JSON Workflow Structure diff --git a/README.md b/README.md index 37fc3832..470c9429 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,14 @@ # diffusers-workflow -A declarative workflow engine and web UI for the [Hugging Face Diffusers library](https://github.com/huggingface/diffusers). Define image/video generation pipelines in JSON β€” with full access to the configuration diffusers exposes β€” and run them from the command line, an interactive REPL, or a browser. +Your GPU, as something an agent can drive. -A workflow JSON file is portable and easy to hand off β€” but that same flexibility means loading one can execute arbitrary Python (dynamic imports are how it reaches any diffusers pipeline or quantization backend without a bespoke adapter for each). Treat a workflow file from someone else the way you'd treat a `.py` script: see [Trust model](docs/SECURITY.md#trust-model) before running one you didn't write. +diffusers-workflow turns the [Hugging Face Diffusers library](https://github.com/huggingface/diffusers) +into a declarative engine β€” image and video pipelines described as data rather +than as Python β€” and then puts three front ends on it: an **MCP server** so +Claude Code (or any MCP client) can author, run and inspect generations; a +**web UI**; and a **CLI/REPL**. The JSON is the wire format underneath. Most +days you don't write it by hand. **Python 3.10-3.14 | CUDA (NVIDIA) | MPS (Apple Silicon) | CPU** @@ -13,44 +18,101 @@ A workflow JSON file is portable and easy to hand off β€” but that same flexibil The workflow browser: every workflow as a card with its description, output kinds, and variables -## Features +A workflow file is portable and easy to hand off β€” but that same flexibility +means loading one can execute arbitrary Python (dynamic imports are how it +reaches any diffusers pipeline or quantization backend without a bespoke +adapter for each). Treat a workflow file from someone else the way you'd treat +a `.py` script: see [Trust model](docs/SECURITY.md#trust-model) before running +one you didn't write. -- **Web UI** β€” browse and run workflows, edit them in introspection-driven forms, watch jobs stream live progress, manage generated output and the models on disk. `python -m dw.serve` and open a browser. See [Server & Web UI](docs/SERVER.md). -- **MCP server** β€” a tool surface that lets an MCP client (Claude Code first) author, validate, save, run and diagnose workflows against a running `dw.serve`: `dw-mcp` over stdio, or mounted at `/mcp` by `dw.serve --mcp` for a client on another machine. See [MCP Server](docs/MCP.md) and [Remote GPU server](docs/REMOTE.md). -- **Step-output caching** β€” a step whose resolved arguments and seed are unchanged since the last run in the same process reuses its cached result instead of re-executing. This applies to any `Workflow.run` β€” REPL iteration and a re-run of a server-submitted job alike, so re-running a fixed-seed workflow from the UI finishes instantly and writes no new output files. The cache holds the most recent 50 steps and is dropped by `memory clear` -- **Declarative JSON workflows** with variable substitution and cross-step data flow -- **Multi-step pipelines** β€” chain text-to-image, image-to-video, inpainting, ControlNet -- **Reproducible by construction** β€” outputs embed their full workflow definition and seed; any image in the gallery reopens as the exact workflow that made it (see [Trust model](docs/SECURITY.md#trust-model) before reopening one someone else sent you). A rerun never overwrites a prior output β€” a name collision gets an incrementing suffix instead -- **Long-video chaining** β€” run a video pipeline once per segment and stitch the segments into one clip, with audio-driven length and frame-to-frame continuity -- **Quantization** β€” BitsAndBytes, TorchAO, GGUF, SDNQ, optimum-quanto -- **Inference acceleration** β€” TeaCache, FirstBlockCache, FasterCache, MagCache, TaylorSeerCache -- **Prompt weighting** β€” A1111-style `(word:1.5)` syntax with long prompt support -- **Prompt library** β€” store prompts once in `prompts/` and reference them from any workflow as `prompt:name` or `prompt:folder/name`, with a web UI for browsing, editing, and AI-enhancing them -- **LoRA and IP-Adapter** support -- **Composable workflows** from multiple JSON files with `builtin:` references -- **Utility tasks** β€” upscaling, face restoration, segmentation, captioning, frame interpolation, QR codes, and more -- **Interactive REPL** with persistent GPU model caching (2-4x faster iteration) -- **Cross-platform** β€” CUDA, MPS (Apple Silicon), and CPU +## Drive it from Claude Code -## Installation - -### Linux / macOS +Two processes: the engine, and the agent that talks to it. ```bash -bash ./install.sh -source ./activate -python -m dw.test +# 1. the engine, holding your workspace. Leave it running. +python -m dw.serve --workspace ~/studio --examples-dir ~/src/diffusers-workflow/workflows + +# 2. register the MCP server with Claude Code (absolute path - see docs/MCP.md) +claude mcp add dw -- "$(pwd)/venv/bin/dw-mcp" ``` -### Windows +If the GPU is a different machine, start it with `--mcp` and skip the local +install entirely β€” Claude Code connects over HTTP: -```powershell -.\install.ps1 -.\venv\scripts\activate -python -m dw.test +```bash +# on the GPU box +python -m dw.serve --host 0.0.0.0 --token "$DW_API_TOKEN" --mcp --workspace ~/studio + +# on your laptop +claude mcp add --transport http dw http://gpu-box:8765/mcp \ + --header "Authorization: Bearer $DW_API_TOKEN" ``` -The install scripts detect your Python version, create a virtual environment, and install all dependencies including platform-specific packages (bitsandbytes on CUDA, fp4-fp8-for-torch-mps on macOS). +You don't have to compose that command by hand β€” the server's own **Server** +page builds it from the address you pick, alongside the directories it +resolved and the workspaces it holds: + +![The Server page: the address picker, the generated claude mcp add line, the resolved directories, and the workspace list](docs/img/ui-server-dark.png) + +Then just ask. The agent has 50 tools covering the whole surface β€” the +workflow catalog, the real diffusers pipeline signatures, the job queue, the +gallery, the model cache: + +![Claude Code driving the dw MCP server: creating a workspace, authoring a script, and generating from it](docs/img/claude-authoring.png) + +Generation is the long pass, and the agent stays with it β€” queuing each shot, +waiting it out, and reporting what came back: + +![The same session hours later: shots rendering one at a time, roughly 30 minutes each, with the agent reporting progress between them](docs/img/claude-generating.png) + +> **What can this box actually run, and what workflows do I already have?** +> +> Claude calls `get_server_info` (device, version, workspace), `list_workflows` +> (each with its description, variables and output kinds), and `list_models` +> (what's already in the hub cache). It tells you the accelerator before it +> proposes anything CUDA-only. + +> **Take my Flux workflow, swap in the portrait LoRA, and render four at 1024 +> square.** +> +> `get_workflow` to read it, `get_pipeline_signature` to check the arguments +> actually exist, `validate_workflow` (free β€” schema *and* signature checking, +> no model loads), `save_workflow` into your workspace, then `run_workflow`. +> That last one refuses unless it passes `acknowledged_cost=true`, so the agent +> has to tell you it's about to spend GPU minutes before it spends them. + +> **How's it going?** +> +> `wait_for_job` blocks for a bounded interval instead of hand-polling; +> `get_job_events` pages through per-step and per-denoise-step progress. +> `get_output_image` brings the result back into the conversation, downscaled, +> so the agent can look at what it made and say whether it matches what you +> asked for. + +> **That third frame is the one. Keep it, and use it to seed the video pass.** +> +> `keep_output` promotes the file into the asset library under a name you pick +> β€” the agent then writes `asset:hero-frame.png` into the next workflow, rather +> than a run id that pruning would break. + +Nothing above needs a shell on the GPU box or a checkout of this repository. +Six tools that cost real money or real disk (`run_workflow`, `rerun_job`, +`enhance_prompt`, `download_model`, `delete_model`, `update_diffusers`) plus +`delete_workspace` refuse until they're explicitly acknowledged, so an agent +cannot quietly burn an hour of GPU time or delete 40GB of weights. + +Full setup, the complete tool reference, and the troubleshooting table: +[MCP Server](docs/MCP.md). Running it on another machine end to end: +[Remote GPU server](docs/REMOTE.md). + +### Several agents, one GPU + +A server holds several **workspaces** β€” each with its own workflows, assets +and outputs, sharing one prompt library. An agent calls `use_workspace` once +and everything it reads and writes for the rest of the session lands there, so +two agents (or an agent and you, in the browser) share the GPU without saving +over each other. See [Workspaces](docs/WORKSPACES.md). ## The Web UI @@ -59,36 +121,59 @@ python -m dw.serve # diffusers-workflow server on http://127.0.0.1:8765 ``` -Everything the engine does, in a browser backed by a persistent GPU worker β€” models stay loaded between runs. +Everything the engine does, in a browser backed by the same persistent GPU +worker β€” models stay loaded between runs. -**A form-based editor with the real pipeline signatures.** Forms and argument autocomplete are generated by introspecting diffusers itself, so every knob a pipeline exposes is available β€” with its documentation β€” without leaving the browser. A split view puts the JSON beside the form, both editable; validation catches schema errors *and* argument typos (by checking the pipeline's actual call signature) before any model loads. +**A form-based editor with the real pipeline signatures.** Forms and argument +autocomplete are generated by introspecting diffusers itself, so every knob a +pipeline exposes is available β€” with its documentation β€” without leaving the +browser. A split view puts the JSON beside the form, both editable; validation +catches schema errors *and* argument typos (by checking the pipeline's actual +call signature) before any model loads. A flow view draws the workflow's +data-flow graph. ![The editor: introspection-driven forms beside live JSON in Monaco](docs/img/ui-editor.png) -**A gallery where every image is a recipe.** Outputs embed their workflow and seed; *open as workflow* drops the definition into the editor with the seed pinned, ready to reproduce or riff on. +**A gallery where every image is a recipe.** Each run gets its own directory +with a `manifest.json` beside its files, and images carry their full workflow +definition and seed; *open as workflow* drops the definition into the editor +with the seed pinned, ready to reproduce or riff on. *Keep as asset* promotes a +generated file into the asset library so later workflows can rely on it. ![The gallery with generated images and videos](docs/img/ui-gallery.jpg) -**A prompt library shared by every workflow.** Store a prompt once, reference it anywhere as `prompt:name` β€” the Prompts page browses, edits, and filters the library, and an *Enhance with AI* panel expands an idea into a full prompt with a local language model. +**A prompt library shared by every workflow.** Store a prompt once, reference +it anywhere as `prompt:name` β€” the Prompts page browses, edits, and filters the +library, and an *Enhance with AI* panel expands an idea into a full prompt with +a local language model. -**A model manager for the disk your models actually consume.** The Hugging Face hub cache, inventoried: sizes, revisions, last-used dates, free space β€” download new models by id with live progress, delete with one click. +**A model manager for the disk your models actually consume.** The Hugging Face +hub cache, inventoried: sizes, revisions, last-used dates, free space β€” download +new models by id with live progress, delete with one click. ![The model manager listing cached models with sizes](docs/img/ui-models.png) -Jobs queue, stream progress live (per denoising step), cancel cooperatively, and persist to a searchable history. See [Server & Web UI](docs/SERVER.md) for the pages and the HTTP API. - -## Usage +Jobs queue, stream progress live (per denoising step), cancel cooperatively, +and persist to a searchable history. See [Server & Web UI](docs/SERVER.md) for +the pages and the HTTP API. -### Run a Workflow +## The command line -`workflows/sd15.json` is the smallest, fastest starting point β€” a small, ungated model and a literal prompt, so the first run needs no Hugging Face login and downloads only a few GB: +The engine runs standalone, with no server involved. +`workflows/sd15.json` is the smallest starting point β€” a small, ungated model +and a literal prompt, so the first run needs no Hugging Face login and +downloads only a few GB: ```bash python -m dw.run workflows/sd15.json python -m dw.run workflows/sd15.json prompt="a cat" num_images_per_prompt=4 +python -m dw.validate workflows/sd15.json ``` -Most of the workflows under `workflows/` (Flux, LTX-2, MiniMax...) use **gated** Hugging Face models β€” the repo owner has to approve your account before you can download them. Before running one of those, request access on the model's Hugging Face page (e.g. [black-forest-labs/FLUX.1-dev](https://huggingface.co/black-forest-labs/FLUX.1-dev)) and log in locally: +Most of the workflows under `workflows/` (Flux, LTX-2, MiniMax...) use **gated** +Hugging Face models β€” the repo owner has to approve your account first. Request +access on the model's page (e.g. [black-forest-labs/FLUX.1-dev](https://huggingface.co/black-forest-labs/FLUX.1-dev)), +then: ```bash huggingface-cli login @@ -97,17 +182,8 @@ python -m dw.run workflows/flux/FluxDev.json Without this, the run fails partway through with an HTTP 401/403 from the Hub. -### Validate a Workflow - -```bash -python -m dw.validate workflows/sd15.json -``` - -### Interactive REPL - -```bash -python -m dw.repl -``` +An interactive REPL keeps models resident between runs for 2-4x faster +iteration: ```text dw> workflow load flux/FluxDev @@ -119,156 +195,121 @@ dw> arg set prompt="a starry night" dw> workflow run Reusing loaded models from cache [... 2-4x faster ...] - -dw> memory show -dw> ? # show all command groups ``` See [REPL Commands](docs/REPL_COMMANDS.md) and [Worker Guide](docs/REPL_WORKER_GUIDE.md). -## Workflow Examples +## Installation -### Simple Image Generation +### Linux / macOS -```json -{ - "id": "flux_example", - "variables": { - "prompt": "an apple", - "num_images_per_prompt": 1 - }, - "steps": [ - { - "name": "main", - "pipeline": { - "configuration": { - "component_type": "FluxPipeline", - "offload": "sequential" - }, - "from_pretrained_arguments": { - "model_name": "black-forest-labs/FLUX.1-dev", - "torch_dtype": "torch.bfloat16" - }, - "arguments": { - "prompt": "variable:prompt", - "num_inference_steps": 25, - "num_images_per_prompt": "variable:num_images_per_prompt", - "guidance_scale": 3.5 - } - }, - "result": { - "content_type": "image/jpeg" - } - } - ] -} +```bash +bash ./install.sh +source ./activate +python -m dw.test ``` -Override variables from the command line: +### Windows -```bash -python -m dw.run flux_example.json prompt="an orange" num_images_per_prompt=4 +```powershell +.\install.ps1 +.\venv\scripts\activate +python -m dw.test ``` -### Multi-Step Workflow (Image to Video) +The install scripts detect your Python version, create a virtual environment, +and install all dependencies including platform-specific packages (bitsandbytes +on CUDA, fp4-fp8-for-torch-mps on macOS). + +## What a workflow is -Chain steps using `previous_result:step_name` to pass outputs between steps: +Underneath every front end is one JSON document: named steps, each a diffusers +pipeline or a utility task, with arguments that can reference variables, +earlier steps' outputs, stored prompts, assets, and files an earlier run wrote. ```json { - "id": "img2vid", + "id": "flux_example", + "variables": { "prompt": "an apple" }, "steps": [ { - "name": "image_generation", + "name": "main", "pipeline": { - "configuration": { - "component_type": "StableDiffusion3Pipeline", - "offload": "model" - }, + "configuration": { "component_type": "FluxPipeline", "offload": "sequential" }, "from_pretrained_arguments": { - "model_name": "stabilityai/stable-diffusion-3.5-large", + "model_name": "black-forest-labs/FLUX.1-dev", "torch_dtype": "torch.bfloat16" }, "arguments": { - "prompt": "a luminous owl in a neon forest", + "prompt": "variable:prompt", "num_inference_steps": 25, - "guidance_scale": 4.5 - } - }, - "result": { "content_type": "image/png" } - }, - { - "name": "video", - "pipeline": { - "configuration": { - "component_type": "CogVideoXImageToVideoPipeline", - "offload": "sequential", - "vae": { "configuration": { "enable_slicing": true, "enable_tiling": true } } - }, - "from_pretrained_arguments": { - "model_name": "THUDM/CogVideoX-5b-I2V", - "torch_dtype": "torch.bfloat16" - }, - "arguments": { - "image": "previous_result:image_generation", - "prompt": "The owl blinks slowly", - "num_inference_steps": 50, - "num_frames": 49, - "guidance_scale": 6 + "guidance_scale": 3.5 } }, - "result": { "content_type": "video/mp4" } + "result": { "content_type": "image/jpeg" } } ] } ``` -### Inference Acceleration +Arguments carry references rather than paths, which is what makes multi-stage +work composable: -Speed up generation with built-in diffusers caching or TeaCache: +| Reference | Resolves to | +| --- | --- | +| `variable:prompt` | A workflow variable, overridable from the CLI, the UI form, or a tool call | +| `previous_result:step_name` | An earlier step's output β€” this is how text-to-image chains into image-to-video | +| `prompt:folder/name` | The text of a stored prompt in the shared prompt library | +| `asset:iris.png` | A file in the workspace's input-media library | +| `output:ltx2/Gyre/latest/still.png` | A file an earlier run wrote, `latest` picking the newest run that holds it | +| `constant:...` | A value declared in Python rather than copied into JSON | -```json -"configuration": { - "component_type": "FluxPipeline", - "cache": { "type": "first_block", "threshold": 0.05 } -} -``` +The full structure β€” steps, tasks, offloading, quantization, LoRAs, +schedulers, chained video β€” is in the [Workflow Guide](docs/WORKFLOW_GUIDE.md). +The schema the server validates against is browsable +[here](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2Fdkackman%2Fdiffusers-workflow%2Frefs%2Fheads%2Fmaster%2Fdw%2Fworkflow_schema.json), +and [workflows/](workflows/) is a corpus of runnable examples. -```json -"configuration": { - "component_type": "FluxPipeline", - "teacache": { "rel_l1_thresh": 0.6 } -} -``` - -### Prompt Weighting - -Use A1111-style syntax for per-token weighting: - -```json -"configuration": { - "component_type": "FluxPipeline", - "prompt_weighting": true -} -``` - -```text -a (photorealistic:1.4) portrait with (bright red hair:1.3) and [freckles] -``` - -## JSON Schema - -**Interactive schema browser:** [View Schema](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2Fdkackman%2Fdiffusers-workflow%2Frefs%2Fheads%2Fmaster%2Fdw%2Fworkflow_schema.json) +## Features -See [workflows/](workflows/) for more workflow files. +- **MCP server** β€” 50 tools letting an agent author, validate, save, run, + watch and inspect generations against a running server, locally or over the + network, with a cost gate on everything that spends GPU time or disk +- **Web UI** β€” browse and run workflows, edit them in introspection-driven + forms, watch jobs stream live progress, manage output and models +- **Workspaces** β€” your workflows, prompts, assets and outputs live outside the + checkout; one server can hold several, so several agents don't collide +- **Reproducible by construction** β€” each run writes its own directory with a + manifest; outputs embed their full workflow definition and seed, and any + image in the gallery reopens as the exact workflow that made it +- **Step-output caching** β€” a step whose resolved arguments and seed are + unchanged reuses its cached result instead of re-executing, so re-running a + fixed-seed workflow finishes instantly and writes no new files +- **Multi-step pipelines** β€” chain text-to-image, image-to-video, inpainting, + ControlNet; compose workflows from other workflows with `builtin:` +- **Long-video chaining** β€” run a video pipeline once per segment and stitch + the segments into one clip, with audio-driven length and frame-to-frame + continuity +- **Quantization** β€” BitsAndBytes, TorchAO, GGUF, SDNQ, optimum-quanto +- **Inference acceleration** β€” TeaCache, FirstBlockCache, FasterCache, + MagCache, TaylorSeerCache +- **Prompt weighting** β€” A1111-style `(word:1.5)` syntax with long prompt support +- **Prompt library** β€” store a prompt once, reference it from any workflow, + with a UI for browsing, editing and AI-enhancing +- **LoRA and IP-Adapter** support +- **Utility tasks** β€” upscaling, face restoration, segmentation, captioning, + frame interpolation, QR codes, and more +- **Interactive REPL** with persistent GPU model caching +- **Cross-platform** β€” CUDA, MPS (Apple Silicon), and CPU ## Documentation ### Guides +- [MCP Server](docs/MCP.md) β€” The agent tool surface (Claude Code, Claude Desktop) - [Server & Web UI](docs/SERVER.md) β€” The web UI, jobs API, and introspection service -- [MCP Server](docs/MCP.md) β€” Tool surface for MCP clients (Claude Code, Claude Desktop) - [Remote GPU server](docs/REMOTE.md) β€” Using the server, UI and MCP from another machine +- [Workspaces](docs/WORKSPACES.md) β€” Where your content lives, run directories, and several workspaces on one server - [Workflow Guide](docs/WORKFLOW_GUIDE.md) β€” JSON structure, variables, steps, data flow - [Quantization](docs/QUANTIZATION.md) β€” BitsAndBytes, TorchAO, GGUF, SDNQ - [Inference Acceleration](docs/ACCELERATION.md) β€” torch.compile, FirstBlockCache, MagCache, TaylorSeer, TeaCache diff --git a/docs/MCP.md b/docs/MCP.md index 0e1d549d..6f13f935 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -32,6 +32,7 @@ which is why the client needs a command it can actually find (see below). | --- | --- | --- | | `--url` | `$DW_MCP_URL`, else `http://127.0.0.1:8765` | Base URL of the running `dw.serve` | | `--token` | `$DW_API_TOKEN`, else none | Bearer token, when `dw.serve` was started with `--token` / `DW_API_TOKEN` - the same variable, so one export configures both ends | +| `--workspace` | `$DW_MCP_WORKSPACE`, else the server's default | Which of the server's workspaces the session works in. A *name* on the server, not a directory here - `DW_WORKSPACE` means something else to the engine. `use_workspace` switches it mid-session | | `--timeout` | `30` | Seconds to wait on any one API request | | `--no-probe` | off | Skip the startup `GET /api/health` that confirms the server is reachable and the token is accepted | @@ -175,7 +176,7 @@ Nothing in this sequence costs GPU time. ## Tool reference -43 tools in six groups. Names and arguments below are transcribed from +50 tools in six groups. Names and arguments below are transcribed from `dw_mcp/server.py` β€” nothing here is renamed or reshaped for the docs. ### Catalog (read-only) @@ -189,21 +190,21 @@ workflow is a preference, not a rule; `run_workflow` still takes an | Tool | Arguments | Purpose | | --- | --- | --- | -| `list_workflows()` | β€” | List stored workflows, each with its description, output kinds, step count, variable names and the stored prompts it references. The first call to make for a request an existing workflow might cover | +| `list_workflows()` | β€” | List stored workflows, each with its description, output kinds, step count, variable names, the stored prompts it references, and its `origin`/`writable` - a workflow from a read-only examples directory can be read and run but not saved over or deleted. The first call to make for a request an existing workflow might cover | | `get_workflow(name)` | `name` | Get one stored workflow's full JSON definition | | `get_schema()` | β€” | Get the JSON schema every workflow definition must satisfy | | `list_pipelines()` | β€” | List every diffusers pipeline class this installation provides | | `get_pipeline_signature(name)` | `name` | Get a pipeline's real call arguments | | `list_classes(kind)` | `kind` | List class names of one kind: pipelines, models, schedulers, or quantization | -| `get_class(name, target="init")` | `name`, `target` (`init`\|`call`\|`load`) | Get a class's argument schema | +| `get_class(name, target="init")` | `name`, `target` (`init`\|`call`\|`load`) | Get a class's argument schema from the entry point a workflow reaches it by: `init` the constructor (quantization configs, schedulers), `call` a pipeline's `__call__`, `load` `from_pretrained` plus the curated loading knobs | | `list_tasks()` | β€” | List every task command a workflow's task step can name | | `get_task(command)` | `command` | Get a task command's argument schema | | `list_models()` | β€” | List what the Hugging Face model cache holds, largest first | | `get_memory()` | β€” | Get the worker's VRAM and RAM statistics | | `get_health()` | β€” | Check that the server is alive, and which machine answered: `version`, `device`, whether the worker process is up, the job running now and the queue depth | -| `get_server_info()` | β€” | What this installation can do and where it keeps things: `device` (the accelerator a run will use), `version`, the workflow/output/prompt `directories`, 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()` | β€” | List queued, running and recent jobs | -| `list_gallery(limit=50)` | `limit` | List generated output files, newest first | +| `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()` | β€” | List queued, running and recent jobs. In a named workspace, that workspace's jobs; in the default one, every job the server holds | +| `list_gallery(limit=50)` | `limit` | List generated output files, newest first. A name is `//`; 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 | | `get_gallery_metadata(name)` | `name` | Get the metadata embedded in a generated file: the exact workflow and arguments that produced it | ### Media @@ -215,12 +216,26 @@ workflow is a preference, not a rule; `run_workflow` still takes an | `download_output(name, destination=None, overwrite=False)` | `name`, `destination`, `overwrite` | 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 | | `delete_output(name)` | `name` | Permanently remove one generated file from the output directory | -### Authoring +### Authoring, assets and workspaces + +Authoring happens inside one workspace. A server can hold several - each with +its own `workflows/`, `assets/` and `outputs/`, all sharing one prompt library +- and `use_workspace` picks the one this session reads and writes for the rest +of its life. That is how two agents work against one GPU without saving over +each other; see [Workspaces](WORKSPACES.md#several-workspaces-on-one-server). +The session starts in `default` and stays there unless it is told otherwise. | Tool | Arguments | Purpose | | --- | --- | --- | | `validate_workflow(workflow=None, name=None)` | exactly one of `workflow` (inline definition) or `name` (a stored workflow, as `list_workflows` reports it) | 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 | -| `save_workflow(name, workflow)` | `name`, `workflow` | Save a workflow to the server, overwriting any existing workflow of that name | +| `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)` | `name` | Create a workspace. Creating does not switch to it | +| `delete_workspace(name, acknowledged_cost=False)` | `name`, `acknowledged_cost` | Permanently delete a workspace and everything in it. Refuses without the acknowledgement, reporting what it would remove | +| `list_assets()` | β€” | 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 | +| `keep_output(name, asset_name=None, overwrite=False)` | `name`, optional `asset_name`, `overwrite` | Keep a generated file as an input asset under a stable `asset:` name, so a later workflow can rely on it. The copy happens on the server: nothing is downloaded or re-uploaded | +| `upload_asset(file_path)` | `file_path` | Push a local image, video or audio file into the server's asset library and get back its `asset:` reference. The file is read from the machine the MCP server runs on, so this is how an input reaches a dw.serve running somewhere else | +| `save_workflow(name, workflow)` | `name`, `workflow` | Save a workflow into the server's writable workflow directory, overwriting any existing workflow of that name there. A name that currently resolves to a read-only source (an examples directory) is not overwritten - the copy lands in the writable directory and shadows it | | `delete_workflow(name)` | `name` | Permanently delete a stored workflow | ### Prompts @@ -235,7 +250,7 @@ references written in the same session. | `list_prompts()` | β€” | List the stored prompts with their text and descriptions | | `get_prompt(name)` | `name` | Get one stored prompt's full definition | | `get_prompt_schema()` | β€” | Get the JSON schema every stored prompt must satisfy. Its own route rather than a name under `/api/prompts`, so a prompt called `schema` cannot shadow it | -| `save_prompt(name, prompt)` | `name`, `prompt` | Save a prompt, overwriting any prompt of that name. The server validates first, and refuses a `text` that itself begins with `variable:`, `previous_result:`, `constant:` or `prompt:` | +| `save_prompt(name, prompt)` | `name`, `prompt` | Save a prompt, overwriting any prompt of that name. The server validates first, and refuses a `text` that itself begins with a reference prefix (`variable:`, `previous_result:`, `constant:`, `asset:`, `output:`, `prompt:`) | | `delete_prompt(name)` | `name` | Permanently delete a stored prompt. A workflow still referencing it will fail to load | | `list_enhancers()` | β€” | List the enhancer presets `enhance_prompt` accepts | | `enhance_prompt(idea, preset="h3", model_name=None, device=None, acknowledged_cost=False)` | `idea`, `preset`, optional `model_name` and `device`, `acknowledged_cost` | Expand a short idea into a full prompt with a language model. Queued as an ordinary job, so it passes the gate; the enhanced text is the text file in the finished manifest, readable with `get_output_text` | @@ -271,7 +286,7 @@ hazard twice. That refusal arrives as the server's own explanation. ## The cost gate -Six tools refuse unless `acknowledged_cost=true` is passed. Each commits +Seven tools refuse unless `acknowledged_cost=true` is passed. Each commits the machine to something the user would want to have been asked about first, and each says so in its own words β€” a single shared refusal would be wrong for each of them in a different way, and a gate the user learns to wave @@ -285,6 +300,7 @@ through is not a gate. | `delete_model` | Cached weights, unrecoverably β€” getting them back means downloading again | | `update_diffusers` | Replacing the installed library with an untagged development build | | `enhance_prompt` | A real job on the one-at-a-time engine, delaying any generation behind it | +| `delete_workspace` | Every workflow, asset and generated file in a workspace, unrecoverably | `rerun_job` is gated for the same reason as `run_workflow`: it queues the identical work, so leaving it open would make the gate worth nothing β€” any @@ -292,9 +308,10 @@ job id from `list_jobs` would buy a way around it. `cancel_job` and `cancel_download` are deliberately *not* gated: they end a cost rather than starting one, and gating them would make the safe direction the harder one. -Passing the flag does not make a tool wait. Each returns as soon as the work -is queued or started, the same way queuing a job from the web UI does not -block the browser tab. +Passing the flag does not make a tool wait. The five that start work return +as soon as it is queued or started, the same way queuing a job from the web UI +does not block the browser tab; `delete_model` and `delete_workspace` are +deletions rather than queued work and complete before they answer. The intended loop: @@ -354,12 +371,16 @@ default) for any server an MCP client can reach. - **Images only.** `get_output_image` decodes and returns images; it refuses video and audio outputs. Use `get_gallery_metadata` to inspect other media kinds. -- **No upload.** Files move outward only. There is no tool for `POST - /api/uploads` (the web UI's file picker route), so an input image or video - a workflow conditions on has to already be on the server machine, or be - reachable by URL - the arguments that take a path take a URL too. `download_output` - moves a *generated* file, and on a `dw.serve --mcp` endpoint it writes on - the GPU box, not the client's machine. +- **Uploads read the MCP server's disk.** `upload_asset(file_path)` pushes a + local file into the asset library, but "local" means the machine `dw-mcp` + 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). + `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. +- **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. ## Troubleshooting diff --git a/docs/SERVER.md b/docs/SERVER.md index ea8c43d3..32c34732 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -8,7 +8,14 @@ past generations in a gallery, and manage the models on disk. ```bash python -m dw.serve # http://127.0.0.1:8765 python -m dw.serve --port 8000 --workflow-dir ./workflows --output-dir ./outputs --prompt-dir ./prompts + +# or point it at a workspace, which supplies all four directories +python -m dw.serve --workspace ~/studio + +# your own workflows, with a checkout's examples alongside them read-only +python -m dw.serve --workspace ~/studio --examples-dir ~/src/diffusers-workflow/workflows python -m dw.serve --host 0.0.0.0 --token "some-long-random-string" # reachable off this machine +python -m dw.serve --host 0.0.0.0 --token "..." --mcp # ...and drivable by an agent on another machine python -m dw.serve --trust-workflows # only if nothing untrusted can reach POST /api/jobs - see Security model ``` @@ -21,10 +28,13 @@ load entirely. ## The pages -- **Workflows** β€” every JSON file under `--workflow-dir`, as cards with - descriptions, output kinds, and variable counts. Folders one level deep - become sections. Click through to a run form generated from the workflow's - variables, with the raw JSON alongside. +- **Workflows** β€” every workflow on the search path (the workspace's own + `--workflow-dir` first, then any `--examples-dir`, read-only), as cards + with descriptions, output kinds, and variable counts. Folders one level + deep become sections. Click through to a run form generated from the + workflow's variables, with the raw JSON alongside. When the server holds + more than one workspace, a picker here chooses which one's workflows are + listed and where a save lands. - **Prompts** β€” the prompt library under `--prompt-dir` (default: discovered the way a CLI run discovers it, then pinned for every job, so the page and `prompt:` resolution always agree): stored prompts as @@ -36,7 +46,9 @@ load entirely. argument written as `prompt:name` loads the stored text at run time, and deleting a prompt warns which workflows reference it. - **Jobs** β€” the queue and full run history (persisted in - `~/.diffusers_helper/jobs.sqlite`). A running job streams step-by-step + `~/.diffusers_helper/jobs.sqlite`), spanning every workspace with a filter + to narrow to one; each job says which workspace it ran in, and keeps it + through a rerun. A running job streams step-by-step progress, per-step denoising ticks, what each step is doing when it is not denoising (loading a model, decoding, saving), and its result files as they land. @@ -58,14 +70,21 @@ load entirely. literal `num_images_per_prompt` on both producers). It's a diagram of the JSON, not a second way to edit it; clicking a step jumps to it in the form view. -- **Gallery** β€” everything in the output directory. Images generated with +- **Gallery** β€” everything in the selected workspace's output directory, + which the engine lays out as `//`. The folder filter groups a workflow's runs + together rather than listing each run separately, and each run directory + also holds a `manifest.json` describing what produced it (see + [Workspaces](WORKSPACES.md#runs)). Images generated with `embed_metadata` carry their full workflow definition and seed; **open as workflow** loads that definition into the editor with the seed pinned, so any image can be reproduced or riffed on. Each tile carries a checkbox (shift-click extends a range, **Select all** takes whatever the filter leaves showing); a selection can be downloaded as one zip or deleted in bulk, which is how a directory that fills up over a few hundred runs gets - cleared out. Anything that fails to delete stays selected. + cleared out. Anything that fails to delete stays selected. **Keep as + asset** promotes one generated file into the workspace's asset library + under a stable name, so a later workflow can reference it as + `asset:` instead of a run id that pruning would break. - **Models** β€” the Hugging Face hub cache: every cached repo with sizes, revisions, and last-used dates, plus free disk space. Download a repo by id with live progress (cancellable; partial files resume on retry), and @@ -75,11 +94,38 @@ load entirely. upgrade it to GitHub HEAD - new model pipelines usually land there before a PyPI release. The idle worker restarts on success so the next job imports the new version; the upgrade is refused while a job runs. +- **Server** β€” what this server is and how to reach it: device, version, + bind address and LAN addresses, whether a token is required, whether + `/mcp` is mounted (with the `claude mcp add` line to connect to it), the + directories in use, and the workspaces on this server β€” created and + deleted from here. - **Schema** β€” the workflow JSON schema the running server validates against, as a browsable tree: the document root plus every definition, with types, required markers, defaults, enums, and descriptions. `$ref` labels jump to their definition; a filter narrows the list. +## Workspaces + +One server can hold several workspaces β€” each with its own `workflows/`, +`assets/` and `outputs/`, all sharing the root's one prompt library. The +root's own folders are the workspace named `default`. + +Every scoped route takes an optional `?workspace=`; omitting it means +`default`, so nothing written against a single-workspace server changes +meaning. `POST /api/jobs` also accepts `"workspace"` in the body, and a job +holds onto the directories it was submitted with β€” through the run, a rerun, +and when history serves its files back. `GET /api/jobs` spans every workspace +unless one is named. + +A workspace is a namespace, not a security boundary: the API token is +all-or-nothing. See [Workspaces](WORKSPACES.md#several-workspaces-on-one-server). + +The Server page lists them, creates them, and deletes them - beside the +directories it resolved and the `claude mcp add` line for connecting an agent +from another machine: + +![The Server page: address picker, generated claude mcp add line, resolved directories, and the workspace list](img/ui-server-dark.png) + ## Jobs API | Route | What it does | @@ -170,15 +216,37 @@ The editor's forms come from these; they are just as usable from scripts: throttles a burst of single downloads, so the gallery's bulk download goes through here; an unknown or out-of-directory name 404s the whole request rather than yielding a partial archive -- `POST /api/uploads?filename=...` β€” the raw bytes of one image or video +- `GET /api/workspaces`, `POST /api/workspaces` (`{"name": ...}`), + `DELETE /api/workspaces/{name}?acknowledged=true` β€” the workspaces on this + server. The workspace root's own `workflows/assets/outputs` are the + `default` workspace and a named one is a subdirectory beside them, sharing + the root's one prompt library. Delete answers with what it would remove and + refuses until acknowledged, refuses the default, and refuses a workspace + with jobs still queued. A workspace is a namespace, **not** a security + boundary: the API token is all-or-nothing +- `GET /api/assets` β€” the asset library: input media, each with the + `asset:` reference a workflow carries rather than a path, since a path + only means something on the server's own machine. Empty rather than an + error when no library is configured +- `POST /api/assets/keep` (`{"name": ..., "asset_name": ..., "overwrite": false}`) + β€” keep a generated file as an input asset under a stable name, returning + its `asset:` reference. A run's files are named by the run that made them, + which is the wrong thing for a later workflow to depend on: `latest` moves + and a pinned run id breaks when outputs are pruned. The copy happens inside + the workspace and is a hard link where the filesystem allows one, so + keeping one frame of a large render costs no second copy of it. Refuses an + existing name unless `overwrite` +- `POST /api/uploads?filename=...` β€” the raw bytes of one image, video or audio file (200MB ceiling, checked from `Content-Length` before a byte is read, and again on the body; extension held to the allowed image/video list), saved - into the output directory's `uploads/` subfolder under a generated name. - Answers 201 with `path` - the absolute path, which is the string shape a - workflow's `image`/`video` argument already takes - and `url`, the same - file under the `/outputs` mount. This is how the UI's file pickers get a - local file onto the machine that will run the workflow. The body is the - file itself, so no multipart parser is needed for a single-file upload + into the asset library's `uploads/` subfolder under a generated name. + Answers 201 with `path` - `asset:uploads/`, the reference a saved + workflow can carry and still resolve on a later run - and `url`, the same + file under the `/inputs` mount, for the editor's preview. A server started + without an asset library falls back to the output directory's `uploads/` + and an absolute path. This is how the UI's file pickers get a local file + onto the machine that will run the workflow. The body is the file itself, + so no multipart parser is needed for a single-file upload - `GET /api/models`, `DELETE /api/models?repo={repo_id}` β€” hub cache inventory and deletion - `POST /api/models/download` (`{"repo_id": ...}`), `GET /api/models/downloads`, diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index 56f61666..561f61f5 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -918,6 +918,75 @@ always resolves to exactly one string: it never multiplies a step's iterations t reference prefix such as `variable:` - the engine refuses it rather than resolving text as syntax. +### Asset References + +A workflow's plain media paths resolve against the workflow file's own directory, which +means a workflow that reads anything has to keep that thing beside it. An `asset:` +reference is rooted at the asset library instead - the workspace's `assets/` folder - +so a workflow and the media it reads do not have to live in the same place: + +```json +"image": "asset:iris.png", +"video": "asset:gyre/frames/web.mp4", +"references": [ + { + "reference_type": "diffusers.modular_pipelines.minimax_h3.MiniMaxH3ImageReference", + "from_file": "asset:subject.png" + } +] +``` + +A reference names a file with its extension, at most four folders deep, and resolves to +that file's path - so it works under any argument that accepts a path: `image`, `video`, +a `from_file`, a list of any of them, or a task argument that names a file. What loads +the path is unchanged; only where the path comes from is. + +The library's location is resolved in order: the `DW_ASSET_DIR` environment variable +(which `--asset-dir` on both `dw.run` and `dw.serve` sets), then the workspace's +`assets/` when a workspace was named explicitly, then `./assets` in the working +directory when it exists, then the first `assets/` folder found walking up from the +workflow file's own directory. See [Workspaces](WORKSPACES.md). + +A reference can only name a file inside the library: `..`, an absolute path, or a +symlink pointing out of it are all refused. Browser uploads land in the library's +`uploads/` folder and come back as `asset:uploads/`, so a workflow saved after +an upload still resolves on the next run. + +### Output References + +Multi-stage work β€” generate stills, then animate them; generate a score, then mux it β€” +used to mean copying files out of the output directory and back in beside the next +workflow. An `output:` reference names what an earlier run wrote, directly: + +```json +"image": "output:ltx2/Gyre/latest/Gyre-still.0-0.0.png", +"audio": "output:ltx2/GyreScore/20260905-181530-a1b2c3d4/Gyre-score.10-0.0.wav" +``` + +The name is a path under the output directory β€” the workflow's identity, the run, and +the file (see [Runs](WORKSPACES.md#runs)). Writing `latest` where the run id goes +resolves to the newest run of that workflow *that holds the file*, which is what lets a +second-stage workflow name the first stage's product without being edited after every +run - and keeps working when the newest run failed part way, or reused every step from +the cache and so wrote nothing of its own but a manifest. Runs sort by their id, which +starts with a UTC timestamp, so "newest" needs no file timestamps and survives a +directory being copied. `latest` only selects a run where run directories are; a +workflow or file that happens to be called `latest` is still named as itself. + +Like `asset:`, a reference resolves to a path and then whatever loads paths loads it, so +it works under `image`, `video`, a `from_file`, or a list of them. It resolves against +the output directory the run was told to write to, and cannot leave it: `..`, an +absolute path, and a symlink pointing out are all refused. + +To name an *earlier step of the same run*, use `previous_result:` instead β€” that passes +the value in memory rather than through the filesystem. + +A generated file worth reusing repeatedly is better *kept* than referenced by the run +that made it: `POST /api/assets/keep` (the gallery's **Keep as asset**, or MCP's +`keep_output`) copies it into the workspace's asset library under a name you choose, and +from then on it is an `asset:` reference like any other β€” stable whatever happens to the +run directory it came from. + ### Objects Built From a File Some pipelines take arguments that are objects rather than plain media. An argument that diff --git a/docs/WORKSPACES.md b/docs/WORKSPACES.md new file mode 100644 index 00000000..50fa827a --- /dev/null +++ b/docs/WORKSPACES.md @@ -0,0 +1,247 @@ +# Workspaces + +A workspace is the directory your own content lives in: the workflows you +write, the prompt library they reference, the assets they read, and the files +they generate. + +``` +/ + workflows/ your workflows + prompts/ the stored prompt library ('prompt:' references) + assets/ input media + outputs/ generated files +``` + +This exists so that day-to-day work does not have to live inside a checkout of +this repository. The examples under the repo's `workflows/` are still examples +β€” a corpus to read, copy and run β€” but they are not where your own workflows +belong, and generated media does not belong in a source tree at all. + +## Which directory is used + +First match wins: + +1. `--workspace ` on `dw.run`, `dw.serve` (`config set workspace=` in the REPL) +2. the `DW_WORKSPACE` environment variable +3. `"workspace"` in `~/.diffusers_helper/settings.json` +4. the working directory, when it holds any of `workflows/`, `prompts/` or `outputs/` +5. `~/diffusers-workspace` + +Rule 4 is why nothing changes when you work from a checkout: the repository +root holds all three, so it resolves to itself and every default lands exactly +where it always has. Only a working directory with none of those folders falls +through to the home workspace. + +Nothing is created just by resolving. A command that is about to write creates +what it needs β€” `dw.run` creates its output directory, `dw.serve` creates the +workspace's `workflows/` so the UI has somewhere to save. + +## Overriding one folder + +The existing per-directory flags still work and each overrides exactly one +folder of the workspace: + +```bash +python -m dw.run workflows/sd15.json -o /mnt/big-disk/renders +python -m dw.serve --workspace ~/studio --output-dir /mnt/big-disk/renders +python -m dw.run some.json --prompt-dir ~/shared-prompts +``` + +`--output-dir` is the one people reach for most: video work fills disks, and +the outputs folder is the one worth putting on another volume. + +## Working in a workspace + +```bash +mkdir -p ~/studio/{workflows,prompts,assets,outputs} +export DW_WORKSPACE=~/studio + +# or, standing, in ~/.diffusers_helper/settings.json +# { "workspace": "/home/you/studio" } + +python -m dw.serve # serves ~/studio +python -m dw.run ~/studio/workflows/x.json +``` + +An example from a checkout still runs by path, and writes into the workspace's +outputs: + +```bash +DW_WORKSPACE=~/studio python -m dw.run ~/src/diffusers-workflow/workflows/sd15.json +``` + +The server reports what it resolved at `GET /api/server`, under +`directories.workspace` alongside the three folder paths. + +## The prompt library + +`prompt:` references resolve to the workspace's `prompts/` when a workspace was +named explicitly (rules 1–3 above, or `DW_PROMPT_DIR`, which still wins over +everything). A workspace that was merely inferred from the working directory +does not preempt the older discovery β€” `./prompts`, then the nearest `prompts/` +above the workflow file β€” so a repository workflow keeps reaching the library +it lives beside. When the workspace is explicit, its `prompts/` becomes the library +even if it does not exist yet, so a checkout's `./prompts` is no longer found once +a standing workspace setting (like `DW_WORKSPACE` or `"workspace"` in settings.json) +is in place; `--prompt-dir` and `DW_PROMPT_DIR` still override it. This follows +the "explicit wins" rule. See [Prompt References](WORKFLOW_GUIDE.md#prompt-references). + +## Assets + +`assets/` is the input-media library. A workflow argument written as +`asset:name.ext` (or `asset:folder/name.ext`) resolves to that file's path, +rooted at the library rather than at the workflow file β€” so a workflow and the +media it reads no longer have to sit in the same folder. `--asset-dir` and +`DW_ASSET_DIR` override the folder, and browser uploads land in +`assets/uploads/`, coming back as `asset:uploads/` (and served for +preview under `/inputs/`, since the SPA's own bundles own `/assets/`). + +A generated file becomes an input the same way: **Keep as asset** in the +gallery (`POST /api/assets/keep`, `keep_output` over MCP) links or copies it +out of `outputs/` into `assets/` under a name you choose, so a later workflow +carries `asset:` rather than a run id that pruning would break. The copy +stays inside the workspace β€” a hard link where the filesystem allows one, so +keeping one frame of a large render costs no second copy of it. See +[Asset References](WORKFLOW_GUIDE.md#asset-references). + +## Where workflows are read from, and written to + +The server reads workflows from a search path and writes them to exactly one +place β€” the front: + +``` +/workflows/ yours, writable β€” every save lands here +<--examples-dir> read-only, repeatable +``` + +A name found in an earlier root shadows the same name in a later one, so a +workspace copy of an example is the one that runs. Reads β€” listing, opening, +downloading, validating, running β€” span the whole path. Saves and deletes do +not: `PUT` always writes into the writable root, and deleting something from a +read-only root is refused with a 403 that says where it came from. + +That makes "open an example, change it, save" do the obvious thing: the copy +lands in your library and shadows the example from then on, and the example +itself is never touched. It is also what stops an agent's saves landing in a +checkout β€” point `--workflow-dir` (or `--workspace`) at your own directory and +the repository's workflows at `--examples-dir`: + +```bash +python -m dw.serve --workspace ~/studio --examples-dir ~/src/diffusers-workflow/workflows +``` + +`GET /api/workflows` reports the path as `sources` and tags every entry with +its `origin` and `writable`, which is how the UI knows to hide delete and how +an MCP client can tell what it may change. + +The packaged workflows in `dw/workflows/` are deliberately *not* on the path. +They are the pieces a `builtin:` sub-workflow step names, resolved by the +engine where that step is read β€” not workflows to browse or run on their own. + +## Runs + +Each execution writes its own directory under the output folder, named by the +workflow and the run: + +``` +outputs/ + ltx2/Gyre/ + 20260905-181530-a1b2c3d4/ + Gyre-still.0-0.0.png + Gyre-video.1-0.0.mp4 + manifest.json +``` + +The folder is the workflow's identity β€” its path under a `workflows/` tree +when it has one, its file name otherwise, its `id` for an inline definition. +The run id is a timestamp plus a short digest of what actually ran, so two +runs of the same workflow sort by time and a rerun of an edited workflow is +visibly different; a second run of the same spec in the same second takes a +counter rather than sharing a directory. + +`manifest.json` records the run beside what it made β€” status, seed, arguments, +device, dw version, and each step's files, named relative to the directory so +it keeps describing itself if you move or copy it. It is written even when a +run fails part way, since the files it did write are on disk either way. A +sub-workflow is part of its parent's run: it writes into the same directory and +rolls up into the same manifest. + +An unchanged rerun still reuses the step cache: it writes no new files and its +manifest reports the earlier run's, marked `"reused": true`. + +A later workflow names what an earlier run made with an `output:` reference β€” +`output:ltx2/Gyre/latest/Gyre-still.0-0.0.png` β€” so a multi-stage pipeline no +longer needs files copied back by hand. See +[Output References](WORKFLOW_GUIDE.md#output-references). + +To keep the previous layout β€” everything at the output root, with only a +`workflows/`-mirroring subfolder β€” use `--output-layout flat`, `DW_OUTPUT_LAYOUT=flat`, +or `"output_layout": "flat"` in settings. Scripts that glob the output directory +are the reason to. + +## Several workspaces on one server + +Everything above describes one workspace, which is all `dw.run` and the REPL +ever see. `dw.serve` goes one step further: the workspace root can hold +several, and a client picks which one it is working in. + +``` +/ + workflows/ assets/ outputs/ <- the 'default' workspace + prompts/ <- shared by all of them + studio/ + workflows/ assets/ outputs/ <- the 'studio' workspace + scratch/ + workflows/ assets/ outputs/ <- the 'scratch' workspace +``` + +The root's own three folders are the workspace named `default`, so a server +that has never heard of named workspaces behaves exactly as it did. A named +workspace is a sibling directory holding the same three folders β€” and *not* a +`prompts/`, because there is one prompt library: `prompt:` is shared by +reference, and a prompt duplicated per workspace would resolve to different +text depending on where a workflow happened to be saved. `workflows`, +`prompts`, `assets` and `outputs` are reserved names for that reason. + +This is what lets two agents share one GPU without sharing a namespace: each +takes a workspace, and neither can save over the other's workflows or delete +the other's renders. + +**How a client picks one.** Every scoped route takes an optional +`?workspace=`; omitting it means `default`, which is why every +pre-workspace call still means what it meant. + +| Client | How | +| --- | --- | +| Web UI | The workspace picker on the Workflows and Gallery pages. The choice is remembered in `localStorage`, and the Jobs page adds a filter β€” job history spans every workspace and says which one each job ran in | +| MCP | `list_workspaces`, then `use_workspace(name)`. It is a session default rather than an argument on each call, so switching is one visible step in the transcript instead of a flag that can be forgotten on the call where it mattered | +| HTTP | `?workspace=` on the route, or `"workspace"` in a `POST /api/jobs` body | +| Server page | The Workspaces section lists them, creates and deletes them | + +A job carries its own workflow, asset and output directories, so it stays in +the workspace it was submitted from however many others the server serves +while it runs β€” including through a rerun, and when its files are served back +from history. + +**Creating and deleting.** `POST /api/workspaces` (`create_workspace` over +MCP) makes one; creating does not switch to it. Deleting removes everything in +it, so it refuses until acknowledged and answers first with what it would +remove β€” file counts and bytes per folder. The default workspace cannot be +deleted (it holds the shared prompt library, and there has to be somewhere to +work), nor can one with jobs still queued. + +A workspace is a **namespace, not a security boundary**. The API token is +all-or-nothing: anything that can reach the server can name any workspace on +it. Use them to keep work apart, not to keep it private. + +## Where this is going + +Workspaces were the first stage of the design in +[proposals/workspaces.md](proposals/workspaces.md). The resolver, the workflow +search path with writes confined to the writable root, run directories with an +on-disk manifest, `asset:` and `output:` references, and server-side named +workspaces are all implemented. What remains from the proposal is an MCP +client that keeps its workspace on its own machine and mirrors it to the +server β€” see +[proposals/server-workspaces.md](proposals/server-workspaces.md) for why +mirroring is not currently planned. diff --git a/docs/img/claude-authoring.png b/docs/img/claude-authoring.png new file mode 100644 index 00000000..401b5463 Binary files /dev/null and b/docs/img/claude-authoring.png differ diff --git a/docs/img/claude-generating.png b/docs/img/claude-generating.png new file mode 100644 index 00000000..781bccb7 Binary files /dev/null and b/docs/img/claude-generating.png differ diff --git a/docs/img/ui-server-dark.png b/docs/img/ui-server-dark.png new file mode 100644 index 00000000..09deabbe Binary files /dev/null and b/docs/img/ui-server-dark.png differ diff --git a/docs/proposals/server-workspaces.md b/docs/proposals/server-workspaces.md new file mode 100644 index 00000000..4bbc9898 --- /dev/null +++ b/docs/proposals/server-workspaces.md @@ -0,0 +1,279 @@ +# Proposal: workspaces on the server, and client workspaces that mirror into them + +Status: draft / scoping only β€” no implementation. +Follows [workspaces.md](workspaces.md), whose stages one through five and +stage six level one are implemented. Replaces the first draft of level two, +which made a client-side workspace the system of record and submitted runs as +anonymous inline definitions. + +## What changed since that draft + +The first draft asked the server to run work it had no record of. An agent +would author into a laptop workspace, and at submit time its assets would be +pushed and its definition rewritten and sent inline. Two problems with that, +one practical and one conceptual: + +- **The web UI cannot see any of it.** The gallery, editor and prompts pages + read the server's directories. Work authored through MCP would exist only in + the agent's transcript and in a job's recorded definition β€” invisible to the + browser the same user has open on the same machine. For a single user + working through both surfaces, that splits their material in two. +- **Rewriting at submit time is a translation layer** β€” inlining prompts, + rewriting asset references β€” that exists only because the server has nowhere + to put a workflow that is not its own. + +Giving the server named workspaces removes both. The agent's work lands +somewhere real, the UI can browse it, and a run is submitted by name like any +other rather than as a rewritten copy. + +## The scale this is designed for + +One user, one server, several MCP clients. That is the shape of the +deployment, and it deletes a category of complexity before it is written: no +per-workspace permissions, no quotas, no per-workspace tokens. A workspace is +a namespace for keeping work separate, not for keeping it private - the API +token is all-or-nothing and everything behind it is reachable by anyone who +holds it. Anything below that is justified only by multi-user use should be +left out until there is a multi-user user. + +## What workspaces are for + +Two things, and it is worth being exact because it decides what is worth +building next: + +1. **Namespace separation** - the repository's example workflows and prompts + are one thing, day-to-day creative work is another, and several MCP + clients working against one server should not share a namespace. +2. **Reuse on the server** - a stored prompt, or an input asset, used by more + than one project should exist once and be referable from all of them. + +Source control of creative work is *not* on this list. It is handled on the +client, by the user, with the tools they already use. That matters because it +is what makes stages four and five - a client workspace mirroring into a +server one, and pulling runs back - unnecessary rather than merely +speculative: see "Mirroring", below, which is kept as a record of a design +that was scoped and then deliberately not built. + +## The ownership model + +One owner per class of artifact, not per workspace: + +| Artifact | Canonical | Flows | +|---|---|---| +| Workflows, assets | The client, when one is mirroring | client β†’ server | +| Stored prompts | The server - one library, shared by every workspace | pushed with `save_prompt`, as today | +| Outputs, run manifests, job history | The server | server β†’ client, on request | + +Prompts are shared on purpose. `dw/prompts.py` already describes the library +as "shared by reference rather than copied into every workflow that uses it", +and `prompt:scenic` resolving to different text in different workspaces would +break exactly that. One library also means a mirroring client has nothing to +mirror for prompts: it writes them with the existing `save_prompt`, which is +already how a prompt reaches the server. + +Nothing is canonical in two places, so there is no merge to get wrong. A +client workspace stays a plain directory under the user's control β€” in their +project, under version control, backed up with everything else β€” and the +server holds a materialized copy plus everything the GPU produced. + +A user with no client workspace at all (the web UI alone, or `dw-mcp` on the +same box) is unaffected: their server workspace is owned by the server, and +is writable from every surface exactly as today. + +### The one real conflict, and how it is settled + +A mirrored workspace is **read-only on the server**. If the UI could edit it, +the next mirror would silently clobber that edit. + +Stage four already has the vocabulary: `GET /api/workflows` tags every entry +with `origin` and `writable`, the UI hides delete and marks read-only sources, +and a save resolves to the writable root instead of overwriting. A mirrored +workspace reports `origin: "mirror"`, `writable: false`, and "save" from the +UI writes a copy into a server-owned workspace β€” the same gesture that already +works for an example. No new concept, and the UI change is a listing that +already carries the flag. + +## Workspaces on the server + +``` +~/diffusers-workspace/ the root - what --workspace already names + prompts/ the one shared prompt library + workflows/ assets/ outputs/ the default workspace + studio/ a named workspace, server-owned + workflows/ assets/ outputs/ + laptop-mirror/ mirrored from a client, read-only here + workflows/ assets/ outputs/ +``` + +The existing workspace directory becomes the root, and its own +`workflows/ assets/ outputs/` stay the default workspace, so **there is no +migration**: an install that has one workspace today has a root with a default +workspace in it tomorrow, and `--workspace ~/studio` keeps meaning what it +means. Shared prompts fall out of the layout rather than needing a mechanism. + +The price is asymmetry - the default workspace's outputs are `/outputs` +where a named one's are `//outputs` - and a rule: `workflows`, +`prompts`, `assets` and `outputs` are reserved and cannot name a workspace. +The listing endpoint reports each workspace's real directories, so a client +never has to derive them. + +Two alternatives were weighed. Sibling directories under a new +`~/diffusers-workspaces/` root are perfectly symmetric, but need a migration +and put `workspace` and `workspaces` one letter apart in every flag, variable +and document. A registry in `settings.json` pointing anywhere on disk needs no +migration and would let a workspace live on another volume, at the cost of a +state file that can disagree with the disk and a delete that means two things. +The registry is the better answer if per-workspace volumes ever matter, and +this layout does not block it: a workspace could later carry an explicit path. + +One server process, workspaces as a dimension of a request β€” not one process +per workspace. A second server process would duplicate the model cache in host +RAM, which on this hardware is the expensive resource, and buy no concurrency: +jobs serialize on the one GPU anyway. + +### Routes + +- `GET /api/workspaces` β€” name, owner (`server` / `mirror`), writable, sizes, + which is the default +- `POST /api/workspaces` β€” create one +- `DELETE /api/workspaces/{name}` β€” remove it. Deletes generated work, so it + needs the gallery's bulk-delete care: a count and a confirmation, never a + quiet success +- Every existing route that touches workflows, assets or outputs gains an + optional workspace selector, defaulting to the server's configured default, + so nothing that works today changes shape. The prompt routes do not: there + is one library + +### The plumbing this needs + +Two of the four roots already travel with a job; two do not: + +| Root | Today | +|---|---| +| `output_dir` | already per-job on the worker protocol (`dw/worker.py`, `dw/server/jobs.py`) | +| `workflow_dir` | already per-job β€” stage four made confinement travel with the job | +| `prompt_dir`, `asset_dir` | process-wide, pinned into the environment at startup (`dw/serve.py`) and inherited by the spawned worker | + +So the work is moving the prompt and asset roots from environment pinning to +per-job values. Stage five set the precedent: `activate_output_root` is a +contextvar the run activates and `resolve_output_reference` reads, and +`get_prompt_dir`/`get_asset_dir` would take the same shape β€” the environment +variable staying as the fallback for the CLI and REPL, which have one +workspace per process and always will. + +What stays process-wide and should: the model cache (keyed by what a pipeline +loads, so two workspaces naming the same model share it β€” which is the whole +point of the persistent worker) and the step cache (keyed by workflow id, step +and output root, so entries from different workspaces cannot collide). + +`jobs.sqlite` needs a workspace column, or history stops making sense the +moment there are two. + +## Mirroring + +**Status: designed, not planned.** Kept because the reasoning is worth +having on record, not because it is queued. + +Mirroring is a distribution mechanism, not a version-control one. Where the +working copy and the server share a filesystem - one box, shell access - git +in the workspace directory already gives history, diffs and durability with +no new code, and `git push`/`git pull` is a better mirror than this design +would be: real three-way merges rather than "the client wins and the server +is read-only". Its only genuine niche is an agent that can reach the API but +cannot run commands on the box, which is a hosted or shared GPU, not the +deployment this targets. The cost it avoids is real: named upload +destinations, digest change detection, prune semantics, refusing workflow +shapes that cannot be synced, and a standing "which side is right" question +every time someone edits in the UI. + +### Direction and trigger + +One way, client β†’ server, for authored content only. Two triggers: + +- **On save.** `save_workflow` writes locally and pushes. The common case + costs nothing extra and keeps the UI in step with the agent. `save_prompt` + is unchanged: it writes the shared library directly, as it does today. +- **`sync_workspace()` explicitly**, for reconciliation β€” first use, a + workspace edited outside the agent, or after working offline. + +### What crosses, and how change is detected + +Workflows are small JSON: push the ones whose content differs. Assets are +not, so the mirror compares digests and pushes only what changed. + +**This supersedes the previous draft's content-addressed upload naming.** A +mirror has to preserve names: `asset:gyre/frames/iris.png` must mean the same +file on both sides, or every reference in every mirrored workflow needs +rewriting β€” the translation layer this design exists to remove. So the upload +route grows a destination name (validated by the existing asset-name validator +and confined to the library, like every other client-supplied path), and the +digest is used for *change detection*, not for naming. + +The browser's file picker keeps generated names; it has no name worth +preserving and no mirror to keep consistent. + +### What the mirror does not do + +- **It does not delete.** A file gone locally stays on the server unless the + user asks (`sync_workspace(prune=True)`). Deleting generated work as a side + effect of a sync is not a thing to do by default. +- **It does not pull.** The server never writes back into a client workspace; + that is what makes the ownership model hold. + +### Running a mirrored workflow + +By name, in the mirrored workspace, like any other run. No inline rewriting, +no prompt inlining, no `base_dir` question β€” because the mirror already put +the workflow, its prompts and its assets on the server under the names its +references use. This is the simplification that server-side workspaces buy, +and it is most of why the design changed. + +## Results + +The server is canonical for what it generated. `fetch_run(job_id)` copies a +run's files and its manifest into the client workspace's +`outputs///`, and that copy is a cache: deleting it loses +nothing, and `output:` references resolve on whichever side is running. + +## What this does not do + +- It does not make `dw_mcp` run workflows. One engine, on the machine with the + GPU. +- It does not make a workspace a security boundary - see the scale section + above. This must be stated plainly in the docs, or someone will assume + otherwise. +- It does not change the trust model: a mirrored workflow is a stored workflow + like any other, and `--trust-workflows` governs it the same way. + +## Staging + +Stages one through three are implemented. Four and five are not planned - +see the note above. + +1. **Workspaces on the server, one at a time.** The named subdirectories, the + CRUD routes, the per-job asset root, the workspace column in history. The + default workspace behaves exactly as the single workspace does today, and + nothing on disk moves. +2. **The UI catches up.** A workspace switcher; the gallery and workflows + pages scoped to the selection. The Prompts page is not scoped - there is + one library, and that is the point. +3. **MCP selects a workspace** β€” per call, defaulting to one per session. This + alone covers the remote-agent case without any client workspace at all: + an agent gets its own namespace on the box and the user can see it. +4. **Client workspaces mirror.** `dw-mcp --workspace`, push on save, + `sync_workspace`, the named upload destination, read-only marking of a + mirrored workspace. +5. **`fetch_run`.** Optional, and last. + +Stage 3 is the natural stopping point if mirroring turns out not to be worth +it β€” it is useful on its own, which is a good property for the stage before +the speculative one. + +## Open questions + +- **How does a client name its mirror?** Explicitly (`--mirror-as + laptop-don`) is predictable; derived from the hostname is convenient and + collides the first time someone has two checkouts. +- **Should the UI offer "copy this into my workspace"** as a first-class + gesture between workspaces, the way stage four's save-a-copy works between + sources? It is the same operation one level up. diff --git a/docs/proposals/workspaces.md b/docs/proposals/workspaces.md new file mode 100644 index 00000000..a7d2b58a --- /dev/null +++ b/docs/proposals/workspaces.md @@ -0,0 +1,255 @@ +# Proposal: workspaces β€” decoupling content from the repo (and from the server) + +Status: draft / design only β€” no implementation. + +## Problem + +The project grew CLI-first, where "outputs next to the code" was correct and +free. A workflow file, the assets it reads, the prompts it references and the +files it writes all resolved relative to the checkout, and that was the whole +story. The web UI inherited that model and mostly gets away with it β€” it is +single-user and usually started from the repo root. The MCP server is where it +stops working: an agent authoring a workflow writes it into the repo's example +corpus, cannot supply input bytes at all, and has no notion of "my stuff" +versus "the examples that ship with the project". + +The examples use case is still valid β€” `workflows/` as a runnable corpus is one +of the better things about the project. What is missing is a second place for +everything that is *not* an example. + +## Current behavior + +Four roots, four different resolution rules, three of them anchored to the +process working directory: + +| Content | Where it resolves | How | +|---|---|---| +| Workflow files | CLI: any path given on the command line. Server: `--workflow-dir`, default `./workflows`. Packaged: `builtin:name.json` β†’ `dw/workflows/` | `workflow_from_file` / `resolve_workflow_reference` (`dw/server/app.py:192`) | +| Input assets | The **workflow file's own directory** | `resolve_relative_path(path, base_dir)` (`dw/arguments.py:769`); `base_dir` is `dirname(file_spec)` | +| Stored prompts | `DW_PROMPT_DIR` β†’ `./prompts` β†’ nearest `prompts/` walking up from the workflow file | `get_prompt_dir` (`dw/prompts.py:30`) | +| Generated output | `-o/--output-dir`, default `./outputs`, plus a subfolder mirroring the workflow's position under the nearest directory literally named `workflows` | `workflow_output_subfolder` (`dw/workflow.py:104`), `Workflow.effective_output_dir` | +| Settings, job history, logs | `DIFFUSERS_HELPER_ROOT` β†’ `~/.diffusers_helper/` | `dw/settings.py` | + +Only the last row is already decoupled. Only prompts has an environment +variable and a discovery walk; there is no `DW_WORKFLOW_DIR` or +`DW_OUTPUT_DIR`, so "start it from the repo root" is load-bearing for +everything else. + +### What that costs today + +- **Output layout is coupled to repo layout.** `workflow_output_subfolder` + keys the output subfolder off a path segment literally named `workflows`. + Move a workflow out of that tree and its outputs silently flatten into the + output root. The *filesystem shape of the checkout* is the grouping key. +- **Assets must live beside the workflow.** Because `base_dir` is the workflow + file's directory, any workflow with inputs drags a sibling `assets/` folder + into the repo β€” hence `.gitignore`'s `workflows/*/assets/`, `local_inputs/`, + `/*.mp4`, `/*.wav`. Generated media is being gitignored *inside the source + tree* rather than kept out of it. +- **The outputβ†’input loop is a manual copy.** The GYRE workflows reference + `assets/Gyre-score_choir.10-0.0.wav` and `assets/Gyre-still_1_iris.0-0.0.jpg` + β€” files a previous run wrote to `outputs/` and a human copied back into the + repo. Multi-stage work (the marmot passes, `outputs/marmot_pass1..3`) is + exactly the day-to-day case, and the engine has no vocabulary for "the thing + the last stage made". +- **Inputs and outputs share a directory on the server.** `POST /api/uploads` + writes to `/uploads` (`dw/server/app.py:1233`) because the output + dir is the only writable root the server knows about. +- **History and output can disagree.** `jobs.sqlite` lives under + `~/.diffusers_helper/` and stores manifest paths *relative to* whatever + `--output-dir` the server was started with (`dw/server/jobs.py:594`). Start + it from a different cwd and old rows point at nothing. A CLI run records no + manifest at all. +- **MCP writes into git.** `save_workflow` β†’ `PUT /api/workflows/{name}` β†’ + the server's `workflow_dir`, which defaults to the repo's example corpus. + There is no writable/read-only distinction, and no upload tool at all + (`dw_mcp/CLAUDE.md` notes `POST /api/uploads` is deliberately uncovered), so + an agent can author a workflow it has no way to supply inputs for. +- **`workflow_dir` is doing two jobs.** It is both "where workflows are kept" + and the server's *confinement boundary* for `workflow_path`, inline + `base_dir`, and sub-workflow steps. Any change to the first has to preserve + the second. + +### What is already right + +The mechanism is mostly there; the defaults and the discovery rules are what +bind it to the repo. + +- `--workflow-dir` / `--output-dir` / `--prompt-dir` already exist as explicit + parameters. Nothing in the engine *requires* the repo. +- `builtin:` is already the precedent for "packaged, read-only, reachable by a + scheme rather than a path". +- `prompt:name` is already a library reference rooted at a library rather than + at the workflow file β€” the exact shape assets need, including its validator + (`validate_prompt_reference`) and its no-double-resolution rule. +- `DIFFUSERS_HELPER_ROOT` already establishes a user-data root outside the + checkout. +- Path validation is centralized in `dw/security.py`, so a new root is one + containment base, not a new class of check. + +## Proposal + +### 1. Name the container: a workspace + +One directory holding everything a user generates or curates: + +``` +/ + workflows/ # mine, writable + prompts/ # the existing prompt library + assets/ # input media, incl. uploads/ + outputs/ # runs +``` + +A single resolver (`dw/workspace.py`) replaces three ad-hoc rules: + +1. `--workspace` flag +2. `DW_WORKSPACE` +3. `workspace` in `~/.diffusers_helper/settings.json` +4. the working directory, *if it looks like a workspace* β€” contains any of + `workflows/`, `prompts/`, `outputs/` +5. `~/diffusers-workflow/`, created on demand + +Rule 4 is what keeps this backward compatible: a repo checkout looks like a +workspace, so running from the repo root behaves exactly as it does now. Only +a bare working directory falls through to the home workspace. The existing +per-slot flags stay and continue to override individual roots, so no current +invocation changes meaning. `get_prompt_dir`'s walk-up becomes a deprecated +fallback rather than the primary rule. + +### 2. A workflow search path, writes to the front + +Rather than one `workflow_dir`, an ordered list: + +``` +workspace/workflows/ (writable) +/workflows/ (read-only, if present) +dw/workflows/ (read-only, packaged β€” today's builtin:) +``` + +Reads resolve front-to-back; **writes only ever go to the first entry**. That +alone fixes the MCP-writes-into-git problem while keeping the example corpus +first-class and browsable. `GET /api/workflows` gains `origin` and `writable` +per entry; the UI and MCP grey out save/delete on non-writable ones, and +"save as" from an example into the workspace becomes the natural gesture. +`examples:Foo.json` joins `builtin:` as an explicit scheme. + +Confinement generalizes cleanly: the boundary becomes *the workspace root plus +the read-only roots* instead of a single `workflow_dir`. Server-side runs stay +confined; CLI runs of an arbitrary file stay unconfined, as today +(`workflow_from_file`'s docstring already states that a locally-run file is not +a trust boundary). + +### 3. Run directories, and output layout from workflow identity + +Replace the `workflow_output_subfolder` path-segment hack with the workflow's +own identity β€” its name, or its path relative to the search-path root it came +from β€” and give each run its own directory: + +``` +outputs/// + + manifest.json # spec, arguments, seeds, resolved model ids, saved files +``` + +This is the change that makes multi-stage work tractable: intermediates, final +outputs and provenance are colocated, prunable as a unit, and addressable. +`manifest.json` also means a CLI run leaves a record, and `jobs.sqlite` demotes +from sole record to index β€” history becomes rebuildable from disk. + +Costs to plan for: `dw/step_cache.py` compares `entry["output_dir"]`; the +gallery scans `output_dir` recursively and would want to group by run; the UI +builds `/outputs/` URLs from paths relative to `output_dir`. A flat mode +should stay available for CLI users who prefer today's layout. + +### 4. First-class assets, and references that close the loop + +Two new reference prefixes, built on the `prompt:` machinery rather than beside +it: + +- `asset:name` / `asset:folder/name` β€” rooted at `workspace/assets/`, not at + the workflow file. A workflow stops needing to live next to its inputs, which + is what currently drags media into the source tree. +- `output:/` (or a `latest` selector per workflow) β€” reference a + previous run's product directly. This removes the manual copy-back that the + GYRE and marmot workflows document. + +Both resolve in `realize_args` where `prompt:` does, reuse +`validate_prompt_reference`-style validation, and inherit the existing rule +that a resolved value may not itself begin with a reference prefix. + +`POST /api/uploads` moves to `workspace/assets/uploads/`. Inputs and outputs +stop sharing a directory, and β€” because the destination is no longer "the +output dir" β€” an MCP `upload_asset` tool becomes reasonable to add. + +### 5. Decoupling MCP from the server + +Two levels, worth doing in order: + +**Level 1 β€” workspace-aware client.** `get_server_info` reports the workspace, +its roots and the writable flags. `save_workflow` targets the workspace, never +an example root. Add `upload_asset` so an agent can supply input bytes. MCP +stays a pure HTTP client of a running `dw.serve`; the boundary +`dw_mcp/CLAUDE.md` describes (no `dw.*` imports, no torch) is untouched. + +**Level 2 β€” client-side workspace.** `dw-mcp --workspace ` keeps +workflows, prompts and assets *on the agent's machine*. Authoring, schema +validation and library management are local; only runs go to the remote +`dw.serve`, submitted inline (`POST /api/jobs` with `workflow` + `base_dir`, +already supported) with assets pushed content-addressed β€” hash-named, skipped +if the server already has them. This is the literal answer to "not everything +should be on the server": the server becomes a GPU, and the workspace is the +user's. + +Level 2 has one real snag worth deciding early: inline submission's `base_dir` +is confined to the server's workflow root, so a locally-authored workflow's +asset references have to be rewritten to server-side asset ids at submit time. +That is tractable but it is the design work, not a detail. + +Trust interacts here too: `--trust-workflows` is process-wide today. Per-root +trust ("my workspace is trusted, the shared example corpus and anything an +MCP client submits is not") is a better-shaped knob once roots are named +things, and it is the knob that lets a server usefully run untrusted while an +owner's own workflows still use `pre_load_modules`. + +### 6. Multiple workspaces + +`.gitignore` already carries `local_projects/`, so this is happening +informally. `dw workspace new|list|use`, `--workspace` on every entry point, a +switcher in the UI later. One active workspace per process β€” no ambiguity about +which root a bare name resolved against. + +## Staging + +Each stage is independently shippable and the first is behavior-neutral. + +1. **`dw/workspace.py` + `--workspace`/`DW_WORKSPACE`.** One resolver, all + entry points consume it, defaults unchanged in a repo checkout. Nothing + moves yet. +2. **Assets root and `asset:` references.** Move uploads out of the output + directory. Workflows can stop hoarding sibling `assets/` folders. +3. **Run directories and on-disk `manifest.json`.** Output layout derives from + workflow identity instead of file position. Touches step cache, gallery + grouping and the UI's output URLs β€” the biggest single stage. +4. **Workflow search path, writable-first, `examples:`.** `origin`/`writable` + through the API into the UI and MCP. This is the stage that stops agents + writing into git. +5. **`output:` references.** Closes the multi-stage loop. +6. **MCP level 1** (done), **then level 2** - scoped separately in + [server-workspaces.md](server-workspaces.md), which reframes it: named + workspaces on the server, with a client workspace mirroring into one, + rather than a client-side system of record submitting inline. Level 1 + had already removed the `base_dir` confinement problem this document + called level 2's real design work. + +## Open questions + +- Does the repo's top-level `workflows/` stay as an example root on the search + path, or migrate wholesale into the package next to `dw/workflows/` so a + pip install has the same corpus a checkout does? The second is cleaner and is + a bigger move. +- Run id: job id for server runs, but CLI runs have none. Timestamp plus a + short hash of the resolved spec would serve both and dedupes reruns. +- Should `outputs/` be inside the workspace at all, or a peer with its own + setting? Video work fills disks, and the outputs root is the one people most + plausibly want on a different volume. diff --git a/dw/arguments.py b/dw/arguments.py index 284f1f05..e27dddcb 100644 --- a/dw/arguments.py +++ b/dw/arguments.py @@ -4,6 +4,8 @@ from inspect import Parameter, signature from .type_helpers import load_type_from_name, load_constant_from_name, has_method from .prompts import PROMPT_PREFIX, fetch_prompt +from .assets import fetch_asset, is_asset_reference +from .runs import fetch_output, is_output_reference from diffusers.utils import load_image, load_video from .security import ( validate_path, @@ -89,6 +91,12 @@ def realize_args(arg, base_dir=None): if isinstance(arg, dict): logger.debug(f"Processing dictionary arguments: {list(arg.keys())}") for k, v in arg.items(): + # An asset reference resolves to the path of a file in the asset + # library, and does it first: what it stands for is a path, so + # everything below - the media conventions, an object's + # 'from_file' - then handles it as the path it always was + if is_path_reference(v) or isinstance(v, list): + v = arg[k] = resolve_path_references(v, base_dir) # A constant resolves under any argument name, and before the # conventions below - what it holds is the value, not a file to load if is_constant_reference(v): @@ -143,6 +151,8 @@ def realize_args(arg, base_dir=None): elif isinstance(arg, list): logger.debug("Processing list arguments") for i, item in enumerate(arg): + if is_path_reference(item): + item = arg[i] = resolve_path_references(item, base_dir) if is_constant_reference(item): arg[i] = fetch_constant(item) continue @@ -156,6 +166,31 @@ def realize_args(arg, base_dir=None): arg[i] = realize_object(item, base_dir) +def is_path_reference(value): + """Whether a value is a reference that stands for a file's path - a + stored asset, or something an earlier run wrote.""" + return is_asset_reference(value) or is_output_reference(value) + + +def resolve_path_references(value, base_dir=None): + """Replace any reference that stands for a path with the path itself. + + A list is walked in place, because an 'image' argument may be a list of + references and the key conventions hand the whole list to the loader at + once - by then it is too late for a reference to be recognized. + Dictionaries are left alone: realize_args recurses into those itself, + and each of their values reaches this on the way through. + """ + if is_asset_reference(value): + return fetch_asset(value, base_dir=base_dir) + if is_output_reference(value): + return fetch_output(value) + if isinstance(value, list): + for index, item in enumerate(value): + value[index] = resolve_path_references(item, base_dir) + return value + + def is_constant_reference(value): """Whether a value references a constant declared in python.""" return isinstance(value, str) and value.startswith(CONSTANT_PREFIX) diff --git a/dw/assets.py b/dw/assets.py new file mode 100644 index 00000000..2821e7f6 --- /dev/null +++ b/dw/assets.py @@ -0,0 +1,111 @@ +"""The asset library: input media a workflow references by name. + +A workflow's media paths resolve against the workflow file's own directory, +which means a workflow that reads anything has to keep that thing beside it - +the reason generated media ends up gitignored inside a source tree. An +'asset:name' reference is rooted at the asset library instead, the way +'prompt:name' is rooted at the prompt library, so the same reference means the +same file from every workflow and neither has to live next to the other. + +A reference resolves to a path, not to a value: 'asset:frames/iris.jpg' +becomes the absolute path of that file, and whatever would have loaded a path +written there loads it unchanged. +""" + +import contextvars +import logging +import os + +from .security import validate_asset_reference, validate_path +from .workspace import ASSETS_SUBDIR, discover_library + +logger = logging.getLogger("dw") + +# The prefix marking a value as a reference to a stored asset +ASSET_PREFIX = "asset:" + +# Set by an entry point from --asset-dir, and inherited by a spawned worker, +# the way DW_PROMPT_DIR is +ASSET_DIR_ENV_VAR = "DW_ASSET_DIR" + + +# The asset library of the run in progress. A server holds several +# workspaces and each has its own assets, so this cannot be a process-wide +# environment variable there the way the prompt library can - there is one +# prompt library, shared, but assets belong to a workspace. Set per job by +# the worker; unset for the CLI and REPL, which have one workspace per +# process and read the environment below +_active_asset_dir = contextvars.ContextVar("dw_asset_dir", default=None) + + +def activate_asset_dir(directory): + """Make an asset library the active one; returns a token for deactivate.""" + return _active_asset_dir.set(directory) + + +def deactivate_asset_dir(token): + _active_asset_dir.reset(token) + + +def get_asset_dir(base_dir=None): + """The directory 'asset:' references are rooted at. + + A library activated for this run wins outright - that is the server + telling the worker which workspace's assets this job uses. Otherwise + discovery mirrors the prompt library's - see workspace.discover_library + for the shared precedence (DW_ASSET_DIR, then a named workspace, then + ./assets, then a walk up from base_dir, then the workspace's assets/ as + the fallback). + + Args: + base_dir: The workflow file's directory, when one anchors the search + """ + active = _active_asset_dir.get() + if active: + return active + + return discover_library(ASSETS_SUBDIR, ASSET_DIR_ENV_VAR, base_dir) + + +def is_asset_reference(value): + """Whether a value references a file in the asset library.""" + return isinstance(value, str) and value.startswith(ASSET_PREFIX) + + +def resolve_asset_reference(reference, asset_dir=None, base_dir=None): + """Resolve an 'asset:' reference to the file it names. + + Args: + reference: The 'asset:name.ext' or 'asset:folder/name.ext' string + asset_dir: Directory the name is rooted at; defaults to get_asset_dir() + base_dir: The workflow file's directory, anchoring discovery when no + asset directory is configured + + Returns: + The validated absolute path of the asset file + + Raises: + InvalidInputError: If the name is not a valid asset name + PathTraversalError: If the name escapes the asset directory + ValueError: If no file exists under that name + """ + name = validate_asset_reference(reference.removeprefix(ASSET_PREFIX).strip()) + asset_dir = asset_dir or get_asset_dir(base_dir) + # Confined to the library: the name is joined onto a directory, so the + # containment check is what makes a name a name rather than a path + # allow_create leaves "does not exist" to the check below, which can say + # what an asset reference is instead of what a path is + path = validate_path(os.path.join(asset_dir, name), asset_dir) + if not os.path.isfile(path): + raise ValueError( + f"Asset '{name}' not found in {asset_dir} - an 'asset:' reference " + f"names a file in the asset library, with its extension, like " + f"'asset:iris.jpg' or 'asset:gyre/frame_1.jpg'" + ) + logger.debug(f"Resolved {reference} to {path}") + return path + + +def fetch_asset(reference, asset_dir=None, base_dir=None): + """The path an 'asset:' reference names, for whatever loads paths.""" + return resolve_asset_reference(reference, asset_dir, base_dir) diff --git a/dw/prompts.py b/dw/prompts.py index 6e892e97..59811741 100644 --- a/dw/prompts.py +++ b/dw/prompts.py @@ -13,6 +13,7 @@ from .schema import load_schema, validate_data from .security import validate_prompt_path, validate_prompt_reference +from .workspace import PROMPTS_SUBDIR, discover_library logger = logging.getLogger("dw") @@ -24,40 +25,32 @@ # The prefixes a stored prompt's text may not begin with. Resolved text is # substituted where the reference stood, so text that itself looks like a # reference would be resolved again - or worse, expand a step's iterations -RESERVED_TEXT_PREFIXES = ("previous_result:", "variable:", "constant:", PROMPT_PREFIX) +RESERVED_TEXT_PREFIXES = ( + "previous_result:", + "variable:", + "constant:", + "asset:", + "output:", + PROMPT_PREFIX, +) def get_prompt_dir(base_dir=None): """The directory stored prompts are rooted at. DW_PROMPT_DIR names it explicitly - the server sets it from --prompt-dir, - and the spawned worker inherits it. Without one it is ./prompts in the - working directory when that exists; otherwise the walk from the workflow - file's directory up toward the filesystem root finds the prompts/ folder - of the tree the workflow lives in - which is how a repo workflow run from - any working directory still reaches the library beside it. Read at call - time, not import time, so a test or worker sees the current value. + and the spawned worker inherits it. Below that, see + workspace.discover_library for the shared precedence (a named workspace, + then ./prompts, then a walk up from base_dir, then the workspace's + prompts/ as the fallback). + + Read at call time, not import time, so a test or worker sees the current + value. Args: base_dir: The workflow file's directory, when one anchors the search """ - explicit = os.environ.get("DW_PROMPT_DIR") - if explicit: - return explicit - default = os.path.abspath("./prompts") - if os.path.isdir(default): - return default - if base_dir: - current = os.path.abspath(base_dir) - while True: - candidate = os.path.join(current, "prompts") - if os.path.isdir(candidate): - return candidate - parent = os.path.dirname(current) - if parent == current: - break - current = parent - return default + return discover_library(PROMPTS_SUBDIR, "DW_PROMPT_DIR", base_dir) def resolve_prompt_reference(reference, prompt_dir=None, base_dir=None): diff --git a/dw/repl.py b/dw/repl.py index cd4f0f16..868dad4e 100644 --- a/dw/repl.py +++ b/dw/repl.py @@ -14,6 +14,7 @@ import multiprocessing from . import startup from .repl_worker import WorkerManager +from .workspace import resolve_workspace, set_workspace from .repl_commands import ( ConfigCommands, ArgCommands, @@ -46,11 +47,15 @@ class DiffusersWorkflowREPL(cmd.Cmd): def __init__(self): # Initialize cmd.Cmd first, before setting up our globals cmd.Cmd.__init__(self) - # Initialize globals dictionary with default values + # Initialize globals dictionary from the workspace - in a checkout, + # whose root is a workspace, these are the ./outputs and ./workflows + # they have always been + workspace = set_workspace(resolve_workspace()) self.globals = { - "output_dir": "./outputs", # Default output directory + "workspace": workspace.root, # Where the two directories below live + "output_dir": workspace.outputs, # Default output directory "log_level": "INFO", # Default log level - "workflow_dir": "./workflows", # Default workflow directory + "workflow_dir": workspace.workflows, # Default workflow directory } self.current_workflow = None self.workflow_args = {} # Store workflow arguments @@ -182,7 +187,7 @@ def do_set(self, arg): ARG_SUBCOMMANDS = ("show", "set", "clear") MEMORY_SUBCOMMANDS = ("show", "clear") CONFIG_SUBCOMMANDS = ("show", "set") - CONFIG_KEYS = ("output_dir", "log_level", "workflow_dir") + CONFIG_KEYS = ("workspace", "output_dir", "log_level", "workflow_dir") @staticmethod def _matches(candidates, text): diff --git a/dw/repl_commands.py b/dw/repl_commands.py index d985f67f..48b5c5b1 100644 --- a/dw/repl_commands.py +++ b/dw/repl_commands.py @@ -42,12 +42,17 @@ def do_config(self, arg: str): print(" config show - Show all configuration settings") print(" config set = - Set a configuration value") print("\nAvailable settings:") - print(" output_dir - Directory for output files (default: ./outputs)") + print(" workspace - Directory holding your workflows, prompts,") + print(" assets and outputs; setting it moves") + print(" workflow_dir and output_dir with it") + print(" output_dir - Directory for output files (default: the") + print(" workspace's outputs/)") print( " log_level - Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL" ) print(" workflow_dir - Where 'workflow load ' and 'workflow list'") - print(" look for workflows (default: ./workflows)") + print(" look for workflows (default: the workspace's") + print(" workflows/)") print() print("These apply to this REPL session. Standing settings (device,") print("log file, TF32) live in ~/.diffusers_helper/settings.json") @@ -116,6 +121,33 @@ def _config_set(self, arg: str): except SecurityError as e: print(f"Error: Invalid workflow directory: {e}") return + + # Setting the workspace moves the two directories under it with + # it - setting either of those alone still overrides just that one + elif name == "workspace": + from .workspace import FLAG, Workspace, set_workspace + + try: + value = validate_path(value, allow_create=False) + if not os.path.isdir(value): + print(f"Warning: Directory '{value}' does not exist") + return + except SecurityError as e: + print(f"Error: Invalid workspace: {e}") + return + workspace = set_workspace(Workspace(value, FLAG)) + self.repl.globals["workflow_dir"] = workspace.workflows + self.repl.globals["output_dir"] = workspace.outputs + print(f" workflow_dir={workspace.workflows}") + print(f" output_dir={workspace.outputs}") + # Warn if a worker is already running - it keeps using the + # old prompt and asset libraries until restart + worker_manager = getattr(self.repl, "worker_manager", None) + if worker_manager and getattr(worker_manager, "worker_active", False): + print( + "Note: the running worker keeps the prompt and asset " + "libraries it started with until 'workflow restart'." + ) else: print(f"Warning: Unknown setting '{name}'") diff --git a/dw/run.py b/dw/run.py index 93a95eae..33788799 100644 --- a/dw/run.py +++ b/dw/run.py @@ -2,6 +2,8 @@ import os from . import startup from .workflow import workflow_from_file +from .runs import set_output_layout +from .workspace import resolve_workspace, set_workspace from .security import ( validate_workflow_path, validate_output_path, @@ -22,8 +24,18 @@ def main(): "-o", "--output_dir", type=str, - default="./outputs", - help="The folder to write the outputs to", + default=None, + help="The folder to write the outputs to (default: the workspace's " + "outputs/ - ./outputs when run from a workspace, as a checkout is)", + ) + parser.add_argument( + "--workspace", + type=str, + default=None, + help="Directory holding your workflows, prompts, assets and outputs " + "(default: DW_WORKSPACE, else the 'workspace' setting, else the " + "working directory when it looks like a workspace, else " + "~/diffusers-workspace)", ) parser.add_argument( "variables", @@ -45,6 +57,24 @@ def main(): "DW_PROMPT_DIR, else ./prompts if it exists, else the nearest " "prompts/ above the workflow file)", ) + parser.add_argument( + "--asset-dir", + type=str, + default=None, + help="Directory 'asset:' references resolve against (default: " + "DW_ASSET_DIR, else the workspace's assets/ when a workspace was " + "named, else ./assets if it exists, else the nearest assets/ above " + "the workflow file)", + ) + parser.add_argument( + "--output-layout", + type=str, + choices=("run", "flat"), + default=None, + help="'run' (default) gives each run its own directory under " + "//, with a manifest.json beside its files; " + "'flat' writes the way it did before run directories", + ) parser.add_argument( "--trust-workflows", action="store_true", @@ -62,9 +92,20 @@ def main(): # and the name=value pairs - fails as unrecognized arguments args = parser.parse_intermixed_args() + # Pinned before anything resolves a directory from it, and exported so a + # subprocess sees the same answer + workspace = set_workspace(resolve_workspace(args.workspace)) + output_dir = args.output_dir or workspace.outputs + if args.prompt_dir: os.environ["DW_PROMPT_DIR"] = os.path.abspath(args.prompt_dir) + if args.asset_dir: + os.environ["DW_ASSET_DIR"] = os.path.abspath(args.asset_dir) + + if args.output_layout: + set_output_layout(args.output_layout) + set_trust_workflows(args.trust_workflows) # Parse key-value pairs with validation @@ -87,7 +128,7 @@ def main(): # Validate and secure file paths try: - validated_output_dir = validate_output_path(args.output_dir, None) + validated_output_dir = validate_output_path(output_dir, None) if not os.path.exists(validated_output_dir): # Create output directory if it doesn't exist os.makedirs(validated_output_dir, exist_ok=True) diff --git a/dw/runs.py b/dw/runs.py new file mode 100644 index 00000000..d848e676 --- /dev/null +++ b/dw/runs.py @@ -0,0 +1,371 @@ +"""A run: the directory one execution of a workflow writes into, and the +manifest it leaves behind. + +Output used to be laid out by where the workflow file sits - the subfolder +mirrored its position under the nearest directory literally named 'workflows', +so the *shape of a checkout* was the grouping key, and a workflow moved out of +that tree silently flattened. A run directory replaces that with the +workflow's own identity plus one directory per execution: + + /// + + manifest.json + +Everything one execution produced - intermediates, finals, and the record of +what made them - lands in one place, prunable and addressable as a unit, and +a rerun can no longer interleave its files with an earlier one's. + +The old flat-ish layout stays available: DW_OUTPUT_LAYOUT=flat, an +'output_layout' setting of "flat", or --output-layout flat on dw.run and +dw.serve, for a caller whose scripts glob the output directory. +""" + +import contextvars +import hashlib +import json +import logging +import os +import re +from datetime import datetime, timezone + +logger = logging.getLogger("dw") + +RUN_LAYOUT = "run" +FLAT_LAYOUT = "flat" +LAYOUTS = (RUN_LAYOUT, FLAT_LAYOUT) + +# Set by an entry point, and inherited by a spawned worker the way +# DW_PROMPT_DIR and DW_ASSET_DIR are +OUTPUT_LAYOUT_ENV_VAR = "DW_OUTPUT_LAYOUT" + +MANIFEST_FILE_NAME = "manifest.json" + +# The prefix marking a value as a reference to a file an earlier run wrote. +# Like 'asset:', it stands for a path - what a previous run made is an input +# like any other, and multi-stage work is what a workflow engine is for +OUTPUT_PREFIX = "output:" + +# The segment that means "the newest run of this workflow that has the +# file", so a workflow can name the stage before it without being edited +# after every run - see _resolve_segments for why it is not simply the +# newest run directory +LATEST = "latest" + +# What a run id looks like: a UTC timestamp and a short digest of the spec. +# The pattern is not only documentation - the gallery reads it to group a +# workflow's runs under one folder rather than listing every run separately +# The trailing counter appears only when two runs of the same spec start in +# the same second - see run_directory +RUN_ID_PATTERN = re.compile(r"^\d{8}-\d{6}-[0-9a-f]{8}(-\d+)?$") + +# Characters allowed in a path segment derived from a workflow's name or file +_UNSAFE_SEGMENT_CHARACTERS = re.compile(r"[^A-Za-z0-9_.-]+") + +# The synthetic file name workflow_from_definition gives an inline workflow - +# it carries a directory, not an identity +INLINE_FILE_NAME = "__inline__" + + +# The output root of the run in progress, so an 'output:' reference resolves +# against the directory this run was told to write to rather than guessing +# one. Set by Workflow.run; a reference realized outside any run falls back +# to the workspace's outputs +_active_output_root = contextvars.ContextVar("dw_output_root", default=None) + + +def activate_output_root(root): + """Make an output root the active one; returns a token for deactivate.""" + return _active_output_root.set(root) + + +def deactivate_output_root(token): + _active_output_root.reset(token) + + +def output_root(): + """The output directory 'output:' references resolve against.""" + active = _active_output_root.get() + if active: + return active + + from .workspace import resolve_workspace + + return resolve_workspace().outputs + + +def is_output_reference(value): + """Whether a value references a file an earlier run wrote.""" + return isinstance(value, str) and value.startswith(OUTPUT_PREFIX) + + +def _runs_newest_first(directory): + """The run directories inside a workflow's output folder, newest first. + + Run ids start with a UTC timestamp, so sort order is age order - no stat + calls, and no dependence on mtimes that a copy would have rewritten + anyway. Empty when the directory holds no runs, or is not there. + """ + try: + return sorted( + ( + name + for name in os.listdir(directory) + if is_run_id(name) and os.path.isdir(os.path.join(directory, name)) + ), + reverse=True, + ) + except OSError: + return [] + + +def _resolve_segments(directory, parts, reference, root): + """Build the path a name stands for, expanding 'latest' where it names + a run. + + 'latest' means the newest run *that has the file*, not the newest run + directory: a run that failed part way, or one whose every step was a + cache hit, leaves a directory holding only its manifest, and a + second-stage workflow pointed at that would find nothing where the + stage before it plainly produced something. So the runs are tried + newest first and the first one holding the rest of the name wins. + + Only a segment standing where run directories are is a run selector. A + 'latest' segment in a directory that holds no runs is a name like any + other, so a workflow or a file called 'latest' stays reachable. + + Returns the path, or None when runs were found and none of them holds + the file. + """ + if not parts: + return directory + part, rest = parts[0], parts[1:] + if part == LATEST: + runs = _runs_newest_first(directory) + if runs: + for run in runs: + candidate = _resolve_segments( + os.path.join(directory, run), rest, reference, root + ) + if candidate and os.path.isfile(candidate): + return candidate + return None + if not os.path.exists(os.path.join(directory, part)): + raise ValueError( + f"No runs yet under {os.path.relpath(directory, root)} - " + f"'{reference}' names the newest run of a workflow that " + f"has not produced one" + ) + return _resolve_segments(os.path.join(directory, part), rest, reference, root) + + +def resolve_output_reference(reference, root=None): + """Resolve an 'output:' reference to the file it names. + + The name is a path under the output directory - '//' - and the run id may be written as 'latest', which resolves + to the newest run of that workflow that holds the file. That is what + lets a second-stage workflow name the first stage's product without + being edited after every run, and without breaking when the newest run + failed or reused cached files and so wrote none of its own. + + Args: + reference: The 'output:...' string + root: The output directory to resolve against; defaults to the run + in progress, else the workspace's outputs + + Returns: + The validated absolute path of the file + + Raises: + InvalidInputError: If the name is not a valid output name + PathTraversalError: If the name escapes the output directory + ValueError: If no such run or file exists + """ + from .security import validate_output_reference, validate_path + + name = validate_output_reference(reference.removeprefix(OUTPUT_PREFIX).strip()) + root = root or output_root() + + resolved = _resolve_segments(root, name.split("/"), reference, root) + if resolved is None: + raise ValueError( + f"Output '{name}' not found under {root} - no run of that workflow " + f"holds the file. A run that failed, or reused every step from the " + f"cache, leaves only its manifest behind" + ) + # Containment is checked once, on the whole path, after 'latest' has + # been expanded - so what is validated is the real directory it named. + # The segments themselves were pattern-checked, so this guards symlinks + resolved = validate_path(resolved, root) + + if not os.path.isfile(resolved): + raise ValueError( + f"Output '{name}' not found under {root} - an 'output:' reference " + f"names a file an earlier run wrote, like " + f"'output:ltx2/Gyre/latest/Gyre-still.0-0.0.png'" + ) + logger.debug(f"Resolved {reference} to {resolved}") + return resolved + + +def fetch_output(reference, root=None): + """The path an 'output:' reference names, for whatever loads paths.""" + return resolve_output_reference(reference, root) + + +def output_layout(): + """Whether runs get their own directory ('run') or write into the output + directory the way they did before ('flat'). + + Read at call time so a worker subprocess and a test see the current + value, the same as every other directory question. + """ + from_environment = os.environ.get(OUTPUT_LAYOUT_ENV_VAR) + if from_environment in LAYOUTS: + return from_environment + + from .settings import load_settings + + from_settings = load_settings().output_layout + return from_settings if from_settings in LAYOUTS else RUN_LAYOUT + + +def set_output_layout(layout): + """Pin the layout for this process and anything it spawns.""" + if layout not in LAYOUTS: + raise ValueError(f"Unknown output layout: {layout}") + os.environ[OUTPUT_LAYOUT_ENV_VAR] = layout + return layout + + +def _safe_segment(text): + cleaned = _UNSAFE_SEGMENT_CHARACTERS.sub("_", str(text)).strip("._") + return cleaned or "workflow" + + +def workflow_identity(file_spec, workflow_id=None): + """What names this workflow's outputs, as a relative path. + + A workflow's position under a 'workflows' tree still reads as its + identity when it has one - 'workflows/ltx2/Gyre.json' is 'ltx2/Gyre' - + because that is the organization a user already chose. Outside such a + tree the file's own name is the identity, and an inline definition, + which has no file, is named by its workflow id. + + The result is always a relative path of safe segments: it is joined onto + the output directory, and nothing about it is allowed to leave. + """ + name = None + subfolder = "" + if file_spec: + base = os.path.basename(file_spec) + stem = os.path.splitext(base)[0] + if stem and stem != INLINE_FILE_NAME: + name = stem + directory = os.path.dirname(os.path.abspath(file_spec)) + parts = os.path.normpath(directory).split(os.sep) + try: + # The last 'workflows' segment wins, matching the packaged + # dw/workflows tree when a checkout has a top-level one too + index = len(parts) - 1 - parts[::-1].index("workflows") + except ValueError: + index = None + if index is not None and index + 1 < len(parts): + subfolder = os.path.join(*(_safe_segment(p) for p in parts[index + 1 :])) + + name = _safe_segment(name or workflow_id or "workflow") + return os.path.join(subfolder, name) if subfolder else name + + +def new_run_id(spec=None, now=None): + """An identifier for one execution: a UTC timestamp, then eight hex + digits of the spec that produced it. + + The timestamp is what sorts and what a person reads; the digest is what + tells two runs of the same second apart and makes a rerun of an edited + workflow visibly different from a rerun of the same one. A server job + could have used its job id, but a CLI run has none, and one scheme + everywhere is what lets anything reading the directory tree - the + gallery, a future history rebuild - understand both. + """ + stamp = (now or datetime.now(timezone.utc)).strftime("%Y%m%d-%H%M%S") + try: + material = json.dumps(spec, sort_keys=True, default=str) + except (TypeError, ValueError): + material = repr(spec) + digest = hashlib.sha256(material.encode("utf-8", "replace")).hexdigest()[:8] + return f"{stamp}-{digest}" + + +def is_run_id(segment): + """Whether a path segment is a run id this module generated.""" + return bool(RUN_ID_PATTERN.match(segment or "")) + + +def strip_run_id(relative_path): + """The workflow identity a run-relative path belongs to. + + 'ltx2/Gyre/20260905-181530-a1b2c3d4/still-0.png' -> 'ltx2/Gyre'. A path + with no run id in it comes back with its own directory unchanged, which + is what a flat-layout output does. + """ + parts = [part for part in (relative_path or "").split("/") if part] + directory = parts[:-1] + if directory and is_run_id(directory[-1]): + directory = directory[:-1] + return "/".join(directory) + + +def run_directory(output_dir, file_spec, workflow_id, run_id): + """Where one execution writes: //. + + One execution gets one directory, so a run id already taken - two runs + of the same spec started in the same second, which is what a quick + rerun is - takes a counter rather than writing into the earlier run's + directory and burying its manifest. + """ + base = os.path.join(output_dir, workflow_identity(file_spec, workflow_id), run_id) + candidate = base + counter = 1 + while os.path.exists(candidate): + counter += 1 + candidate = f"{base}-{counter}" + return candidate + + +def write_manifest(run_dir, manifest): + """Record what a run did, beside what it made. + + A server run is already in jobs.sqlite, but a CLI run has never been + recorded anywhere, and history that lives only in a database cannot + survive the directory being moved to another machine. Never fatal: a + run that produced its files has succeeded whether or not this lands. + """ + path = os.path.join(run_dir, MANIFEST_FILE_NAME) + try: + os.makedirs(run_dir, exist_ok=True) + with open(path, "w") as file: + json.dump(manifest, file, indent=2, default=str) + except OSError as e: + logger.warning(f"Could not write {path}: {e}") + return None + return path + + +def manifest_relative_files(files, run_dir): + """A run's file paths as the manifest records them: relative to the run + directory, so the directory can be moved or copied and still describe + itself. A file from an earlier run - what a step cache hit republishes - + is outside this directory and stays absolute. + """ + recorded = [] + for path in files or []: + try: + relative = os.path.relpath(path, run_dir) + except ValueError: # different drive on Windows + recorded.append(path) + continue + recorded.append( + path if relative.startswith(os.pardir) else relative.replace(os.sep, "/") + ) + return recorded diff --git a/dw/security.py b/dw/security.py index 40e1bded..cf33966c 100644 --- a/dw/security.py +++ b/dw/security.py @@ -421,6 +421,37 @@ def validate_variable_name(name: str) -> str: MAX_PROMPT_REFERENCE_LENGTH = 200 +def _validate_name( + name: str, pattern: str, max_length: int, what: str, hint: str +) -> str: + """Shared body of the reference/name validators below: an empty check, a + length check, then the pattern - length before pattern so a name that + fails both reports the shorter, cheaper-to-fix complaint first, matching + what each validator already reported on its own. + + Args: + name: The value to validate + pattern: Regex the name must fully match + max_length: Longest allowed length + what: Short label for the messages ('Prompt name', 'Asset name', ...) + hint: The rest of the "invalid" message, describing what a valid one + looks like + + Raises: + InvalidInputError: If name is invalid + """ + if not name: + raise InvalidInputError(f"{what} cannot be empty") + + if len(name) > max_length: + raise InvalidInputError(f"{what} too long: {len(name)} > {max_length}") + + if not re.match(pattern, name): + raise InvalidInputError(f"Invalid {what.lower()}: {name} - {hint}") + + return name + + def validate_prompt_reference(name: str) -> str: """ Validate the name a 'prompt:' reference points at. @@ -438,19 +469,124 @@ def validate_prompt_reference(name: str) -> str: Raises: InvalidInputError: If name is invalid """ - if not name: - raise InvalidInputError("Prompt name cannot be empty") + return _validate_name( + name, + PROMPT_REFERENCE_PATTERN, + MAX_PROMPT_REFERENCE_LENGTH, + "Prompt name", + "a prompt is named by its file under the prompt directory, at most " + "one folder deep, like 'scenic_landscape' or 'minimax/fox_dawn'", + ) - if not re.match(PROMPT_REFERENCE_PATTERN, name): - raise InvalidInputError( - f"Invalid prompt name: {name} - a prompt is named by its file under " - f"the prompt directory, at most one folder deep, like " - f"'scenic_landscape' or 'minimax/fox_dawn'" - ) - if len(name) > MAX_PROMPT_REFERENCE_LENGTH: +# A stored asset's name: a file name with its extension, optionally under +# folders. Each segment starts with a word character, which precludes '..', +# hidden files and absolute paths; the depth cap keeps a name a name. A prompt +# is named without its extension and lives at most one folder deep - an asset +# carries its extension, because which file it is depends on it, and media +# libraries nest deeper than prompt libraries do +ASSET_REFERENCE_PATTERN = r"^[\w][\w.-]*(/[\w][\w.-]*){0,4}\Z" +MAX_ASSET_REFERENCE_LENGTH = 400 + + +def validate_asset_reference(name: str) -> str: + """ + Validate the name an 'asset:' reference points at. + + The name is joined onto the asset directory to find the file, so it is + checked before anything touches the filesystem. Containment in the + library is checked separately, by the validate_path call that joins it. + + Args: + name: Asset name to validate + + Returns: + The validated name + + Raises: + InvalidInputError: If name is invalid + """ + return _validate_name( + name, + ASSET_REFERENCE_PATTERN, + MAX_ASSET_REFERENCE_LENGTH, + "Asset name", + "an asset is named by its file under the asset directory, with its " + "extension and at most four folders deep, like 'iris.jpg' or " + "'gyre/frames/iris.jpg'", + ) + + +# A generated output's name: the workflow's identity, the run, and the file - +# deeper than an asset name because the identity itself can nest, and the run +# id is a segment of its own +OUTPUT_REFERENCE_PATTERN = r"^[\w][\w.-]*(/[\w][\w.-]*){1,6}\Z" +MAX_OUTPUT_REFERENCE_LENGTH = 500 + + +def validate_output_reference(name: str) -> str: + """ + Validate the name an 'output:' reference points at. + + The name is joined onto the output directory to find the file, so it is + checked before anything touches the filesystem. Containment is checked + separately, by the validate_path call that joins it - after any 'latest' + segment has been expanded, so what is checked is the real path. + + Args: + name: Output name to validate + + Returns: + The validated name + + Raises: + InvalidInputError: If name is invalid + """ + return _validate_name( + name, + OUTPUT_REFERENCE_PATTERN, + MAX_OUTPUT_REFERENCE_LENGTH, + "Output name", + "an output is named by the workflow that made it, the run, and the " + "file, like 'ltx2/Gyre/latest/Gyre-still.0-0.0.png'", + ) + + +# A workspace's name: one path segment, starting with a word character, so +# '..', hidden names and anything with a separator in it are all excluded +# before the name is joined onto the workspace root +WORKSPACE_NAME_PATTERN = r"^[\w][\w.-]*\Z" +MAX_WORKSPACE_NAME_LENGTH = 100 + + +def validate_workspace_name(name: str) -> str: + """ + Validate a workspace name. + + Args: + name: Workspace name to validate + + Returns: + The validated name + + Raises: + InvalidInputError: If the name is not one a workspace can take + """ + from .workspace import RESERVED_WORKSPACE_NAMES + + _validate_name( + name, + WORKSPACE_NAME_PATTERN, + MAX_WORKSPACE_NAME_LENGTH, + "Workspace name", + "a workspace is one folder under the workspace root, named with " + "letters, numbers, dot, dash or underscore", + ) + + if name in RESERVED_WORKSPACE_NAMES: raise InvalidInputError( - f"Prompt name too long: {len(name)} > {MAX_PROMPT_REFERENCE_LENGTH}" + f"'{name}' is one of the workspace root's own folders " + f"({', '.join(RESERVED_WORKSPACE_NAMES)}) and cannot name a workspace" ) return name diff --git a/dw/serve.py b/dw/serve.py index d9108217..fb217116 100644 --- a/dw/serve.py +++ b/dw/serve.py @@ -27,10 +27,24 @@ def main(): ) parser.add_argument("--port", type=int, default=8765, help="Port (default: 8765)") parser.add_argument( - "--workflow-dir", default="./workflows", help="Directory of workflow JSON files" + "--workspace", + default=None, + help="Directory holding the workflows, prompts, assets and outputs " + "this server serves (default: DW_WORKSPACE, else the 'workspace' " + "setting, else the working directory when it looks like a " + "workspace, else ~/diffusers-workspace). --workflow-dir, " + "--output-dir and --prompt-dir each override one of its folders", + ) + parser.add_argument( + "--workflow-dir", + default=None, + help="Directory of workflow JSON files (default: the workspace's " + "workflows/)", ) parser.add_argument( - "--output-dir", default="./outputs", help="Directory results are written to" + "--output-dir", + default=None, + help="Directory results are written to (default: the workspace's " "outputs/)", ) parser.add_argument( "--prompt-dir", @@ -39,6 +53,31 @@ def main(): "a CLI run discovers it - DW_PROMPT_DIR, else ./prompts if it " "exists, else the nearest prompts/ above the workflow directory)", ) + parser.add_argument( + "--examples-dir", + action="append", + default=None, + dest="examples_dirs", + metavar="DIR", + help="A read-only directory of workflows to offer alongside the " + "workspace's own - a checkout's workflows/ tree, say. Repeatable. " + "Saves never go here: they always land in --workflow-dir", + ) + parser.add_argument( + "--asset-dir", + default=None, + help="Directory of input media 'asset:' references resolve against, " + "and where browser uploads are saved (default: the workspace's " + "assets/)", + ) + parser.add_argument( + "--output-layout", + choices=("run", "flat"), + default=None, + help="'run' (default) gives each job its own directory under " + "//, with a manifest.json beside its files; " + "'flat' writes the way it did before run directories", + ) parser.add_argument( "-l", "--log_level", @@ -77,6 +116,37 @@ def main(): ) args = parser.parse_args() + # Resolved and pinned before anything derives a directory from it - the + # spawned worker inherits the environment variable, the way it inherits + # the prompt directory and the trust flag below + from .workspace import resolve_workspace, set_workspace + + workspace = set_workspace(resolve_workspace(args.workspace)) + workflow_dir = args.workflow_dir or workspace.workflows + output_dir = args.output_dir or workspace.outputs + asset_dir = os.path.abspath(args.asset_dir or workspace.assets) + + # A workspace's workflow folder is where the UI and MCP clients save, so + # it has to exist for a first run in a fresh workspace. Only the folder + # actually defaulted to is created - an explicit --workflow-dir that does + # not exist stays the operator's typo, not a new empty directory. The + # output folder is created by the job manager on the same reasoning + if not args.workflow_dir: + os.makedirs(workflow_dir, exist_ok=True) + + # Created either way: it is the upload destination and a static mount, + # both of which need it to exist before the first request + os.makedirs(asset_dir, exist_ok=True) + + # Pinned like the prompt directory, so 'asset:' resolves to the same + # library in the worker that the upload route writes into + os.environ["DW_ASSET_DIR"] = asset_dir + + if args.output_layout: + from .runs import set_output_layout + + set_output_layout(args.output_layout) + token = args.token or os.environ.get("DW_API_TOKEN") or None from .server.app import LOOPBACK_HOSTS @@ -109,7 +179,7 @@ def main(): from .prompts import get_prompt_dir prompt_dir = os.path.abspath( - args.prompt_dir or get_prompt_dir(base_dir=os.path.abspath(args.workflow_dir)) + args.prompt_dir or get_prompt_dir(base_dir=os.path.abspath(workflow_dir)) ) os.environ["DW_PROMPT_DIR"] = prompt_dir @@ -145,10 +215,13 @@ def main(): app = create_app( # absolute, so the path the UI hands back on submit is unambiguous - workflow_dir=os.path.abspath(args.workflow_dir), - output_dir=args.output_dir, + workflow_dir=os.path.abspath(workflow_dir), + output_dir=output_dir, log_level=args.log_level, prompt_dir=prompt_dir, + asset_dir=asset_dir, + examples_dirs=args.examples_dirs, + workspace=workspace.root, host=args.host, token=token, mcp=args.mcp, diff --git a/dw/server/app.py b/dw/server/app.py index 6f5646d8..bdfc9528 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -6,6 +6,7 @@ """ import os +import shutil import io import zipfile import tempfile @@ -20,7 +21,7 @@ from urllib.parse import quote, urlparse from typing import Any, Dict, Optional -from fastapi import FastAPI, HTTPException, Request +from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import StreamingResponse, JSONResponse, Response, FileResponse from fastapi.staticfiles import StaticFiles @@ -29,6 +30,7 @@ from starlette.background import BackgroundTask from ..security import ( + validate_asset_reference, validate_path, validate_output_path, validate_prompt_reference, @@ -53,6 +55,28 @@ from .enhancers import build_enhance_workflow, preset_descriptions from ..result import read_embedded_metadata from ..hub_cache import scan_models, delete_model, DownloadManager +from ..runs import strip_run_id +from ..workspace import ( + DEFAULT_WORKSPACE_NAME, + ConfiguredWorkspace, + NotAWorkspaceError, + Workspace, + _holds_a_workspace, + create_workspace, + delete_workspace, + named_workspace, + workspace_contents, + workspace_names, +) +from ..workflow_sources import ( + find_workflow, + listing, + resolve_in_source, + source_for_path, + workflow_names, + workflow_sources, + writable_source, +) from .jobs import JobManager, MAX_PERSISTED_EVENTS, TERMINAL_STATES from .netinfo import local_addresses from .updater import DiffusersUpdater @@ -77,19 +101,10 @@ class JobRequest(BaseModel): default=None, description="Directory relative paths in an inline workflow resolve against", ) - - -def workflow_names(workflow_dir): - """Workflow names under workflow_dir, as relative paths without .json.""" - names = [] - if not os.path.isdir(workflow_dir): - return names - for root, _dirs, files in os.walk(workflow_dir): - for file_name in files: - if file_name.endswith(".json"): - relative = os.path.relpath(os.path.join(root, file_name), workflow_dir) - names.append(relative[: -len(".json")]) - return sorted(names) + workspace: Optional[str] = Field( + default=None, + description="Which workspace to run or resolve in; the default when omitted", + ) # What each workflow produces and takes, for listing cards - cached by mtime @@ -98,12 +113,28 @@ def workflow_names(workflow_dir): def _prune_detail_cache(cache, directory, names): """Forget files a listing no longer names - a long-lived server that - creates and deletes scratch files would otherwise grow the cache forever.""" + creates and deletes scratch files would otherwise grow the cache forever. + + `names` are relative names under `directory`. + """ live = {os.path.join(directory, f"{name}.json") for name in names} for stale in [path for path in cache if path not in live]: del cache[stale] +def _prune_missing(cache): + """Forget cached files that are gone from disk. + + Pruning by what one listing named would be wrong here: the workflow + cache is shared by every workspace, and a listing only ever sees one + workspace's search path, so anything cached for another workspace would + be thrown away and re-parsed on the next switch. Existence is the test + that holds for all of them at once. + """ + for stale in [path for path in cache if not os.path.exists(path)]: + del cache[stale] + + def collect_prompt_references(value): """Every stored-prompt name a definition references, at any depth - so deleting a prompt can warn which workflows would break.""" @@ -120,15 +151,21 @@ def collect_prompt_references(value): return references -def workflow_details(workflow_dir, names): +def workflow_details(sources_by_name): """Per-workflow card metadata: output kinds, step and variable counts, and the variable names themselves - enough for an agent to pick a workflow and know what to pass it without fetching each candidate. The names but not their defaults: across the workflows on disk the defaults - are an order of magnitude more payload, on a listing the UI reloads.""" + are an order of magnitude more payload, on a listing the UI reloads. + + Takes the name -> source mapping the search path produced, so each + entry also says where it came from and whether it can be written to - + what a client needs to decide between offering save and offering + save-a-copy. + """ details = {} - for name in names: - path = os.path.join(workflow_dir, f"{name}.json") + for name, source in sources_by_name.items(): + path = os.path.join(source.root, f"{name}.json") try: mtime = os.path.getmtime(path) except OSError: @@ -167,8 +204,14 @@ def workflow_details(workflow_dir, names): "prompt_refs": [], } _workflow_detail_cache[path] = (mtime, detail) - details[name] = detail - _prune_detail_cache(_workflow_detail_cache, workflow_dir, names) + # Cached by content, not by placement: the same file listed from a + # different source keeps its parsed detail and gets fresh origins + details[name] = { + **detail, + "origin": source.origin, + "writable": source.writable, + } + _prune_missing(_workflow_detail_cache) return details @@ -177,53 +220,73 @@ def _write_bytes(path, data): f.write(data) -def resolve_workflow_name(workflow_dir, name, allow_create=False): - """The on-disk path for a workflow name, confined to workflow_dir.""" - if not name.endswith(".json"): - name = f"{name}.json" - try: - return validate_path( - os.path.join(workflow_dir, name), workflow_dir, allow_create=allow_create +def resolve_readable_workflow(sources, name): + """The path a name has anywhere on the search path, and its source. + + Reads span every root - the workspace's own workflows, any examples + directory, and the packaged builtins - front to back, so a workspace + copy shadows the example it came from. + """ + path, source = find_workflow(sources, name) + if path is None: + raise HTTPException(status_code=404, detail=f"Unknown workflow: {name}") + return path, source + + +def resolve_writable_workflow(sources, name): + """Where a save goes: always the writable source, whatever the name + currently resolves to. + + Saving a workflow opened from an example is not an overwrite of that + example - it is a copy into the user's own library, which is what makes + the read-only roots safe to browse and edit from. + """ + source = writable_source(sources) + if source is None: + raise HTTPException( + status_code=409, detail="This server has no writable workflow directory" ) - except SecurityError as e: - raise HTTPException(status_code=404, detail=f"Unknown workflow: {e}") - - -def resolve_workflow_reference(workflow_dir, workflow_path): - """A submitted workflow_path, resolved to a file on disk, confined to - workflow_dir - the same confinement the /api/workflows CRUD routes - already enforce via resolve_workflow_name. - - Tried as a stored workflow name - exactly what /api/workflows hands - out, with or without .json and nested names included - so an agent can - run what a listing gave it. A relative or absolute path that already - names a file under workflow_dir resolves the same way: os.path.join - discards workflow_dir in favor of an absolute second argument, so an - absolute path under workflow_dir reaches the same containment check. - Anything that does not resolve under workflow_dir - an unknown name, a - traversal attempt, or a real file elsewhere on disk - is rejected with - 400, rather than silently opened: a workflow_path is not a general + path = resolve_in_source(source, name, allow_create=True) + if path is None: + raise HTTPException(status_code=404, detail=f"Unknown workflow: {name}") + return path, source + + +def resolve_workflow_reference(workflow_path, sources): + """A submitted workflow_path, resolved to a file on disk, and the source + it lives in - the same search path the /api/workflows CRUD routes read + from, spanning every root rather than confining to one, since a run of + an example is a read and reads are not confined to the writable root. + + Tried as a stored workflow name first - exactly what /api/workflows + hands out, with or without .json and nested names included - so an + agent can run what a listing gave it. A relative or absolute path that + already names a file under one of the sources resolves the same way: + os.path.abspath handles a path relative to the server's cwd, and + source_for_path holds it to that source's containment check. + + Anything that resolves under no source - an unknown name, a traversal + attempt, or a real file elsewhere on disk - is rejected with 400, + rather than silently opened: a workflow_path is not a general filesystem path. + + Returns (None, None) when workflow_path itself is None - an inline + workflow submission names no path to resolve. """ if workflow_path is None: - return workflow_path - try: - return resolve_workflow_name(workflow_dir, workflow_path) - except HTTPException: - pass - # Not a stored name. A path relative to the server's cwd - the shape the - # Workflow page submits when --workflow-dir is itself relative, e.g. - # './workflows/x.json' against './workflows' - would double the directory - # if joined onto workflow_dir, so it is resolved from the cwd and then - # held to the same containment check. - try: - return validate_path(os.path.abspath(workflow_path), workflow_dir) - except SecurityError: - raise HTTPException( - status_code=400, - detail=f"workflow_path must name a workflow under the workflow " - f"directory: {workflow_path}", - ) + return None, None + path, source = find_workflow(sources, workflow_path) + if path is not None: + return path, source + candidate = os.path.abspath(workflow_path) + source = source_for_path(sources, candidate) if os.path.isfile(candidate) else None + if source is not None: + return candidate, source + raise HTTPException( + status_code=400, + detail=f"workflow_path must name a workflow the server can reach: " + f"{workflow_path}", + ) # What each prompt says about itself, for listing cards - cached by mtime @@ -379,6 +442,9 @@ def create_app( download_manager=None, diffusers_updater=None, prompt_dir="./prompts", + asset_dir=None, + examples_dirs=None, + workspace=None, host="127.0.0.1", token=None, mcp=False, @@ -438,8 +504,48 @@ async def lifespan(app): ) app.state.job_manager = manager app.state.workflow_dir = workflow_dir + # The search path: the writable directory first, then read-only roots - + # any --examples-dir, then the packaged builtins. Reads span all of it, + # saves only ever reach the front + app.state.workflow_sources = workflow_sources(workflow_dir, examples_dirs) app.state.prompt_dir = prompt_dir + # Where uploads land and 'asset:' references resolve. None when the + # caller configured no asset library: uploads then fall back to the + # output directory's uploads/ subfolder, as they did before there was one + app.state.asset_dir = os.path.abspath(asset_dir) if asset_dir else None + # The workspace the three directories above default to folders of, for a + # client that wants to name the root rather than reason about the parts. + # None when the caller resolved no workspace (a test building an app + # around three explicit directories) + app.state.workspace = os.path.abspath(workspace) if workspace else None + # The root that holds named workspaces. Its own folders are the default + # workspace - which is what the three directories above already point at, + # so a server given individual directory overrides simply has one + # workspace and no others + app.state.workspace_root = ( + Workspace(app.state.workspace, "flag") if app.state.workspace else None + ) + # The default workspace itself, as a Workspace: its four folders are the + # configured directories above, not '/workflows' and friends - a + # caller can override any one of them individually (--workflow-dir, + # etc), so they cannot be derived from a root the way a named + # workspace's folders are + app.state.default_workspace = ConfiguredWorkspace( + workflows=app.state.workflow_dir, + assets=app.state.asset_dir, + outputs=manager.output_dir, + prompts=app.state.prompt_dir, + root=app.state.workspace, + ) app.state.mcp_mounted = mcp_asgi is not None + # A StaticFiles instance per output/asset root, built lazily and reused - + # a mount is bound to one directory at startup, but a named workspace's + # root does not exist yet then. Keeping the instance around (rather than + # building one per request) is what makes /outputs and /inputs answer + # ETag/If-None-Match with 304 and Range with 206 the way a real mount + # does, instead of the plain FileResponse this replaced always resending + # the whole file + app.state.static_files_by_root = {} wildcard_bind = host in WILDCARD_HOSTS allowed_hosts = set(LOOPBACK_HOSTS) @@ -542,18 +648,80 @@ async def require_bearer_token(request: Request, call_next): ) return await call_next(request) + # -------------------------------------------------------- workspace lookup + + def _workspace_root(): + root = app.state.workspace_root + if root is None: + raise HTTPException( + status_code=409, + detail="This server has no workspace root - it was started " + "with individual directory overrides, so it has one " + "workspace and cannot create others", + ) + return root + + def _workspace_for(name): + """The Workspace a request names. + + No name, or the default name, is the server's own configuration - + the directories it was started with - so every call that predates + workspaces keeps working unchanged. A named one resolves under the + root, and must already exist: creating a workspace by mentioning it + would turn a typo into a directory. Checked by looking at the one + candidate directory rather than listing the whole root - this runs + on every gallery thumbnail request. + """ + if not name or name == DEFAULT_WORKSPACE_NAME: + return app.state.default_workspace + root = _workspace_root() + try: + selected = named_workspace(root, name) + except SecurityError as e: + raise HTTPException(status_code=400, detail=str(e)) + if not _holds_a_workspace(selected.root): + raise HTTPException(status_code=404, detail=f"No such workspace: {name}") + return selected + + def selected_workspace(workspace: Optional[str] = None) -> Workspace: + """FastAPI dependency form of _workspace_for, reading the name from + the `?workspace=` query parameter every scoped route already takes - + used as `ws: Workspace = Depends(selected_workspace)`.""" + return _workspace_for(workspace) + + def _sources_for(ws): + """The workflow search path of one workspace: its own workflows + first, then the same read-only roots every workspace shares.""" + return workflow_sources(ws.workflows, examples_dirs) + # ------------------------------------------------------------------ jobs @app.post("/api/jobs", status_code=201) - def submit_job(request: JobRequest): + def submit_job(request: JobRequest, ws: Workspace = Depends(selected_workspace)): + """Queue a workflow. The workspace it runs in comes from the body or, + for a client that scopes every call the same way, the query string - + the body wins when both are given.""" try: + workspace = _workspace_for(request.workspace or ws.name) + sources = _sources_for(workspace) + resolved, source = resolve_workflow_reference( + request.workflow_path, sources + ) job = manager.submit( - workflow_path=resolve_workflow_reference( - app.state.workflow_dir, request.workflow_path - ), + workflow_path=resolved, workflow=request.workflow, arguments=request.arguments, base_dir=request.base_dir, + # The root this run is confined to: the source the workflow + # came from, so an example runs where it lives while an + # inline definition stays held to this workspace's own + # workflows + workflow_dir=source.root if source else workspace.workflows, + # The roots this job runs against, so it stays in its + # workspace however many others the server serves meanwhile + output_dir=workspace.outputs, + asset_dir=workspace.assets, + workspace=workspace.name, ) except HTTPException: raise @@ -564,8 +732,11 @@ def submit_job(request: JobRequest): return manager.describe(job) @app.get("/api/jobs") - def list_jobs(): - return {"jobs": manager.list()} + def list_jobs(workspace: Optional[str] = None): + """All jobs by default - a plain filter, not `selected_workspace`, + since the jobs list spans every workspace the server holds unless a + caller asks to narrow it.""" + return {"jobs": manager.list(workspace=workspace)} @app.get("/api/jobs/{job_id}") def get_job(job_id: str): @@ -740,35 +911,43 @@ def workflow_schema(): return JSONResponse(load_schema("workflow")) @app.post("/api/validate") - def validate_workflow(request: JobRequest): + def validate_workflow( + request: JobRequest, ws: Workspace = Depends(selected_workspace) + ): """Schema-validate a workflow and check its pipeline arguments against real signatures, without queuing anything. Give either an inline workflow or a workflow_path - a path on the server or a - stored workflow name from /api/workflows.""" + stored workflow name from /api/workflows. The workspace it resolves + in comes from the body or the query string, body first.""" if (request.workflow is None) == (request.workflow_path is None): raise HTTPException( status_code=400, detail="Provide exactly one of workflow or workflow_path", ) try: + workspace = _workspace_for(request.workspace or ws.name) if request.workflow_path is not None: # Built from the file so relative paths inside it resolve # against its own directory, exactly as a run would + sources = _sources_for(workspace) + resolved, source = resolve_workflow_reference( + request.workflow_path, sources + ) candidate = workflow_from_file( - resolve_workflow_reference( - app.state.workflow_dir, request.workflow_path - ), - manager.output_dir, - app.state.workflow_dir, + resolved, + workspace.outputs, + # Confined to the source it came from, not to the + # writable root - an example is read where it lives + source.root if source else workspace.workflows, ) definition = candidate.workflow_definition else: definition = request.workflow candidate = workflow_from_definition( copy.deepcopy(request.workflow), - manager.output_dir, + workspace.outputs, request.base_dir, - app.state.workflow_dir, + workspace.workflows, ) except HTTPException: raise @@ -794,30 +973,138 @@ def validate_workflow(request: JobRequest): "warnings": workflow_argument_warnings(definition), } + # ------------------------------------------------------------ workspaces + + class WorkspaceRequest(BaseModel): + name: str = Field(description="Name for the new workspace") + + @app.get("/api/workspaces") + def list_workspaces(): + """Every workspace on this server, the default first. + + A workspace is a namespace, not a security boundary: the API token + is all-or-nothing, so anything that can list these can reach all of + them. + """ + root = app.state.workspace_root + # workspace_names lists the whole root once; everything after the + # first entry (always the default, see its docstring) is a named + # workspace to describe individually + names = workspace_names(root)[1:] if root else [] + described = [app.state.default_workspace.describe()] + for name in names: + described.append(named_workspace(root, name).describe()) + return { + "workspace_root": root.root if root else None, + "default": DEFAULT_WORKSPACE_NAME, + "workspaces": described, + } + + @app.post("/api/workspaces", status_code=201) + def add_workspace(request: WorkspaceRequest): + """Create a workspace: its own workflows, assets and outputs, sharing + this server's one prompt library.""" + root = _workspace_root() + try: + created = create_workspace(root, request.name) + except SecurityError as e: + raise HTTPException(status_code=400, detail=str(e)) + except FileExistsError as e: + raise HTTPException(status_code=409, detail=str(e)) + logger.info(f"Created workspace {request.name} at {created.root}") + return created.describe() + + @app.delete("/api/workspaces/{name}") + def remove_workspace(name: str, acknowledged: bool = False): + """Delete a workspace and everything in it. + + Answers what it would remove and refuses until `acknowledged=true`: + this deletes generated work, and a count is what makes it an + informed choice rather than a surprise. + """ + root = _workspace_root() + if name == DEFAULT_WORKSPACE_NAME: + raise HTTPException( + status_code=400, + detail="The default workspace cannot be deleted - it is the " + "workspace root itself, and holds the shared prompt library", + ) + if name not in workspace_names(root): + raise HTTPException(status_code=404, detail=f"No such workspace: {name}") + + contents = workspace_contents(named_workspace(root, name)) + if not acknowledged: + raise HTTPException( + status_code=409, + detail={ + "message": f"Deleting workspace '{name}' removes these files " + f"permanently. Repeat with acknowledged=true to proceed.", + "contents": contents, + }, + ) + with manager._lock: + queued = [ + job + for job in manager.jobs.values() + if job.status not in TERMINAL_STATES + and job.spec.get("workspace") == name + ] + if queued: + raise HTTPException( + status_code=409, + detail=f"Workspace '{name}' has {len(queued)} job(s) queued or " + f"running - cancel them first", + ) + try: + delete_workspace(root, name) + except NotAWorkspaceError as e: + # The directory holds more than a workspace - refused outright, + # since what else it holds is not the caller's to acknowledge away + raise HTTPException( + status_code=409, detail={"message": str(e), "entries": e.entries} + ) + except (ValueError, FileNotFoundError) as e: + raise HTTPException(status_code=400, detail=str(e)) + logger.info(f"Deleted workspace {name}") + return {"name": name, "deleted": True, "contents": contents} + # ------------------------------------------------------------- workflows @app.get("/api/workflows") - def list_workflows(): - names = workflow_names(app.state.workflow_dir) + def list_workflows(ws: Workspace = Depends(selected_workspace)): + """Every workflow the search path offers, each detail saying which + source it came from and whether it can be written to. 'workflow_dir' + stays the writable one - what a save targets.""" + sources = _sources_for(ws) + found = listing(sources) return { - "workflow_dir": app.state.workflow_dir, - "workflows": names, - "details": workflow_details(app.state.workflow_dir, names), + "workspace": ws.name, + "workflow_dir": ws.workflows, + "sources": [source.to_dict() for source in sources], + "workflows": list(found), + "details": workflow_details(found), } @app.put("/api/workflows/{name:path}") - def save_workflow(name: str, request: JobRequest): - """Write a workflow into the workflow directory. The definition must - be schema-valid - the editor validates before saving, and a save that - silently wrote a broken file would betray both.""" + def save_workflow( + name: str, request: JobRequest, ws: Workspace = Depends(selected_workspace) + ): + """Write a workflow into the writable workflow directory. The + definition must be schema-valid - the editor validates before saving, + and a save that silently wrote a broken file would betray both. + + A name that currently resolves to a read-only source (an example, a + builtin) is not overwritten: the copy lands in the writable source + and shadows it from then on. + """ if request.workflow is None: raise HTTPException(status_code=400, detail="Provide an inline workflow") - path = resolve_workflow_name(app.state.workflow_dir, name, allow_create=True) + path, _source = resolve_writable_workflow(_sources_for(ws), name) candidate = Workflow( copy.deepcopy(request.workflow), - manager.output_dir, + ws.outputs, path, - app.state.workflow_dir, + ws.workflows, ) try: candidate.validate() @@ -835,30 +1122,51 @@ def save_workflow(name: str, request: JobRequest): } @app.delete("/api/workflows/{name:path}") - def delete_workflow(name: str): - """Remove a workflow file from the workflow directory.""" - path = resolve_workflow_name(app.state.workflow_dir, name) + def delete_workflow(name: str, ws: Workspace = Depends(selected_workspace)): + """Remove a workflow file from the writable workflow directory. + + A read-only source is refused rather than silently ignored: an + example or a builtin is not the caller's to delete, and saying so + is more useful than a 404 that reads like the file is missing. + """ + path, source = resolve_readable_workflow(_sources_for(ws), name) + if not source.writable: + raise HTTPException( + status_code=403, + detail=f"'{name}' comes from the read-only {source.origin} " + f"directory {source.root} and cannot be deleted", + ) os.remove(path) logger.info(f"Deleted workflow {name} ({path})") return {"name": name, "deleted": True} @app.get("/api/workflows/{name:path}/download") @query_token_ok - def download_workflow(name: str): + def download_workflow(name: str, ws: Workspace = Depends(selected_workspace)): """Serve a workflow definition as a forced download.""" - path = resolve_workflow_name(app.state.workflow_dir, name) + path, _source = resolve_readable_workflow(_sources_for(ws), name) return FileResponse( path, filename=os.path.basename(path), media_type="application/json" ) @app.get("/api/workflows/{name:path}") - def get_workflow(name: str): - path = resolve_workflow_name(app.state.workflow_dir, name) + def get_workflow(name: str, ws: Workspace = Depends(selected_workspace)): + path, source = resolve_readable_workflow(_sources_for(ws), name) try: with open(path, "r") as file: - return JSONResponse(json.load(file)) + definition = json.load(file) except (OSError, json.JSONDecodeError) as e: raise HTTPException(status_code=500, detail=f"Could not read workflow: {e}") + # Which root it came from and whether a save would land here or + # copy elsewhere - the editor reads these to offer save-in-place + # only for a writable source, save-a-copy otherwise + return JSONResponse( + definition, + headers={ + "X-Workflow-Origin": source.origin, + "X-Workflow-Writable": "true" if source.writable else "false", + }, + ) # --------------------------------------------------------------- prompts @@ -955,9 +1263,12 @@ def list_enhancers(): return {"presets": preset_descriptions()} @app.post("/api/enhance", status_code=201) - def enhance(request: EnhanceRequest): + def enhance(request: EnhanceRequest, ws: Workspace = Depends(selected_workspace)): """Queue a prompt enhancement as an ordinary job. The enhanced text - is the job's single manifest file once it succeeds.""" + is the job's single manifest file once it succeeds. + + Scoped like any other job: the caller reads the result back from the + workspace it asked in, so this has to write there too.""" try: definition = build_enhance_workflow( request.preset, @@ -965,7 +1276,14 @@ def enhance(request: EnhanceRequest): model_name=request.model_name, device=request.device, ) - job = manager.submit(workflow=definition, arguments={}) + job = manager.submit( + workflow=definition, + arguments={}, + workflow_dir=ws.workflows, + output_dir=ws.outputs, + asset_dir=ws.assets, + workspace=ws.name, + ) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) return manager.describe(job) @@ -989,12 +1307,14 @@ def enhance(request: EnhanceRequest): # Longest side of an on-demand gallery thumbnail, in pixels GALLERY_THUMBNAIL_MAX_DIM = 320 - def _output_file(name): - """A file inside the output directory, or a 404 - never outside it.""" + def _output_file(name, root=None): + """A file inside a workspace's output directory, or a 404 - never + outside it.""" + root = root or manager.output_dir try: path = validate_path( - os.path.join(manager.output_dir, name), - manager.output_dir, + os.path.join(root, name), + root, allow_create=False, ) except SecurityError as e: @@ -1003,28 +1323,60 @@ def _output_file(name): raise HTTPException(status_code=404, detail="Unknown file") return path - def _iter_gallery_files(): - """Every media file under the output directory, recursing into the - per-workflow subfolders (dw/workflow.py's effective_output_dir mirrors - a workflow's position under a 'workflows' tree into the output dir). - Yields (relative_name, folder, kind, path) - relative_name always - uses '/' so it round-trips through a URL the same way on every - platform.""" - for root, _dirs, names in os.walk(manager.output_dir): - rel_root = os.path.relpath(root, manager.output_dir) - folder = "" if rel_root == "." else rel_root.replace(os.sep, "/") + 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.""" + cache = app.state.static_files_by_root + files = cache.get(root) + if files is None: + files = StaticFiles(directory=root) + cache[root] = files + return files + + def _served_url(path, ws, version=None): + """The URL a served file is reachable at: the default workspace's + files keep the URL they have always had, a named one carries the + same selector its API calls do, so one route serves both. 'v=' is + cache-busting for a name reused by a rerun, not the workspace + selector, so it always comes last.""" + url = path if ws.is_default else f"{path}?workspace={quote(ws.name)}" + if version is None: + return url + separator = "&" if "?" in url else "?" + return f"{url}{separator}v={version}" + + def _iter_gallery_files(root, group_runs=True): + """Every media file under a directory tree. Yields (relative_name, + folder, kind, path) - relative_name always uses '/' so it + round-trips through a URL the same way on every platform. + + With group_runs (the gallery's own use, over the output directory): + recurses into the per-workflow subfolders (dw/workflow.py's + effective_output_dir writes each run under '//', and mirrors a workflow's position under a + 'workflows' tree in the flat layout), and the folder a file is + grouped under drops the run id - a workflow run fifty times is one + folder in the filter, not fifty. Which run a file came from is still + in its name, and in the manifest beside it. + + Without it (the asset library's use, which has no run ids to strip): + folder is just the plain relative directory.""" + for current, _dirs, names in os.walk(root): + rel_root = os.path.relpath(current, root) + directory = "" if rel_root == "." else rel_root.replace(os.sep, "/") for name in names: extension = os.path.splitext(name)[1].lower() kind = MEDIA_KINDS.get(extension) if kind is None: continue - relative_name = name if not folder else f"{folder}/{name}" - yield relative_name, folder, kind, os.path.join(root, name) + relative_name = name if not directory else f"{directory}/{name}" + folder = strip_run_id(relative_name) if group_runs else directory + yield relative_name, folder, kind, os.path.join(current, name) - def _gallery_entries(): + def _gallery_entries(root, ws): entries = [] try: - files = list(_iter_gallery_files()) + files = list(_iter_gallery_files(root)) except OSError: files = [] for relative_name, folder, kind, path in files: @@ -1047,7 +1399,9 @@ def _gallery_entries(): # changing (e.g. a manual overwrite outside the engine) - # normal reruns get a fresh name instead, see # dw/result.py's output_file_path - "url": f"/outputs/{quote(relative_name)}?v={int(stat.st_mtime)}", + "url": _served_url( + f"/outputs/{quote(relative_name)}", ws, int(stat.st_mtime) + ), "kind": kind, "size": stat.st_size, "mtime": stat.st_mtime, @@ -1058,15 +1412,21 @@ def _gallery_entries(): return entries @app.get("/api/gallery") - def gallery(limit: int = 200, offset: int = 0, folder: Optional[str] = None): + def gallery( + limit: int = 200, + offset: int = 0, + folder: Optional[str] = None, + ws: Workspace = Depends(selected_workspace), + ): """A page of media files in the output directory, newest first. Stateless by design - the gallery survives server restarts because it reads the directory tree, not job history. 'folders' lists every - distinct workflow subfolder present (over the whole directory, not - just this page), for the UI's folder filter - '' stands for files + distinct workflow folder present (over the whole directory, not just + this page), for the UI's folder filter - a run id is not a folder of + its own, so a workflow's runs group together; '' stands for files saved directly at the output root, and is itself always a member so that folder-less outputs stay selectable once anything is nested.""" - entries = _gallery_entries() + entries = _gallery_entries(ws.outputs, ws) folders = sorted({e["folder"] for e in entries} | {""}) if folder is not None: entries = [e for e in entries if e["folder"] == folder] @@ -1079,29 +1439,35 @@ def gallery(limit: int = 200, offset: int = 0, folder: Optional[str] = None): "offset": offset, "limit": limit, "folders": folders, + "workspace": ws.name, } @app.get("/api/gallery/{name:path}/metadata") - def gallery_metadata(name: str): + 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.""" - path = _output_file(name) + path = _output_file(name, ws.outputs) metadata = read_embedded_metadata(path) try: - job = manager.history.job_for_file(name) + # 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 return {"name": name, "metadata": metadata, "job": job} @app.get("/api/gallery/{name:path}/thumbnail") @query_token_ok - def gallery_thumbnail(name: str, request: Request): + def gallery_thumbnail( + name: str, request: Request, ws: Workspace = Depends(selected_workspace) + ): """A small JPEG rendition of an image output, for the grid - the full-resolution file is only fetched for the detail/lightbox view. Generated on demand rather than cached to disk, so it never grows the output directory the gallery itself scans.""" - path = _output_file(name) + path = _output_file(name, ws.outputs) extension = os.path.splitext(path)[1].lower() if MEDIA_KINDS.get(extension) != "image": raise HTTPException( @@ -1140,9 +1506,9 @@ def gallery_thumbnail(name: str, request: Request): @app.get("/api/gallery/{name:path}/download") @query_token_ok - def download_output(name: str): + def download_output(name: str, ws: Workspace = Depends(selected_workspace)): """Serve one output file as a forced download rather than an inline view.""" - path = _output_file(name) + path = _output_file(name, ws.outputs) return FileResponse(path, filename=os.path.basename(name)) # A generous ceiling rather than a real limit - it exists so a @@ -1153,7 +1519,9 @@ class ArchiveRequest(BaseModel): names: list[str] = Field(min_length=1, max_length=MAX_ARCHIVE_FILES) @app.post("/api/gallery/archive") - def archive_outputs(request: ArchiveRequest): + def archive_outputs( + request: ArchiveRequest, ws: Workspace = Depends(selected_workspace) + ): """Bundle a multi-file gallery selection into one zip. A browser cannot zip on its own and throttles a burst of single downloads, so the whole selection has to arrive as one file. Written to a temp @@ -1161,7 +1529,7 @@ def archive_outputs(request: ArchiveRequest): RAM - and unlinked once the response has been sent.""" # Resolved before anything is written, so a bad name in the # selection fails the request instead of yielding a partial zip - paths = [(name, _output_file(name)) for name in request.names] + paths = [(name, _output_file(name, ws.outputs)) for name in request.names] handle = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) try: @@ -1185,9 +1553,9 @@ def archive_outputs(request: ArchiveRequest): ) @app.delete("/api/gallery/{name:path}") - def delete_output(name: str): + def delete_output(name: str, ws: Workspace = Depends(selected_workspace)): """Remove one file from the output directory.""" - path = _output_file(name) + path = _output_file(name, ws.outputs) os.remove(path) logger.info(f"Deleted output file {name}") return {"name": name, "deleted": True} @@ -1195,17 +1563,29 @@ def delete_output(name: str): # ---------------------------------------------------------------- uploads UPLOADS_SUBDIR = "uploads" - ALLOWED_UPLOAD_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS | ALLOWED_VIDEO_EXTENSIONS + # Audio included: the asset library holds it and workflows read it (an + # H3 audio reference is built from a .wav), so refusing it here would + # leave one input kind with no way onto the machine + ALLOWED_UPLOAD_EXTENSIONS = ( + ALLOWED_IMAGE_EXTENSIONS | ALLOWED_VIDEO_EXTENSIONS | ALLOWED_AUDIO_EXTENSIONS + ) MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200MB - covers a short video clip @app.post("/api/uploads", status_code=201) - async def upload_media(request: Request, filename: str): - """Save a browser-picked image or video into the output directory's - uploads/ subfolder and hand back its path - the same string shape a - workflow's 'image'/'video' arguments already accept (a plain path, - resolved absolute so it works regardless of the workflow file's own - directory). The body is the raw file bytes: no multipart parser - dependency needed for a single-file upload. + async def upload_media( + request: Request, filename: str, ws: Workspace = Depends(selected_workspace) + ): + """Save a browser-picked image, video or audio file into the asset library's + uploads/ subfolder and hand back the reference a workflow argument + can carry. + + An upload is input, so it belongs in the asset library rather than + among generated output, and the reference handed back is + 'asset:uploads/' - portable, and meaningful in a workflow that + is saved and rerun later. A server with no asset library configured + keeps the old behavior, writing to the output directory's uploads/ + and returning an absolute path. The body is the raw file bytes: no + multipart parser dependency needed for a single-file upload. """ extension = os.path.splitext(os.path.basename(filename))[1].lower() if extension not in ALLOWED_UPLOAD_EXTENSIONS: @@ -1230,7 +1610,8 @@ async def upload_media(request: Request, filename: str): detail=f"Upload too large: {len(body)} > {MAX_UPLOAD_BYTES}", ) - uploads_dir = os.path.join(manager.output_dir, UPLOADS_SUBDIR) + library = ws.assets or ws.outputs + uploads_dir = os.path.join(library, UPLOADS_SUBDIR) os.makedirs(uploads_dir, exist_ok=True) name = f"{uuid.uuid4().hex}{extension}" try: @@ -1242,9 +1623,133 @@ async def upload_media(request: Request, filename: str): # stream and poll for its duration await run_in_threadpool(_write_bytes, dest, body) logger.info(f"Saved upload {filename!r} -> {dest}") + if ws.assets: + return { + "path": f"asset:{UPLOADS_SUBDIR}/{name}", + "url": _served_url(f"/inputs/{UPLOADS_SUBDIR}/{quote(name)}", ws), + } return { "path": dest, - "url": f"/outputs/{UPLOADS_SUBDIR}/{quote(name)}", + "url": _served_url(f"/outputs/{UPLOADS_SUBDIR}/{quote(name)}", ws), + } + + @app.get("/api/assets") + def list_assets(ws: Workspace = Depends(selected_workspace)): + """The asset library: the input media an 'asset:' reference names. + + Reported by reference rather than by path - 'asset:uploads/x.png' is + what a workflow argument carries, and a client that only ever sees + references cannot accidentally write a path that means something + else on another machine. Empty, not an error, on a server with no + library configured: nothing is wrong, there is just nowhere for an + asset to be. + """ + library = ws.assets + if not library or not os.path.isdir(library): + return {"asset_dir": library, "assets": [], "folders": []} + + assets = [] + try: + files = list(_iter_gallery_files(library, group_runs=False)) + except OSError: + files = [] + for relative, folder, kind, path in files: + try: + stat = os.stat(path) + except OSError: + continue + assets.append( + { + "name": relative, + "reference": f"asset:{relative}", + "folder": folder, + "kind": kind, + "size": stat.st_size, + "mtime": stat.st_mtime, + # For the editor's own preview - fetchable the same + # way an upload's URL is + "url": _served_url(f"/inputs/{quote(relative)}", ws), + } + ) + assets.sort(key=lambda entry: entry["mtime"], reverse=True) + return { + "asset_dir": library, + "assets": assets, + "folders": sorted({entry["folder"] for entry in assets} | {""}), + } + + class KeepRequest(BaseModel): + name: str = Field( + description="The generated file to keep, as the gallery names it" + ) + asset_name: Optional[str] = Field( + default=None, + description="Name to keep it under in the asset library; its own " + "file name when omitted", + ) + overwrite: bool = Field( + default=False, description="Replace an asset already under that name" + ) + + @app.post("/api/assets/keep", status_code=201) + def keep_output_as_asset( + request: KeepRequest, ws: Workspace = Depends(selected_workspace) + ): + """Keep a generated file as an input asset, under a stable name. + + A run's files live under '//', which is the right + place for them and the wrong name to build on: 'latest' moves, and a + pinned run id breaks the moment outputs are pruned. Keeping one + copies it into the workspace's asset library, where an 'asset:' name + stays put - which is what turns a generated still or score into an + input later workflows can rely on. + + Within the workspace, so nothing crosses a namespace, and no bytes + cross the network: a client that had to download and re-upload a + multi-gigabyte video to reuse one frame would be paying for the + round trip twice. + """ + library = ws.assets + if not library: + raise HTTPException( + status_code=409, detail="This workspace has no asset library" + ) + + source = _output_file(request.name, ws.outputs) + asset_name = request.asset_name or os.path.basename(request.name) + try: + asset_name = validate_asset_reference(asset_name) + destination = validate_path(os.path.join(library, asset_name), library) + except SecurityError as e: + raise HTTPException(status_code=400, detail=str(e)) + + if os.path.exists(destination) and not request.overwrite: + raise HTTPException( + status_code=409, + detail=f"asset:{asset_name} already exists - pass overwrite=true " + f"to replace it", + ) + + os.makedirs(os.path.dirname(destination), exist_ok=True) + if os.path.exists(destination): + os.remove(destination) + # A hard link first: keeping one frame of a multi-gigabyte render + # should not cost another copy of it, and both names refer to the + # same content anyway. Falls back to a copy when the link cannot be + # made - a different filesystem, or one that has no links + try: + os.link(source, destination) + linked = True + except OSError: + shutil.copy2(source, destination) + linked = False + + logger.info(f"Kept output {request.name} as asset:{asset_name}") + return { + "reference": f"asset:{asset_name}", + "name": asset_name, + "path": destination, + "linked": linked, } # ----------------------------------------------------------------- models @@ -1434,7 +1939,11 @@ def server_info(): "mcp": {"mounted": bool(app.state.mcp_mounted), "path": MCP_PATH}, "addresses": addresses, "directories": { + # The workspace the three below default to folders of; an + # individually overridden folder still reports its own path + "workspace": app.state.workspace, "workflows": os.path.abspath(app.state.workflow_dir), + "assets": app.state.asset_dir, "outputs": os.path.abspath(manager.output_dir), "prompts": ( os.path.abspath(app.state.prompt_dir) @@ -1463,7 +1972,43 @@ def server_info(): Route("/mcp/{sub_path:path}", endpoint=mcp_asgi, name="mcp_sub") ) - app.mount("/outputs", StaticFiles(directory=manager.output_dir), name="outputs") + # Generated files and input media, served as routes rather than static + # mounts: a mount is bound to one directory at startup, and a workspace + # can be created afterwards. Each handler delegates to a StaticFiles + # instance for the workspace's own root (_static_files_for) rather than + # a bare FileResponse - a FileResponse never answers 304 (no + # If-None-Match handling), so every gallery load re-streamed the whole + # file; going through StaticFiles.get_response restores ETag/ + # If-None-Match 304s, Range/206 and its own 404 handling, the way a real + # mount always has. + # + # Ungated, as the mounts were, and for the same reason: an or + #