diff --git a/docs/MCP.md b/docs/MCP.md index d8f93e3..7ae19c9 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -89,6 +89,8 @@ If `dw.serve` runs on another machine with `--mcp` (see claude mcp add --transport http dw http://:8765/mcp \ --header "Authorization: Bearer " +The same token fetches generated files: see step 7 of `The loop` in [WORKFLOW_GUIDE.md](WORKFLOW_GUIDE.md#the-loop). + Nothing from this repository is installed on the client. The stdio setup below is for a machine that has its own `dw` install, and also works against a remote `--url` with `--token`. @@ -247,10 +249,10 @@ 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. Returns every schema violation in `errors`, each with the JSON path it sits at, so a draft is fixed in one pass | +| `validate_workflow(workflow=None, name=None, workspace=None)` | exactly one of `workflow` (inline definition) or `name` (a stored workflow, as `list_workflows` reports it), optional `workspace` | 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. `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 | | `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 | +| `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 | | `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 | @@ -279,12 +281,12 @@ references written in the same session. | Tool | Arguments | Purpose | | --- | --- | --- | -| `run_workflow(workflow_path=None, inline_workflow=None, arguments=None, acknowledged_cost=False)` | exactly one of `workflow_path` (a catalog name from `list_workflows`, with or without `.json`, or a path to a workflow file on the server) or `inline_workflow`, optional `arguments`, `acknowledged_cost` | Queue a workflow for generation. Returns as soon as the job is queued | +| `run_workflow(workflow_path=None, inline_workflow=None, arguments=None, acknowledged_cost=False, workspace=None)` | exactly one of `workflow_path` (a catalog name from `list_workflows`, with or without `.json`, or a path to a workflow file on the server) or `inline_workflow`, optional `arguments`, `acknowledged_cost`, `workspace` | Queue a workflow for generation. Returns as soon as the job is queued. `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 | | `get_job(job_id)` | `job_id` | Get a job's status, warnings, output manifest, error and traceback | | `get_job_workflow(job_id)` | `job_id` | The workflow the job actually ran. `realized: true` means every mutable input is pinned (arguments, seed, prompts, `output:latest`); `false` means the job predates run tracking and this is the definition as submitted. Pass it to `save_workflow` to keep it under a name | -| `export_job(job_id, overwrite=False)` | `job_id`, `overwrite` | Gather one finished job into `/exports//` on the server: the realized workflow, the run's manifest, the job row, a README, and copies of the assets, earlier-run inputs and outputs. Returns the directory, a zip URL, the file list with sizes and the three JSON files inline. **The directory is on the machine running the server**, like `download_output`'s destination - fetch the zip URL and unpack it into `exports/` under the session's working directory (a deliverable, not a temp file); the archive already unpacks into one folder named after the job id | +| `export_job(job_id, overwrite=False)` | `job_id`, `overwrite` | Gather one finished job into `/exports//` on the server: the realized workflow, the run's manifest, the job row, a README, and copies of the assets, earlier-run inputs and outputs. Returns the directory, a zip URL, the file list with sizes and the total. The three JSON files are in the zip, not repeated here - get_job_workflow and get_job serve them individually. **The directory is on the machine running the server**, like `download_output`'s destination - fetch the zip URL and unpack it into `exports/` under the session's working directory (a deliverable, not a temp file); the archive already unpacks into one folder named after the job id | | `get_job_events(job_id, after=-1, limit=200)` | `job_id`, `after`, `limit` | Get a page of a job's progress events | -| `wait_for_job(job_id, timeout_seconds=20)` | `job_id`, `timeout_seconds` | Block until a job reaches a terminal status, or `timeout_seconds` elapses (capped well under a generation's real runtime). Use instead of hand-polling `get_job`/`get_job_events` in a loop; if it returns `still_running: true`, call it again | +| `wait_for_job(job_id, timeout_seconds=20)` | `job_id`, `timeout_seconds` | Block until a job reaches a terminal status, or `timeout_seconds` elapses (capped well under a generation's real runtime). Use instead of hand-polling `get_job`/`get_job_events` in a loop; if it returns `still_running: true`, call it again. Returns a slim job - status, warnings, error, and the manifest once finished - without the arguments; `get_job` has those | | `cancel_job(job_id)` | `job_id` | Ask a queued or running job to stop | | `rerun_job(job_id, acknowledged_cost=False, new_seed=False)` | `job_id`, `acknowledged_cost`, `new_seed` | Queue a fresh job from a previous job's stored specification. Costs GPU time, so it passes the same gate as `run_workflow`. `new_seed=true` draws a fresh seed into the workflow's seed variable — without it a seeded workflow's rerun repeats its arguments exactly and the step cache serves the whole run from the earlier one's files (`reused: true`), generating nothing. `get_job_workflow`'s `seed_variable` says whether there is one | | `move_job(job_id, direction)` | `job_id`, `direction` (`up`\|`down`\|`front`\|`back`) | Reorder a queued job | diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index ee2f072..1613d43 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -343,6 +343,17 @@ signal to restructure the workflow, not to add another reference. `still_running: true`. 6. `get_output_image` to look at what was actually made, and say whether it answers the request. Nothing before this step establishes that it does. +7. Getting the files to the user's machine. `download_output` and `export_job` + write on the machine running `dw.serve`, which over a remote `--mcp` + endpoint is the GPU box. The last mile of every deliverable is the `url` + each `list_gallery` entry carries (or `export_job`'s `zip_url`), fetched + with the same bearer token the MCP connection uses: + + curl -H "Authorization: Bearer $DW_API_TOKEN" \ + -o exports/still.png "http://:8765/outputs/ltx2/Gyre/20260910-.../still.png" + + Put the result under `exports/` in the session's working directory - it is + the user's deliverable, not a temporary file. ### Keeping a set consistent diff --git a/dw/server/catalog_shape.py b/dw/server/catalog_shape.py index 2a706fa..b9a1cbd 100644 --- a/dw/server/catalog_shape.py +++ b/dw/server/catalog_shape.py @@ -175,8 +175,7 @@ def _cuts_together(steps): sources |= { item for item in videos - if isinstance(item, str) - and not item.startswith("previous_result:") + if isinstance(item, str) and not item.startswith("previous_result:") } if len(sources) >= 2: return True diff --git a/dw_mcp/CLAUDE.md b/dw_mcp/CLAUDE.md index fe80140..719fcc5 100644 --- a/dw_mcp/CLAUDE.md +++ b/dw_mcp/CLAUDE.md @@ -17,6 +17,10 @@ means something else to the engine), `use_workspace` to switch, and `DwClient._scoped` adds the selector to every request's query string so no handler carries a workspace parameter. The default sends nothing, so a session that never chooses looks exactly like one from before workspaces. +`run_workflow` and `validate_workflow` are the two exceptions: each takes an +optional per-call `workspace` that `_scoped`'s `setdefault` lets win over the +session's, so one call can be pinned to a workspace other than the session's +without switching it. `get_server_info` (`/api/server`) is the capability call: the device a run will use, the dw version, the workspace and the workflow/output/prompt/asset directories, which is what tells an diff --git a/dw_mcp/authoring.py b/dw_mcp/authoring.py index aff2311..9e72db2 100644 --- a/dw_mcp/authoring.py +++ b/dw_mcp/authoring.py @@ -9,7 +9,7 @@ from dw_mcp.client import DwApiError, api_path -def validate_workflow(client, workflow=None, name=None): +def validate_workflow(client, workflow=None, name=None, workspace=None): """Schema- and signature-check a workflow without queuing anything. This is free (no GPU work) and should be called before any run or save. Give either an inline definition or the name of a stored one, as @@ -19,11 +19,12 @@ def validate_workflow(client, workflow=None, name=None): "Provide exactly one of `workflow` (an inline definition) or " "`name` (a stored workflow)." ) + params = {"workspace": workspace} if workspace else None if workflow is None: # The server resolves the name against its own workflow directory, # so validation sees the same base directory a run would - return client.post_json("/api/validate", {"workflow_path": name}) - return client.post_json("/api/validate", {"workflow": workflow}) + return client.post_json("/api/validate", {"workflow_path": name}, params=params) + return client.post_json("/api/validate", {"workflow": workflow}, params=params) def save_workflow(client, name, workflow): diff --git a/dw_mcp/diagnose.py b/dw_mcp/diagnose.py index 8ae645f..46f7f5d 100644 --- a/dw_mcp/diagnose.py +++ b/dw_mcp/diagnose.py @@ -36,6 +36,7 @@ def run_workflow( inline_workflow=None, arguments=None, acknowledged_cost=False, + workspace=None, ): """Queue a workflow. `workflow_path` is either a catalog name from `list_workflows` or a path to a workflow file on the server. Returns as @@ -56,7 +57,11 @@ def run_workflow( payload["workflow"] = inline_workflow # base_dir is deliberately absent: it decides where an inline workflow's # relative paths resolve, and the MCP surface does not hand that out - job = client.post_json("/api/jobs", payload) + # A named workspace pins this one job rather than the session: a + # restarted session forgets use_workspace, and a job that resolves + # output: references in the wrong root fails after it was queued + params = {"workspace": workspace} if workspace else None + job = client.post_json("/api/jobs", payload, params=params) return { "job_id": job.get("id"), "status": job.get("status"), @@ -100,6 +105,35 @@ def get_job_events(client, job_id, after=-1, limit=200): ) +_SLIM_KEYS = ( + "id", + "workflow_name", + "status", + "created_at", + "started_at", + "finished_at", + "workspace", + "run_id", + "queue_position", + "warnings", + "error", + "event_count", +) + + +def slim_job(job): + """A job row without its arguments and traceback - what a poll needs. + + The arguments of an H3 workflow are thousands of tokens of prompt text, + repeated on every poll of a long render; get_job serves them once. The + manifest is kept only once the job is terminal, when it names files. + """ + slim = {key: job.get(key) for key in _SLIM_KEYS if key in job} + if job.get("status") in TERMINAL_STATUSES: + slim["manifest"] = job.get("manifest") + return slim + + def wait_for_job(client, job_id, timeout_seconds=20): """Block until a job reaches a terminal status, or `timeout_seconds` elapses - a bounded alternative to polling `get_job`/`get_job_events` by @@ -111,7 +145,9 @@ def wait_for_job(client, job_id, timeout_seconds=20): so this never blocks past a budget kept well under that. Returns as soon as the job's status is succeeded, failed or cancelled. If the timeout elapses first, returns the job's last-seen status with - `still_running: true` instead of hanging - call again to keep waiting.""" + `still_running: true` instead of hanging - call again to keep waiting. + Returns a slim job - status, warnings, error, and the manifest once + finished - without the arguments; get_job has those.""" timeout_seconds = max(0.0, min(float(timeout_seconds), MAX_WAIT_SECONDS)) deadline = time.monotonic() + timeout_seconds while True: @@ -122,7 +158,9 @@ def wait_for_job(client, job_id, timeout_seconds=20): "job_id": job_id, "status": status, "still_running": False, - "job": job, + "job": slim_job(job), + "next": "get_job(job_id) for the arguments and traceback, " + "get_job_workflow(job_id) for the realized workflow.", } remaining = deadline - time.monotonic() if remaining <= 0: @@ -130,7 +168,7 @@ def wait_for_job(client, job_id, timeout_seconds=20): "job_id": job_id, "status": status, "still_running": True, - "job": job, + "job": slim_job(job), "next": "Call wait_for_job again, or get_job_events for " "incremental progress.", } diff --git a/dw_mcp/exports.py b/dw_mcp/exports.py index 4d5ec9e..e089707 100644 --- a/dw_mcp/exports.py +++ b/dw_mcp/exports.py @@ -16,9 +16,9 @@ def export_job(client, job_id, overwrite=False): file rather than linking them, so a video job's export costs its size again on the server's disk; `total_bytes` in the result reports what was copied. Returns the directory, the zip URL, the file list with - sizes and the total, and the three JSON files inline. The directory is - on the server machine, not this one - use the zip URL to fetch it - elsewhere.""" + sizes and the total. The three JSON files are in the zip, not repeated + here. The directory is on the server machine, not this one - use the + zip URL to fetch it elsewhere.""" body = client.post_json( api_path("api", "jobs", job_id, "export"), params={"overwrite": "true" if overwrite else "false"}, @@ -32,13 +32,12 @@ def export_job(client, job_id, overwrite=False): "files": body.get("files") or [], "total_bytes": body.get("total_bytes"), "missing": body.get("missing") or [], - "workflow": body.get("workflow"), - "manifest": body.get("manifest"), - "job": body.get("job"), "next": "The directory is on the server. To give the user the files, " "fetch zip_url and unpack it into exports/ under the session's " "working directory - it is the user's deliverable, not a temporary " "file, so not a scratch or temp directory. The archive already " "unpacks into one folder named after the job id; do not create " - "that folder first or the id is doubled in the path.", + "that folder first or the id is doubled in the path. workflow.json, " + "manifest.json and job.json are inside it - they are not repeated " + "here; get_job_workflow and get_job serve them individually.", } diff --git a/dw_mcp/server.py b/dw_mcp/server.py index 533b6c1..071a891 100644 --- a/dw_mcp/server.py +++ b/dw_mcp/server.py @@ -457,13 +457,14 @@ def use_workspace(name: str) -> dict: another agent's namespace, rather than sharing the default one.""" return workspaces.use_workspace(client, name) - def create_workspace(name: str) -> dict: + def create_workspace(name: str, use: bool = False) -> dict: """Create a workspace on the server. It gets its own workflows, assets and outputs and shares the one prompt library. The name is a single path segment and cannot be one of the reserved folder names - (workflows, prompts, assets, outputs). Creating does not switch to - it: call use_workspace after.""" - return workspaces.create_workspace(client, name) + (workflows, prompts, assets, outputs). Pass use=true to switch this + session to it as well; otherwise the session stays where it was and + the result says so.""" + return workspaces.create_workspace(client, name, use=use) def delete_workspace(name: str, acknowledged_cost: bool = False) -> dict: """Permanently delete a workspace and every workflow, asset and @@ -481,14 +482,21 @@ def delete_workspace(name: str, acknowledged_cost: bool = False) -> dict: # ----------------------------------------------------------- authoring def validate_workflow( - workflow: dict | None = None, name: str | None = None + workflow: dict | None = None, + name: str | None = None, + workspace: str | None = None, ) -> dict: """Check a workflow against the schema and against real pipeline signatures. Free and instant - always run this before run_workflow. Give exactly one of `workflow` or `name` - `name` being a stored workflow as `list_workflows` reports it. Every schema error comes - back at once, each with its JSON path.""" - return authoring.validate_workflow(client, workflow=workflow, name=name) + back at once, each with its JSON 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.""" + return authoring.validate_workflow( + client, workflow=workflow, name=name, workspace=workspace + ) def save_workflow(name: str, workflow: dict) -> dict: """Save a workflow to the server's writable workflow directory, @@ -584,6 +592,7 @@ def run_workflow( inline_workflow: dict | None = None, arguments: dict | None = None, acknowledged_cost: bool = False, + workspace: str | None = None, ) -> dict: """Queue a workflow for generation. THIS COSTS GPU TIME: a run occupies the machine for minutes and the engine runs one job at a @@ -595,13 +604,17 @@ def run_workflow( without .json, or a path on the server - or `inline_workflow`, a full definition for a request nothing stored covers. `arguments` overrides the workflow's variables by name, which is how one stored - workflow serves many requests without being edited or copied.""" + workflow serves many requests without being edited or copied. + `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.""" return diagnose.run_workflow( client, workflow_path=workflow_path, inline_workflow=inline_workflow, arguments=arguments, acknowledged_cost=acknowledged_cost, + workspace=workspace, ) def get_job(job_id: str) -> dict: @@ -637,7 +650,9 @@ def wait_for_job(job_id: str, timeout_seconds: int = 20) -> dict: first - returns its current status with still_running: true so you can call again. Does not queue anything, so no acknowledged_cost. timeout_seconds is capped well under a generation's real runtime; - call it repeatedly for a long job.""" + call it repeatedly for a long job. Returns a slim job - status, + warnings, error, and the manifest once finished - without the + arguments; get_job has those.""" return diagnose.wait_for_job(client, job_id, timeout_seconds=timeout_seconds) def cancel_job(job_id: str) -> dict: @@ -680,14 +695,15 @@ def export_job(job_id: str, overwrite: bool = False) -> dict: rather than linking them, so a video job's export costs its size again on the server's disk; `total_bytes` in the result reports what was copied. Returns the directory, a zip URL, the file list - with sizes and the total, and the three JSON files inline. THE - DIRECTORY IS ON THE MACHINE RUNNING THE SERVER, not on yours. To give - the user the files, fetch the zip URL and unpack it into exports/ - under the session's working directory - it is the user's deliverable, - not a temp file; the archive already unpacks into one folder named - after the job id, so do not create that folder first. Refuses a job - that is still running; refuses an existing export unless - overwrite=true.""" + with sizes and the total. The three JSON files are in the zip, not + repeated here - get_job_workflow and get_job serve them individually. + THE DIRECTORY IS ON THE MACHINE RUNNING THE SERVER, not on yours. To + give the user the files, fetch the zip URL and unpack it into + exports/ under the session's working directory - it is the user's + deliverable, not a temp file; the archive already unpacks into one + folder named after the job id, so do not create that folder first. + Refuses a job that is still running; refuses an existing export + unless overwrite=true.""" return exports.export_job(client, job_id, overwrite=overwrite) tool(get_job, READ_ONLY) diff --git a/dw_mcp/workspaces.py b/dw_mcp/workspaces.py index 8b9b362..0c6368b 100644 --- a/dw_mcp/workspaces.py +++ b/dw_mcp/workspaces.py @@ -46,11 +46,26 @@ def use_workspace(client, name): return {"current": name, "workspaces": known or [name]} -def create_workspace(client, name): +def create_workspace(client, name, use=False): """Make a new workspace on the server. It gets its own workflows, assets - and outputs, and shares the server's one prompt library. Creating it does - not switch to it - call use_workspace for that.""" - return client.post_json("/api/workspaces", {"name": name}) + and outputs, and shares the server's one prompt library. Creating it + does not switch to it unless `use` is true - the natural + create-then-run sequence otherwise runs in the workspace the session + was already in, and the result says which that is.""" + body = client.post_json("/api/workspaces", {"name": name}) + if use: + use_workspace(client, name) + return { + **body, + "current": client.workspace, + "next": ( + f"This session now works in '{client.workspace}'." + if use + else f"This session still works in '{client.workspace}' - call " + f"use_workspace('{name}') or pass workspace='{name}' to " + f"run_workflow before running anything meant for it." + ), + } def delete_workspace(client, name, acknowledged_cost=False): diff --git a/tests/test_catalog_structure.py b/tests/test_catalog_structure.py index 01fb389..9f08baa 100644 --- a/tests/test_catalog_structure.py +++ b/tests/test_catalog_structure.py @@ -424,3 +424,21 @@ def test_every_readme_link_resolves(path): assert os.path.exists( os.path.join(base, target) ), f"{path} links to {target}, which does not exist" + + +COSTED = { + "workflows/templates/minimax/music-video.json": 35, + "workflows/templates/minimax/dialogue-short.json": 42, + "workflows/templates/assemble-and-score.json": 0.2, + "workflows/templates/dissolve-between-shots.json": 0.2, +} + + +@pytest.mark.parametrize("path,minutes", sorted(COSTED.items())) +def test_the_cut_templates_quote_a_measured_cost(path, minutes): + """Measured on an RTX 3090, 2026-09-10; without a figure an agent + cannot quote a price before spending 40 minutes of GPU.""" + definition = json.load(open(os.path.join(REPO_ROOT, path), encoding="utf-8")) + entry = definition["cost"][0] + assert entry["name"] == "RTX 3090" + assert entry["minutes"] == minutes diff --git a/tests/test_introspection.py b/tests/test_introspection.py index fc90672..eb4f102 100644 --- a/tests/test_introspection.py +++ b/tests/test_introspection.py @@ -187,17 +187,20 @@ def test_a_crossfade_with_nothing_trimmed_is_warned_about(): assert "crossfade_ms" in warnings[0] and "trim_frames" in warnings[0] assert "audio_bleed_ms" in warnings[0] - warnings = workflow_argument_warnings( - _concat_step(crossfade_ms=200, trim_frames=0) - ) + warnings = workflow_argument_warnings(_concat_step(crossfade_ms=200, trim_frames=0)) assert len(warnings) == 1 def test_a_crossfade_over_a_trim_is_not_warned_about(): - assert workflow_argument_warnings(_concat_step(crossfade_ms=200, trim_frames=1)) == [] + assert ( + workflow_argument_warnings(_concat_step(crossfade_ms=200, trim_frames=1)) == [] + ) assert workflow_argument_warnings(_concat_step(trim_frames=0)) == [] assert workflow_argument_warnings(_concat_step(crossfade_ms=0)) == [] # A referenced trim is unknown until the run - do not guess - assert workflow_argument_warnings( - _concat_step(crossfade_ms=200, trim_frames="variable:trim") - ) == [] + assert ( + workflow_argument_warnings( + _concat_step(crossfade_ms=200, trim_frames="variable:trim") + ) + == [] + ) diff --git a/tests/test_mcp_authoring.py b/tests/test_mcp_authoring.py index f0c869c..e363b38 100644 --- a/tests/test_mcp_authoring.py +++ b/tests/test_mcp_authoring.py @@ -7,7 +7,7 @@ import pytest from dw_mcp import authoring -from dw_mcp.client import DwApiError, DwClient +from dw_mcp.client import DEFAULT_WORKSPACE, DwApiError, DwClient WORKFLOW = {"id": "w", "steps": []} @@ -27,6 +27,18 @@ def handler(request): return DwClient(transport=httpx.MockTransport(handler)), seen +def scripted_with_params(routes): + seen = [] + + def handler(request): + key = (request.method, request.url.path) + seen.append({"key": key, "params": dict(request.url.params)}) + status, body = routes.get(key, (404, {"detail": f"unrouted {key}"})) + return httpx.Response(status, json=body) + + return DwClient(transport=httpx.MockTransport(handler)), seen + + def test_validate_posts_an_inline_workflow(): client, seen = scripted( { @@ -99,6 +111,17 @@ def test_validate_returns_an_invalid_verdict_rather_than_raising(): assert "steps" in result["error"] +def test_validate_can_name_a_workspace_for_one_request(): + client, seen = scripted_with_params( + {("POST", "/api/validate"): (200, {"valid": True})} + ) + + authoring.validate_workflow(client, name="w", workspace="dialogue-short") + + assert seen[0]["params"]["workspace"] == "dialogue-short" + assert client.workspace == DEFAULT_WORKSPACE + + def test_save_puts_the_definition_under_its_name(): body_seen = {} diff --git a/tests/test_mcp_diagnose.py b/tests/test_mcp_diagnose.py index cc2aee4..2c9926b 100644 --- a/tests/test_mcp_diagnose.py +++ b/tests/test_mcp_diagnose.py @@ -334,6 +334,63 @@ def test_wait_for_job_does_not_require_acknowledged_cost(): assert result["status"] == "succeeded" +FAT_JOB = { + "id": "job-1", + "workflow_name": "minimax/dialogue-short", + "status": "running", + "created_at": 1.0, + "started_at": 2.0, + "finished_at": None, + "workspace": "default", + "run_id": None, + "arguments": {"shot_1_cold_open": "x" * 6000}, + "warnings": [], + "manifest": None, + "error": None, + "traceback": None, + "event_count": 12, + "run_dir": None, +} + + +def test_wait_for_job_does_not_echo_the_arguments_on_every_poll(monkeypatch): + """An H3 workflow's arguments are 4-6k tokens of prompt text; a + 45-minute render is polled many times. They are get_job's to serve, + once.""" + monkeypatch.setattr(diagnose, "MAX_WAIT_SECONDS", 0) + client, _ = scripted({("GET", "/api/jobs/job-1"): (200, FAT_JOB)}) + + result = diagnose.wait_for_job(client, "job-1", timeout_seconds=0) + + assert result["still_running"] is True + assert "arguments" not in result["job"] + assert "traceback" not in result["job"] + assert result["job"]["status"] == "running" + assert result["job"]["event_count"] == 12 + + +def test_wait_for_job_keeps_the_manifest_and_error_once_terminal(monkeypatch): + done = {**FAT_JOB, "status": "failed", "manifest": {"steps": []}, "error": "boom"} + client, _ = scripted({("GET", "/api/jobs/job-1"): (200, done)}) + + result = diagnose.wait_for_job(client, "job-1") + + assert result["job"]["manifest"] == {"steps": []} + assert result["job"]["error"] == "boom" + assert "arguments" not in result["job"] + assert "get_job" in result["next"] + + +def test_wait_for_job_reports_queue_position_for_a_still_queued_job(monkeypatch): + monkeypatch.setattr(diagnose, "MAX_WAIT_SECONDS", 0) + queued = {**FAT_JOB, "status": "queued", "queue_position": 2} + client, _ = scripted({("GET", "/api/jobs/job-1"): (200, queued)}) + + result = diagnose.wait_for_job(client, "job-1", timeout_seconds=0) + + assert result["job"]["queue_position"] == 2 + + class TestGetJobWorkflow: def test_a_realized_workflow_comes_back_with_the_flag_set(self): client, seen = scripted( @@ -387,3 +444,27 @@ def test_an_unknown_job_raises_the_client_error(self): with pytest.raises(DwApiError): diagnose.get_job_workflow(client, "nope") + + +def test_run_pins_a_job_to_a_named_workspace_without_switching(): + """A session restart resets the session workspace to default, and a + concat job then resolved output: references against the wrong root. + A run can name its workspace itself, for that one request.""" + client, seen = submitting() + client.workspace = "music-video" + + diagnose.run_workflow( + client, workflow_path="w", acknowledged_cost=True, workspace="dialogue-short" + ) + + assert seen[0]["params"]["workspace"] == "dialogue-short" + assert client.workspace == "music-video" + + +def test_run_sends_the_session_workspace_when_none_is_named(): + client, seen = submitting() + client.workspace = "music-video" + + diagnose.run_workflow(client, workflow_path="w", acknowledged_cost=True) + + assert seen[0]["params"]["workspace"] == "music-video" diff --git a/tests/test_mcp_exports.py b/tests/test_mcp_exports.py index cf1876e..0c5db7c 100644 --- a/tests/test_mcp_exports.py +++ b/tests/test_mcp_exports.py @@ -57,14 +57,19 @@ def test_it_returns_the_directory_the_zip_and_the_file_list(): assert len(seen) == 1 -def test_the_three_json_files_come_back_inline(): +def test_the_three_json_files_stay_in_the_zip(): + """A music-video export inlined 55 KB of workflow, manifest and job row + that the zip already carries and get_job_workflow / get_job already + serve - it blew past the tool output limit. The listing says they are + there; the bytes are not repeated.""" client, _ = exporting() result = exports.export_job(client, "job-1") - assert result["workflow"] == {"id": "w", "steps": []} - assert result["manifest"]["run_id"] == "20260908-120000-abcdef01" - assert result["job"]["status"] == "succeeded" + assert "workflow" not in result + assert "manifest" not in result + assert "job" not in result + assert "get_job_workflow" in result["next"] def test_it_says_where_the_directory_is(): diff --git a/tests/test_mcp_workspaces.py b/tests/test_mcp_workspaces.py index 13dd990..3e4630e 100644 --- a/tests/test_mcp_workspaces.py +++ b/tests/test_mcp_workspaces.py @@ -90,6 +90,20 @@ def test_creating_one_does_not_switch_to_it(self): assert seen[-1].method == "POST" assert client.workspace == DEFAULT_WORKSPACE + def test_creating_one_says_it_did_not_switch(self): + """A create-then-run sequence landed a five-shot job in the wrong + workspace; the result now says where the session still is.""" + client, _ = recording({"name": "shots"}) + result = create_workspace(client, "shots") + assert result["current"] == DEFAULT_WORKSPACE + assert "use_workspace" in result["next"] + + def test_creating_with_use_switches_to_it(self): + client, _seen = recording(listing("default", "shots")) + result = create_workspace(client, "shots", use=True) + assert client.workspace == "shots" + assert result["current"] == "shots" + def test_deleting_refuses_until_the_cost_is_acknowledged(self): client, seen = recording({"detail": "would remove 12 files"}, status=409) with pytest.raises(DwApiError) as refusal: diff --git a/workflows/templates/assemble-and-score.json b/workflows/templates/assemble-and-score.json index 6eeadcf..0f45c9b 100644 --- a/workflows/templates/assemble-and-score.json +++ b/workflows/templates/assemble-and-score.json @@ -1,6 +1,9 @@ { "id": "assemble-and-score", "description": "Cuts shots that already exist into one film with a score under them, generating nothing in the run. It is the pass you re-run while cutting, when re-generating the footage each time would cost GPU hours to change a fade. The shots go straight into concat_videos as hard cuts - nothing is stabilized or rescaled on the way, so a deliberate camera move survives the edit; they must already share one size and frame rate, and the task refuses a set that does not. 'shots' is a list, so a diptych is two entries and a reel is however many the cut needs. The shots' own recorded sound is carried up to the score's sample rate and mixed underneath it, so the room tone of each world survives the edit instead of being replaced by music. Supply the shots and the score as asset: references - 'shot_1.mp4' and friends, uploaded with upload_asset or promoted from a generated run with keep_output.", + "cost": [ + {"device": "cuda", "name": "RTX 3090", "vram_gb": 24, "minutes": 0.2} + ], "variables": { "shots": [ "asset:shot_1.mp4", diff --git a/workflows/templates/dissolve-between-shots.json b/workflows/templates/dissolve-between-shots.json index e8ec27c..82f6ea8 100644 --- a/workflows/templates/dissolve-between-shots.json +++ b/workflows/templates/dissolve-between-shots.json @@ -1,6 +1,9 @@ { "id": "dissolve-between-shots", "description": "assemble-and-score.json with the cuts softened into cross-dissolves. dissolve_videos overlaps each pair by 'dissolve_frames' rather than butting them together, which suits shots that share a composition - registered on the same centre at the same size, an overlap reads as one thing becoming another rather than as a fade between two pictures. Shots whose framing disagrees will read as a plain cross-fade, so use hard cuts there. As in assemble-and-score the shots are a list and go in untouched - a shot that drifts wants stabilize_video run on it first, as its own step, not on every shot by default. The audio bed is mixed the same way as in assemble-and-score.", + "cost": [ + {"device": "cuda", "name": "RTX 3090", "vram_gb": 24, "minutes": 0.2} + ], "variables": { "shots": [ "asset:shot_1.mp4", diff --git a/workflows/templates/minimax/dialogue-short.json b/workflows/templates/minimax/dialogue-short.json index aa55774..b375b72 100644 --- a/workflows/templates/minimax/dialogue-short.json +++ b/workflows/templates/minimax/dialogue-short.json @@ -2,6 +2,9 @@ "id": "MiniMaxH3SitcomShort", "description": "A digital short built the way television is built: from cuts, not from one long take. Chained generation degrades with length - every segment conditions on the previous segment's output, so artifacts compound and identity drifts. A scene cut resets that completely: each shot here is generated fresh from the same two character portraits, so shot five is exactly as clean as shot one and the scene can run as long as the script does. Two Z-Image steps draw the cast (the second reuses the first's loaded pipeline - identical configurations share one model - and 'release_pipeline' frees it before the video model loads). The first shot loads MiniMax-H3 once; the remaining shots are 'pipeline_reference' steps that rerun it with a new prompt, references and length - the tag shot runs 141 frames where the others run 124, since length is per-run. Character consistency across cuts comes from referencing the same portraits in every shot; voice consistency comes from repeating each character's voice description verbatim in every prompt. The soundscape writes the laugh track. A final 'concat_videos' task is the editor, splicing the shots into one episode - hard cuts, no trims, no seams to hide, because nothing was carried between them. Only the picture cuts hard: 'audio_bleed_ms' rings each shot's laugh track on over the silent opening of the next, the way a live audience carries across a cut. It and 'seam_fade_ms' are variables, so a seam is re-tuned with an argument rather than a copy of the workflow; 1800 ms is where a five-shot cut measured best, since a generated shot opens on more silence than it looks.", "summary": "A multi-shot dialogue short: Z-Image draws the cast, each shot is generated fresh from the same portraits, then cut.", + "cost": [ + {"device": "cuda", "name": "RTX 3090", "vram_gb": 24, "minutes": 42} + ], "variables": { "howie_portrait_prompt": "a 3/4-length studio portrait of a wiry man in his early 30s with short curly dark hair and expressive eyebrows, wearing a mustard-yellow sweater vest over a white button-down shirt, standing in a 1990s multi-camera sitcom apartment set with a sagging olive-green couch and a brick wall behind him, warm even studio lighting, sharp focus, photographic", "pat_portrait_prompt": "a 3/4-length studio portrait of a heavyset man in his mid 40s with a shaved head and a calm, heavy-lidded expression, wearing an open gray flannel shirt over a dark t-shirt, standing in a 1990s multi-camera sitcom apartment set with a sagging olive-green couch and a brick wall behind him, warm even studio lighting, sharp focus, photographic", diff --git a/workflows/templates/minimax/music-video.json b/workflows/templates/minimax/music-video.json index d82cacb..0e0b474 100644 --- a/workflows/templates/minimax/music-video.json +++ b/workflows/templates/minimax/music-video.json @@ -1,6 +1,9 @@ { "id": "MiniMaxH3MusicVideo", "description": "A music video built from cuts, sung to a soundtrack that never touches a chain. The long-take way to film a song - one chained generation lip-synced end to end - degrades with every carried segment and can let the sync slip. This builds the video the way music television does instead: MiniMax-Music3 writes the song, 'slice_audio' cuts it into frame-exact pieces (124 frames at 24 fps each), and every shot is generated fresh from the same Z-Image portrait plus its own slice, lip-synced to just those five seconds. No shot conditions on another shot's output, so the last cut is as clean as the first. 'concat_videos' splices the shots, and because each one covered exactly its slice's frames, the edit is sample-accurate by construction: 'pair_audio' drops the original, unbroken song over the whole cut and the mouths line up in every shot. The generation models pass through one at a time - each is released before the next loads - so the workflow peaks no higher than its largest single model.", + "cost": [ + {"device": "cuda", "name": "RTX 3090", "vram_gb": 24, "minutes": 35} + ], "variables": { "singer_portrait_prompt": "prompt:zimage/otter_singer_portrait", "song_prompt": "prompt:minimax/otter_soul_song",