Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1ead06d
fix(security): #112-#118, #120 - one policy for a workflow-supplied l…
dkackman Sep 13, 2026
1154fa8
fix(tasks): #108 #109 #110 - a rate mismatch resamples, and two docum…
dkackman Sep 13, 2026
4452464
docs: the location policy, the closed step object, and download_outpu…
dkackman Sep 13, 2026
46a61d5
Merge fix/mcp-cycle-108-120 into develop (#108-#110, #112-#118, #120)
dkackman Sep 13, 2026
0013182
tmp
dkackman Sep 13, 2026
781a4c8
fix(mcp): #108 #117 #123 #124 - a warning the caller can read, and th…
dkackman Sep 13, 2026
ebab468
Merge fix/mcp-cycle-108-124 into develop (#108, #117, #123, #124)
dkackman Sep 13, 2026
9edb3b5
fix(mcp): #123 - the engine-injected key is legal but not advertised
dkackman Sep 13, 2026
094fb2c
fix(mcp): #126 #127 - padding that says so, and an asset you can measure
dkackman Sep 13, 2026
3bfd40a
Merge fix/mcp-cycle-126-127 into develop (#126, #127)
dkackman Sep 13, 2026
931b169
feat(ui): for_each entries drawn inset in the flow view
dkackman Sep 13, 2026
5dbbd7f
format
dkackman Sep 13, 2026
e1e2cd9
feat(api): update workflow handling to separate definition from trans…
dkackman Sep 13, 2026
67df9af
Merge pull request #130 from dkackman/ui/for-each-member-chips
dkackman Sep 13, 2026
40e47b5
feat(ui): job page can show the workflow it ran as JSON
dkackman Sep 13, 2026
8b6b783
fix(ui): JsonEditor soft-wraps long lines
dkackman Sep 13, 2026
a39499b
Merge pull request #131 from dkackman/ui/job-page-realized-json
dkackman Sep 13, 2026
7083d9f
format
dkackman Sep 13, 2026
5e82082
fix: #128 #129 #133 #134 #136 #137 #138 - templates, warnings, cleanu…
dkackman Sep 14, 2026
d93393d
Merge fixes for #128 #129 #133 #134 #136 #137 #138
dkackman Sep 14, 2026
a6607e2
first ruff run
dkackman Sep 14, 2026
9bf383c
feat: add preflight script for automated checks
dkackman Sep 14, 2026
25ed777
fix: #139 #140 - declared numeric domains on task arguments
dkackman Sep 14, 2026
6930306
Merge fixes for #139 #140 - task argument numeric domains
dkackman Sep 14, 2026
26bfc49
Merge pull request #143 from dkackman/ruff
dkackman Sep 14, 2026
31f4f93
Merge branch 'master' into develop
dkackman Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ jobs:
pip install -r requirements.txt -r requirements-test.txt
pip install git+https://github.com/huggingface/diffusers
- name: Format check
run: black --check dw dw_mcp tests
run: ruff format --check dw dw_mcp tests
- name: Lint
run: ruff check dw dw_mcp tests
- name: Tests
run: pytest -q

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ __pycache__/
*.so

# Distribution / packaging
tmp/
.Python
build/
develop-eggs/
Expand Down
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,24 @@ same reason - default setup cannot load a pack.
items beside the `shots` list change. Gallery names for a template's runs now read
`<template>/<run id>/final/<file>`, so an `output:` reference built from one carries the
`final/` segment
- **A task argument's numeric domain is declared, not inferred** — a task
command's argument schema is its implementation's signature, which says
nothing about range, so `dw/task_domains.py` declares the domains that are
not a judgement call (a count or a rate above zero, an offset zero or above)
and `validation_errors` reports a literal outside one at its JSON path. The
commands check the same table at run time (`check_arguments`), which is the
only layer that sees a value arriving from a `variable:` or an earlier step.
Both defects it closed were silent successes rather than failures:
`slice_audio(num_frames=-10)` reached Python's slice semantics and returned
the track minus its last ten frames (#139), and
`resample_audio(target_sample_rate=0)` left the samples alone and then hit
`DEFAULT_AUDIO_SAMPLE_RATE` at save, writing a 44100 Hz header over a 32 kHz
waveform (#140) — which is why `_as_track` now refuses a non-positive rate
outright: relabelling a waveform changes its speed and pitch, and the save
default makes a missing rate look like a valid one. Adding a domain means
one entry in the table; `tests/test_task_domains.py` pins every entry to a
real parameter of a real command so a rename cannot leave one checking
nothing
- **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. This is why "Run again" on a seeded workflow finishes instantly and generates nothing - the job page says so when every step was reused, and `POST /api/jobs/{id}/rerun` with `{"new_seed": true}` (MCP `rerun_job(new_seed=True)`) draws a fresh seed into the workflow's seed variable, which is the way to get a different image
Expand Down
32 changes: 23 additions & 9 deletions docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,15 @@ when no single workflow covers it.
| `get_server_info()` | — | What this installation can do and where it keeps things: `device` (the accelerator a run will use), `version`, the `workspace` this session is working in and the workflow/asset/output/prompt `directories` of *that* workspace, the bind address and port, whether a token is required, and whether MCP is mounted. Check the device before authoring - a CUDA-only choice (bitsandbytes, `torch.compile`, flash attention) is not available on an `mps` or `cpu` server |
| `list_jobs(limit=20, status=None, workspace=None)` | optional `limit` (newest N), `status` (one state or a comma-separated set of `queued`, `running`, `succeeded`, `failed`, `cancelled`), `workspace` | List queued, running and recent jobs, **newest first**. Bounded by default: the unbounded listing was over a client's tool-result limit on a server with a few months of history, which made it a tool that could not be called at all. `total` says how many matched and `truncated`/`next` say so when the answer was cut - raise `limit` or narrow with `status`. Without `workspace`, a named workspace lists its own jobs and the default one lists every job the server holds |
| `list_gallery(limit=50, subfolder=None, workspace=None)` | `limit`, `subfolder`, `workspace` | List generated output files, newest first. A name is `<workflow>/<run id>/<file>`, where `<file>` may sit in the subfolder the step chose (`final/episode.mp4`); each entry carries `folder` (the workflow) and `subfolder` (by convention `final` or `intermediate`, `''` when the step chose none, any path the workflow wrote otherwise), and `subfolder="final"` lists only deliverables. Each entry also carries a ready-made `url`, already scoped to the workspace that made it - a hand-built `/outputs/<name>` URL 404s for anything but the default workspace. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it |
| `get_gallery_metadata(name, envelope=False, workspace=None)` | `name`, `workspace` | Get the metadata embedded in a generated file: the exact workflow and arguments that produced it, and, for audio/video, a `media` block (duration, rate, channels, fps, size, peak/mean dBFS). `envelope=true` adds `media.envelope` — `rms_dbfs` and `peak_dbfs` one entry per second — which is what locates something in a track rather than measuring the whole of it. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it |
| `get_gallery_metadata(name, envelope=False, workspace=None)` | `name`, `workspace` | Get the metadata embedded in a generated file — or, when `name` is an `asset:` reference, what an *input* asset holds (`source` says which; `job` is null for an asset). Reading an input's duration, frame count, fps and sample rate before a run is how a caller learns the `total_frames`, `fps` and `sample_rate` a workflow expects it to supply: the exact workflow and arguments that produced it, and, for audio/video, a `media` block (duration, rate, channels, fps, size, peak/mean dBFS). `envelope=true` adds `media.envelope` — `rms_dbfs` and `peak_dbfs` one entry per second — which is what locates something in a track rather than measuring the whole of it. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it |

### Media

| Tool | Arguments | Purpose |
| --- | --- | --- |
| `get_output_image(name, max_dimension=768, workspace=None)` | `name`, `max_dimension`, `workspace` | Look at a generated image, downscaled to `max_dimension` on its longest side. Returns the image plus a text part reporting `original_size`, `returned_size` and `bytes`, so a downscale is never silent. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it |
| `get_output_text(name, max_characters=20000, workspace=None)` | `name`, `max_characters`, `workspace` | Read a text output — a prompt enhancement, or any step whose result is `text/plain` or JSON. Reports the file's real length and whether it was truncated. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it |
| `download_output(name, destination=None, overwrite=False, workspace=None)` | `name`, `destination`, `overwrite`, `workspace` | Save one output file to local disk, of any content type. `destination` may be a full path, a directory, or omitted to save under the output's own name in the current working directory; `~` expands and missing parent directories are created. `overwrite=True` is required to replace a file already at the resolved path. Returns nothing to the conversation but where the file landed — unlike the other media tools, the point is a file on disk, not a payload in context. Writes on the machine running the MCP server - over `dw.serve --mcp` that is the GPU box. A write that fails there (a path that exists only on the client, for instance) comes back as an error naming the server-side write and the client-side alternatives, not as an anonymous tool failure. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it |
| `download_output(name, destination=None, overwrite=False, workspace=None)` | `name`, `destination`, `overwrite`, `workspace` | Save one output file to local disk, of any content type. `destination` may be a full path, a directory, or omitted to save under the output's own name in the current working directory; `~` expands and missing parent directories are created. `overwrite=True` is required to replace a file already at the resolved path. Over a `dw.serve --mcp` endpoint the file lands on the server, so the destination is confined to that workspace and a relative one is joined onto it. Returns nothing to the conversation but where the file landed — unlike the other media tools, the point is a file on disk, not a payload in context. Writes on the machine running the MCP server - over `dw.serve --mcp` that is the GPU box. A write that fails there (a path that exists only on the client, for instance) comes back as an error naming the server-side write and the client-side alternatives, not as an anonymous tool failure. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it |
| `delete_output(name, workspace=None)` | `name`, `workspace` | Permanently remove one generated file from the output directory. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it |

### Authoring, assets and workspaces
Expand All @@ -249,7 +249,7 @@ The session starts in `default` and stays there unless it is told otherwise.

| Tool | Arguments | Purpose |
| --- | --- | --- |
| `validate_workflow(workflow=None, name=None, workspace=None, arguments=None)` | exactly one of `workflow` (inline definition) or `name` (a stored workflow, as `list_workflows` reports it), optional `workspace`, optional `arguments` | Check a workflow against the schema and against real pipeline signatures. Free and instant. Validating by name uses the workflow file's own directory as the base directory, so it sees what a run would. Returns every schema violation in `errors`, each with the JSON path it sits at, so a draft is fixed in one pass, and a `previous_result:` that names no earlier step is one of them. `warnings` covers what still runs but is probably wrong - a signature mismatch, and, for a list-driven variable, an entry key no step reads, at the entry's path. `workspace` names the workspace for this one call without switching the session to it - use it to pin a job whose `output:` or `asset:` references live in a workspace other than the session's. Pass the same `arguments` you will pass to `run_workflow` and they are checked too - an undeclared or renamed variable name, a value that will not coerce to the declared type, and an `asset:`, `prompt:` or `output:` reference that names nothing this workspace can reach, each reported at `arguments.<name>`. `checked_arguments` lists what was covered, so a `valid: true` about the stored defaults cannot be mistaken for one about your values. `run_workflow` makes the same check and refuses a bad argument rather than queuing a job that fails on its first step. A valid answer carries `plan` - the fingerprint, step count, list lengths, `downloads_required` and `estimate` (with `basis`) for the arguments given; quote from it |
| `validate_workflow(workflow=None, name=None, workspace=None, arguments=None)` | exactly one of `workflow` (inline definition) or `name` (a stored workflow, as `list_workflows` reports it), optional `workspace`, optional `arguments` | Check a workflow against the schema and against real pipeline signatures. Free and instant. Validating by name uses the workflow file's own directory as the base directory, so it sees what a run would. Returns every schema violation in `errors`, each with the JSON path it sits at, so a draft is fixed in one pass, and a `previous_result:` that names no earlier step is one of them. `warnings` covers what still runs but is probably wrong - a signature mismatch, and, for a list-driven variable, an entry key no step reads, at the entry's path. `workspace` names the workspace for this one call without switching the session to it - use it to pin a job whose `output:` or `asset:` references live in a workspace other than the session's. Pass the same `arguments` you will pass to `run_workflow` and they are checked too - an undeclared or renamed variable name, a value that will not coerce to the declared type, and an `asset:`, `prompt:` or `output:` reference that names nothing this workspace can reach, each reported at `arguments.<name>`. `checked_arguments` lists what was covered, so a `valid: true` about the stored defaults cannot be mistaken for one about your values. A reference set the model would refuse - too many images, videos or audio clips, or, for MiniMax-H3, audio as the only reference - is an error here too, rather than a failure minutes into a run you acknowledged. `run_workflow` makes the same check and refuses a bad argument rather than queuing a job that fails on its first step. A valid answer carries `plan` - the fingerprint, step count, list lengths, `downloads_required` and `estimate` (with `basis`) for the arguments given; quote from it |
| `list_workspaces()` | — | The server's workspaces and which one this session is using. Each has its own workflows, assets and outputs; the prompt library is shared by all of them |
| `use_workspace(name)` | `name` | Work in that workspace for the rest of the session - every later call reads and writes there. This is how to keep your work out of another agent's namespace rather than sharing the default one. Checked against the server, so a typo fails here rather than scoping every later call to nothing |
| `create_workspace(name, use=False)` | `name`, `use` | Create a workspace. Pass use=true to switch this session to it as well; otherwise the session stays where it was and the result says so |
Expand Down Expand Up @@ -412,13 +412,22 @@ localhost binding, no auth, `Origin` header checks, and path confinement in
Nothing under `dw_mcp/` re-implements or loosens that confinement; it is
purely a client of the same validated endpoints the web UI uses - except for
`download_output`, the one tool that writes a local file for the MCP client
rather than only reading through the API. It may write anywhere the
client's own filesystem lets it (a full path, a directory, or the current
working directory by default, `~` expanded), the way a shell redirect
would for the same user; a `..` path segment in `destination` is refused,
and an existing file is left alone unless the caller passes
rather than only reading through the API. Over a stdio `dw-mcp` it may write
anywhere the client's own filesystem lets it (a full path, a directory, or
the current working directory by default, `~` expanded), the way a shell
redirect would for the same user; a `..` path segment in `destination` is
refused, and an existing file is left alone unless the caller passes
`overwrite=True`.

Over `dw.serve --mcp` the write happens **on the server**, and there the
destination is confined to that workspace: an absolute or `~` path outside
it is refused, and a relative one is joined onto the workspace rather than
onto whatever the server process's working directory happens to be. The
transport is what distinguishes the two - on stdio "local disk" is genuinely
the caller's own machine, over HTTP it is the operator's. Confinement is on
the resolved real path, not a substring test, because an absolute path needs
no `..` to reach anywhere the server can write.

`dw-mcp` may be pointed at a `dw.serve` on another machine only when that
server was started with a token, and the same token is passed here
(`--token` / `DW_API_TOKEN`); it refuses to start otherwise. The token is
Expand Down Expand Up @@ -450,8 +459,13 @@ default) for any server an MCP client can reach.
runs on. Over `dw.serve --mcp` that is the GPU box, so a file sitting on
the client's laptop is not reachable that way - put it on the server, or
give the workflow a URL (the arguments that take a path take a URL too).
On a `--mcp` endpoint `file_path` is also *confined* to the directories the
server works in (its workspace, workflows, assets, outputs and prompts), and
the refusal comes before the file is looked for, so the tool cannot be used
to probe which paths exist on the box (#138).
`download_output` has the same asymmetry in the other direction: on a
`--mcp` endpoint it writes on the GPU box, not the client's machine.
`--mcp` endpoint it writes on the GPU box, not the client's machine, and is
confined to the workspace there.
- **Prompts are not per-workspace.** Switching workspaces changes which
workflows, assets and outputs the session sees; the prompt library is one
library shared by all of them, because `prompt:` is shared by reference.
Expand Down
Loading