diffusers-workflow 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
- diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
- diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
- diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
- diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
- diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
- dw/__init__.py +440 -0
- dw/adapter_compatibility.py +226 -0
- dw/arguments.py +1231 -0
- dw/assessment_rules.py +159 -0
- dw/assets.py +130 -0
- dw/cache_blocks.json +16 -0
- dw/cache_blocks.py +146 -0
- dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
- dw/content_types.py +150 -0
- dw/dissolve_frame_errors.py +121 -0
- dw/docs/ACCELERATION.md +352 -0
- dw/docs/AGENT_LOOP.md +95 -0
- dw/docs/DEPENDENCIES.md +91 -0
- dw/docs/IP_ADAPTER.md +109 -0
- dw/docs/LORAS.md +131 -0
- dw/docs/MCP.md +517 -0
- dw/docs/PROMPT_WEIGHTING.md +78 -0
- dw/docs/QUANTIZATION.md +230 -0
- dw/docs/RECIPES_24GB.md +201 -0
- dw/docs/RELEASING.md +195 -0
- dw/docs/REMOTE.md +140 -0
- dw/docs/REPL_COMMANDS.md +121 -0
- dw/docs/REPL_WORKER_GUIDE.md +51 -0
- dw/docs/SECURITY.md +272 -0
- dw/docs/SECURITY_QUICKREF.md +112 -0
- dw/docs/SERVER.md +679 -0
- dw/docs/TASKS.md +1741 -0
- dw/docs/TESTING.md +71 -0
- dw/docs/WORKFLOW_GUIDE.md +2038 -0
- dw/docs/WORKSPACES.md +316 -0
- dw/download_watch.py +335 -0
- dw/elision.py +306 -0
- dw/events.py +275 -0
- dw/for_each.py +409 -0
- dw/host_memory.py +258 -0
- dw/host_memory_projection.py +230 -0
- dw/hub_cache.py +432 -0
- dw/introspection.py +1228 -0
- dw/kernel_availability.py +208 -0
- dw/locations.py +599 -0
- dw/log_setup.py +45 -0
- dw/loudness.py +82 -0
- dw/media_audio.py +217 -0
- dw/media_frames.py +367 -0
- dw/media_info.py +297 -0
- dw/pipeline_processors/chain.py +821 -0
- dw/pipeline_processors/config_objects.py +237 -0
- dw/pipeline_processors/pipeline.py +2297 -0
- dw/pipeline_processors/remote.py +46 -0
- dw/plan.py +920 -0
- dw/previous_results.py +411 -0
- dw/probe_paths.py +59 -0
- dw/prompt_schema.json +48 -0
- dw/prompt_weighting.py +378 -0
- dw/prompts.py +159 -0
- dw/realize.py +250 -0
- dw/reference_limits.py +215 -0
- dw/reference_names.py +125 -0
- dw/repl.py +338 -0
- dw/repl_commands.py +836 -0
- dw/repl_worker.py +159 -0
- dw/result.py +1720 -0
- dw/result_fps.py +82 -0
- dw/run.py +162 -0
- dw/runs.py +768 -0
- dw/scalar_result_validation.py +97 -0
- dw/schema.py +283 -0
- dw/security.py +1038 -0
- dw/select_validation.py +115 -0
- dw/serve.py +277 -0
- dw/server/__init__.py +2 -0
- dw/server/app.py +4586 -0
- dw/server/assess.py +132 -0
- dw/server/catalog_shape.py +487 -0
- dw/server/enhancers.py +129 -0
- dw/server/exports.py +480 -0
- dw/server/guides.py +257 -0
- dw/server/jobs.py +1561 -0
- dw/server/mcp_mount.py +95 -0
- dw/server/netinfo.py +124 -0
- dw/server/observed_cost.py +379 -0
- dw/server/sysinfo.py +71 -0
- dw/server/ui/assets/abap-08VXUWAP.js +1 -0
- dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
- dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
- dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
- dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
- dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
- dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
- dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
- dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
- dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
- dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
- dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
- dw/server/ui/assets/css-DIMkf-bt.js +3 -0
- dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
- dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
- dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
- dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
- dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
- dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
- dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
- dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
- dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
- dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
- dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
- dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
- dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
- dw/server/ui/assets/go-C-y9NEjX.js +1 -0
- dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
- dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
- dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
- dw/server/ui/assets/html-PhsdjHSr.js +1 -0
- dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
- dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
- dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
- dw/server/ui/assets/index-DgrYhQd9.js +43 -0
- dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
- dw/server/ui/assets/java-BEtHBSE6.js +1 -0
- dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
- dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
- dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
- dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
- dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
- dw/server/ui/assets/less-B9JPFI3C.js +2 -0
- dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
- dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
- dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
- dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
- dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
- dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
- dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
- dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
- dw/server/ui/assets/monaco--ixms01u.css +1 -0
- dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
- dw/server/ui/assets/msdax-DauUninz.js +1 -0
- dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
- dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
- dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
- dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
- dw/server/ui/assets/perl-oz_6vUea.js +1 -0
- dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
- dw/server/ui/assets/php-nr791fC2.js +1 -0
- dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
- dw/server/ui/assets/postiats-43DmfD33.js +1 -0
- dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
- dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
- dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
- dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
- dw/server/ui/assets/python-Bcn70HdC.js +1 -0
- dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
- dw/server/ui/assets/r-BwWrilGY.js +1 -0
- dw/server/ui/assets/razor-D1HmNnby.js +1 -0
- dw/server/ui/assets/redis-ClamHrr6.js +1 -0
- dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
- dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
- dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
- dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
- dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
- dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
- dw/server/ui/assets/scheme-BeGwcela.js +1 -0
- dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
- dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
- dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
- dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
- dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
- dw/server/ui/assets/sql-NEE52Syq.js +1 -0
- dw/server/ui/assets/st-DbInun42.js +1 -0
- dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
- dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
- dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
- dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
- dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
- dw/server/ui/assets/twig-De2hgUGE.js +1 -0
- dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
- dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
- dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
- dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
- dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
- dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
- dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
- dw/server/ui/index.html +17 -0
- dw/server/updater.py +192 -0
- dw/settings.py +98 -0
- dw/shot_span_preflight.py +116 -0
- dw/shots.py +359 -0
- dw/slice_preflight.py +148 -0
- dw/step.py +187 -0
- dw/step_cache.py +442 -0
- dw/subfolders.py +107 -0
- dw/task_domains.py +307 -0
- dw/tasks/assess.py +826 -0
- dw/tasks/audio_transcription.py +88 -0
- dw/tasks/audio_utils.py +1862 -0
- dw/tasks/background_remover.py +43 -0
- dw/tasks/borders.py +113 -0
- dw/tasks/compose_text.py +74 -0
- dw/tasks/concat_videos.py +300 -0
- dw/tasks/depth_estimator.py +54 -0
- dw/tasks/diffusion_upscale.py +109 -0
- dw/tasks/dissolve_videos.py +342 -0
- dw/tasks/format_messages.py +24 -0
- dw/tasks/gather.py +173 -0
- dw/tasks/grade.py +97 -0
- dw/tasks/image_to_text.py +43 -0
- dw/tasks/image_utils.py +764 -0
- dw/tasks/interpolate_frames.py +252 -0
- dw/tasks/judge.py +68 -0
- dw/tasks/model_cache.py +55 -0
- dw/tasks/pair_audio.py +268 -0
- dw/tasks/qr_code.py +19 -0
- dw/tasks/restore_faces.py +175 -0
- dw/tasks/rife_model.py +192 -0
- dw/tasks/segment.py +121 -0
- dw/tasks/select.py +111 -0
- dw/tasks/speech_generation.py +228 -0
- dw/tasks/stabilize.py +129 -0
- dw/tasks/task.py +920 -0
- dw/tasks/tensor_image.py +57 -0
- dw/tasks/text_generation.py +169 -0
- dw/tasks/text_sections.py +80 -0
- dw/tasks/upscale.py +203 -0
- dw/tasks/video_utils.py +624 -0
- dw/tasks/zoe_depth.py +71 -0
- dw/teacache.py +381 -0
- dw/teacache_models.json +99 -0
- dw/test.py +29 -0
- dw/type_helpers.py +231 -0
- dw/validate.py +68 -0
- dw/variable_constraints.py +444 -0
- dw/variables.py +443 -0
- dw/video_extensions.py +141 -0
- dw/vram_estimate.py +116 -0
- dw/worker.py +764 -0
- dw/workflow.py +2007 -0
- dw/workflow_schema.json +1346 -0
- dw/workflow_sources.py +383 -0
- dw/workflows/h3_context_ir.json +57 -0
- dw/workflows/test.json +31 -0
- dw/workspace.py +730 -0
- dw_mcp/__init__.py +6 -0
- dw_mcp/__main__.py +133 -0
- dw_mcp/assets.py +336 -0
- dw_mcp/authoring.py +114 -0
- dw_mcp/catalog.py +360 -0
- dw_mcp/client.py +486 -0
- dw_mcp/diagnose.py +371 -0
- dw_mcp/exports.py +84 -0
- dw_mcp/guides.py +35 -0
- dw_mcp/media.py +638 -0
- dw_mcp/models.py +97 -0
- dw_mcp/prompts.py +104 -0
- dw_mcp/server.py +1343 -0
- dw_mcp/workspaces.py +212 -0
dw_mcp/__init__.py
ADDED
dw_mcp/__main__.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""`python -m dw_mcp` / `dw-mcp`: serve the tool surface over stdio."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from dw_mcp.client import (
|
|
9
|
+
DwApiError,
|
|
10
|
+
DwClient,
|
|
11
|
+
is_loopback_url,
|
|
12
|
+
resolve_base_url,
|
|
13
|
+
resolve_token,
|
|
14
|
+
)
|
|
15
|
+
from dw_mcp.server import build_server
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _refuse(message):
|
|
19
|
+
print(f"dw-mcp: {message}", file=sys.stderr)
|
|
20
|
+
return 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv=None, transport=None):
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="dw-mcp",
|
|
26
|
+
description="MCP server for diffusers-workflow. Requires a running "
|
|
27
|
+
"dw.serve - start one with `dw-serve` first.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--url",
|
|
31
|
+
default=None,
|
|
32
|
+
help="Base URL of the running dw.serve "
|
|
33
|
+
"(default: $DW_MCP_URL, else http://127.0.0.1:8765)",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--token",
|
|
37
|
+
default=None,
|
|
38
|
+
help="Bearer token the dw.serve was started with, if any "
|
|
39
|
+
"(default: $DW_API_TOKEN). Required when --url is not loopback.",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--workspace",
|
|
43
|
+
default=None,
|
|
44
|
+
help="Which of the server's workspaces to work in (default: "
|
|
45
|
+
"$DW_MCP_WORKSPACE, else the server's default). A name on the "
|
|
46
|
+
"server, not a directory here - list_workspaces shows them",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--timeout",
|
|
50
|
+
type=float,
|
|
51
|
+
default=30.0,
|
|
52
|
+
help="Seconds to wait on any one API request (default: 30)",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--no-probe",
|
|
56
|
+
action="store_true",
|
|
57
|
+
default=False,
|
|
58
|
+
help="Skip the startup GET /api/health that confirms the server is "
|
|
59
|
+
"reachable and the token is accepted",
|
|
60
|
+
)
|
|
61
|
+
args = parser.parse_args(argv)
|
|
62
|
+
|
|
63
|
+
base_url = resolve_base_url(args.url)
|
|
64
|
+
token = resolve_token(args.token)
|
|
65
|
+
|
|
66
|
+
# A remote dw.serve with no token would let anyone on that network run
|
|
67
|
+
# workflows as this user; refusing here is the client-side half of the
|
|
68
|
+
# server's own non-loopback-without-token warning.
|
|
69
|
+
if not is_loopback_url(base_url) and not token:
|
|
70
|
+
return _refuse(
|
|
71
|
+
f"{base_url} is not a loopback address and no token is set. "
|
|
72
|
+
"A dw.serve reachable from other machines must be started with "
|
|
73
|
+
"--token / DW_API_TOKEN, and the same token passed here with "
|
|
74
|
+
"--token or DW_API_TOKEN."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
client = DwClient(
|
|
78
|
+
base_url=base_url,
|
|
79
|
+
timeout=args.timeout,
|
|
80
|
+
token=token,
|
|
81
|
+
transport=transport,
|
|
82
|
+
workspace=args.workspace,
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
if not args.no_probe:
|
|
86
|
+
# A remote URL or token that is wrong is a misconfiguration the
|
|
87
|
+
# user must fix before anything works, so it is fatal. On
|
|
88
|
+
# loopback it usually means "dw.serve is not up yet", which a
|
|
89
|
+
# tool call reports for itself with the same message - warn and
|
|
90
|
+
# serve anyway rather than making the agent restart us.
|
|
91
|
+
code = _probe(client, base_url, required=not is_loopback_url(base_url))
|
|
92
|
+
if code:
|
|
93
|
+
return code
|
|
94
|
+
build_server(client).run(transport="stdio")
|
|
95
|
+
finally:
|
|
96
|
+
client.close()
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _probe(client, base_url, required=True):
|
|
101
|
+
"""One GET /api/health so a wrong URL or token fails here, once, with a
|
|
102
|
+
message - not on every tool call as an unexplained 401.
|
|
103
|
+
|
|
104
|
+
Returns 2 when the probe failed and `required`; 0 otherwise. A failed
|
|
105
|
+
probe always prints, so a loopback server that is merely not up yet
|
|
106
|
+
still says so.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
health = client.get_json("/api/health")
|
|
110
|
+
except (DwApiError, httpx.HTTPError) as e:
|
|
111
|
+
text = str(e)
|
|
112
|
+
if "401" in text or "token" in text.lower():
|
|
113
|
+
code = _refuse(
|
|
114
|
+
f"dw.serve at {base_url} requires a bearer token and rejected "
|
|
115
|
+
f"the one given (or none was given): {text}"
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
code = _refuse(f"could not reach dw.serve at {base_url}: {text}")
|
|
119
|
+
return code if required else 0
|
|
120
|
+
print(
|
|
121
|
+
"dw-mcp: connected to {host} (dw {version}, {device}) at {url}".format(
|
|
122
|
+
host=health.get("hostname", "?"),
|
|
123
|
+
version=health.get("version", "?"),
|
|
124
|
+
device=health.get("device", "?"),
|
|
125
|
+
url=base_url,
|
|
126
|
+
),
|
|
127
|
+
file=sys.stderr,
|
|
128
|
+
)
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
sys.exit(main())
|
dw_mcp/assets.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""The asset library: the input media a workflow reaches by an 'asset:'
|
|
2
|
+
reference, and the one way to get a local file into it.
|
|
3
|
+
|
|
4
|
+
An agent authoring remotely can name assets that already exist on the box,
|
|
5
|
+
but until it can put bytes there it can only write workflows it has no way
|
|
6
|
+
to supply inputs for. `upload_asset` is that path: the file's bytes go up as
|
|
7
|
+
the request body - the same route the browser's file picker uses - and what
|
|
8
|
+
comes back is the reference, not a path, because the reference is what a
|
|
9
|
+
workflow carries and the path means nothing on the machine the agent is on.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from dw_mcp.client import DwApiError, api_path
|
|
16
|
+
|
|
17
|
+
# Twin of the server's own limit (dw/server/app.py). Checked here as well so
|
|
18
|
+
# a 200MB file fails before it is read and pushed, not after
|
|
19
|
+
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
# `content` bytes arrive base64-encoded in the tool call itself rather than
|
|
22
|
+
# read off a disk, so they compete with everything else in the call for the
|
|
23
|
+
# caller's context budget - a few MB is enough for a voice sample or a small
|
|
24
|
+
# image, and nowhere near enough to use this as a bulk-transfer path (#203)
|
|
25
|
+
MAX_INLINE_UPLOAD_BYTES = 4 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
# What the library holds, and what the upload route accepts. Duplicated from
|
|
28
|
+
# dw/security.py rather than imported: importing anything under dw/ pulls in
|
|
29
|
+
# torch, which this pure HTTP client must not do
|
|
30
|
+
ALLOWED_UPLOAD_EXTENSIONS = frozenset(
|
|
31
|
+
{
|
|
32
|
+
".jpg",
|
|
33
|
+
".jpeg",
|
|
34
|
+
".png",
|
|
35
|
+
".gif",
|
|
36
|
+
".bmp",
|
|
37
|
+
".webp",
|
|
38
|
+
".mp4",
|
|
39
|
+
".avi",
|
|
40
|
+
".mkv",
|
|
41
|
+
".mov",
|
|
42
|
+
".webm",
|
|
43
|
+
".wav",
|
|
44
|
+
".mp3",
|
|
45
|
+
".flac",
|
|
46
|
+
".ogg",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _remote_roots(client):
|
|
52
|
+
"""The directories a remote read is confined to, or None when local.
|
|
53
|
+
|
|
54
|
+
The mirror of media.py's `_remote_root` (#113), for the other direction.
|
|
55
|
+
Only the mounted MCP surface is remote: there `upload_asset` runs inside
|
|
56
|
+
dw.serve, so `file_path` names a file on the operator's box rather than
|
|
57
|
+
on the calling agent's machine, and an unconfined read is an arbitrary
|
|
58
|
+
file read plus a path-existence oracle (#138). A stdio `dw-mcp` returns
|
|
59
|
+
None and keeps reading whatever the user can, because there "local file"
|
|
60
|
+
is genuinely their own.
|
|
61
|
+
"""
|
|
62
|
+
if not getattr(client, "mounted", False):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
directories = (client.get_json("/api/server").get("directories")) or {}
|
|
66
|
+
roots = []
|
|
67
|
+
for key in ("workspace", "workflows", "assets", "outputs", "prompts"):
|
|
68
|
+
value = directories.get(key)
|
|
69
|
+
if not value:
|
|
70
|
+
continue
|
|
71
|
+
resolved = os.path.normpath(
|
|
72
|
+
os.path.realpath(os.path.abspath(os.path.expanduser(str(value))))
|
|
73
|
+
)
|
|
74
|
+
if resolved not in roots:
|
|
75
|
+
roots.append(resolved)
|
|
76
|
+
if not roots:
|
|
77
|
+
raise DwApiError(
|
|
78
|
+
"This server cannot say which directories it works in, so it "
|
|
79
|
+
"will not read a file off its own disk for you. Upload the "
|
|
80
|
+
"bytes through the web UI's file picker, pass them inline to "
|
|
81
|
+
"upload_asset as content=, or keep a generated file with "
|
|
82
|
+
"keep_output."
|
|
83
|
+
)
|
|
84
|
+
return roots
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _confine_source(path, roots, named):
|
|
88
|
+
"""Refuse a source outside `roots`, before anything looks at the file.
|
|
89
|
+
|
|
90
|
+
Ordered ahead of the existence and extension checks on purpose: a
|
|
91
|
+
refusal that depends on whether the file is there turns the tool into a
|
|
92
|
+
path-existence oracle for the whole box, which is the condition this
|
|
93
|
+
closes as much as the read itself (#138). Containment is on the resolved
|
|
94
|
+
real path, so a symlink cannot carry the read out.
|
|
95
|
+
"""
|
|
96
|
+
probe = path
|
|
97
|
+
while not os.path.exists(probe) and os.path.dirname(probe) != probe:
|
|
98
|
+
probe = os.path.dirname(probe)
|
|
99
|
+
resolved = os.path.normpath(
|
|
100
|
+
os.path.join(os.path.realpath(probe), os.path.relpath(path, probe))
|
|
101
|
+
)
|
|
102
|
+
if any(resolved == root or resolved.startswith(root + os.sep) for root in roots):
|
|
103
|
+
return
|
|
104
|
+
raise DwApiError(
|
|
105
|
+
f"Refusing to read {named} - this MCP endpoint is served by "
|
|
106
|
+
f"dw.serve, so the file would be read off the server, where a "
|
|
107
|
+
f"source is confined to the directories it works in "
|
|
108
|
+
f"({', '.join(roots)}). A file that is already there is reachable "
|
|
109
|
+
f"as an 'asset:' reference; to put a new one there when it exists "
|
|
110
|
+
f"only on your own machine, call upload_asset with content= (its "
|
|
111
|
+
f"bytes, base64-encoded) instead of file_path, upload it through "
|
|
112
|
+
f"the web UI's file picker, or promote a generated file with "
|
|
113
|
+
f"keep_output."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# What a compact asset entry keeps: enough to name and choose one, nothing
|
|
118
|
+
# that only opening or previewing it needs
|
|
119
|
+
ASSET_SUMMARY_FIELDS = ("name", "reference", "kind", "size", "origin")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _summarised_asset_entries(entries):
|
|
123
|
+
return [
|
|
124
|
+
{key: entry.get(key) for key in ASSET_SUMMARY_FIELDS if key in entry}
|
|
125
|
+
for entry in entries
|
|
126
|
+
if isinstance(entry, dict)
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def list_assets(client, detail=False):
|
|
131
|
+
"""The input media on the server, each with the 'asset:' reference a
|
|
132
|
+
workflow argument carries.
|
|
133
|
+
|
|
134
|
+
Spans the whole search path: each entry's 'origin' says whether it is
|
|
135
|
+
this workspace's own ('workspace'), the library every workspace shares
|
|
136
|
+
('common'), or one a read-only examples tree brought with it.
|
|
137
|
+
'libraries' names the roots searched, in order, with 'writable' saying
|
|
138
|
+
which one an upload or delete can reach. 'shadowed' lists names a
|
|
139
|
+
nearer library hides - present in a farther root but not resolvable by
|
|
140
|
+
'asset:', since the nearer file wins; a shadowed entry carries no 'url'
|
|
141
|
+
because that URL would serve the file that shadows it.
|
|
142
|
+
|
|
143
|
+
Compact by default - each `assets`/`shadowed` entry cut to
|
|
144
|
+
`name`/`reference`/`kind`/`size`/`origin` - because a library that has
|
|
145
|
+
accumulated months of episode and regression assets answers in the tens
|
|
146
|
+
of KB otherwise for a question that is usually "what's available and
|
|
147
|
+
under what name" (#249). Pass `detail=True` for each entry's `folder`,
|
|
148
|
+
`mtime` and `url` too.
|
|
149
|
+
"""
|
|
150
|
+
result = client.get_json("/api/assets")
|
|
151
|
+
if detail:
|
|
152
|
+
return result
|
|
153
|
+
assets = result.get("assets")
|
|
154
|
+
shadowed = result.get("shadowed")
|
|
155
|
+
if not isinstance(assets, list):
|
|
156
|
+
return result
|
|
157
|
+
return {
|
|
158
|
+
**result,
|
|
159
|
+
"assets": _summarised_asset_entries(assets),
|
|
160
|
+
"shadowed": _summarised_asset_entries(shadowed)
|
|
161
|
+
if isinstance(shadowed, list)
|
|
162
|
+
else shadowed,
|
|
163
|
+
"note": (
|
|
164
|
+
"Compact: each entry is name/reference/kind/size/origin only. "
|
|
165
|
+
"Call list_assets(detail=True) for folder, mtime and url too."
|
|
166
|
+
),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def delete_asset(client, name):
|
|
171
|
+
"""Remove one file from the asset library.
|
|
172
|
+
|
|
173
|
+
Deletes from whichever library holds it - the workspace's own before
|
|
174
|
+
the shared one, the order 'asset:' resolves in. An asset from a
|
|
175
|
+
read-only examples library answers 403.
|
|
176
|
+
"""
|
|
177
|
+
return client.delete_json(api_path("api", "assets", name))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def keep_output(
|
|
181
|
+
client, name, asset_name=None, overwrite=False, shared=False, workspace=None
|
|
182
|
+
):
|
|
183
|
+
"""Keep a generated file as an input asset, under a stable name.
|
|
184
|
+
|
|
185
|
+
A run's files are named by the run that made them, which is the wrong
|
|
186
|
+
thing to build on: 'latest' moves and a pinned run id breaks when
|
|
187
|
+
outputs are pruned. Keeping one gives it an 'asset:' name that stays
|
|
188
|
+
put, so a later workflow can rely on it.
|
|
189
|
+
|
|
190
|
+
The copy happens on the server, inside the workspace - downloading a
|
|
191
|
+
render here only to upload it back would move the bytes twice for
|
|
192
|
+
nothing.
|
|
193
|
+
|
|
194
|
+
`shared` keeps it in the library every workspace under the server's
|
|
195
|
+
root shares instead, which is where something a later episode in its
|
|
196
|
+
own workspace has to reach belongs.
|
|
197
|
+
"""
|
|
198
|
+
return client.post_json(
|
|
199
|
+
"/api/assets/keep",
|
|
200
|
+
{
|
|
201
|
+
"name": name,
|
|
202
|
+
"asset_name": asset_name,
|
|
203
|
+
"overwrite": overwrite,
|
|
204
|
+
"shared": shared,
|
|
205
|
+
},
|
|
206
|
+
workspace=workspace,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def upload_asset(client, file_path=None, content=None, asset_name=None, shared=False):
|
|
211
|
+
"""Put an image, video or audio file into the server's asset library and
|
|
212
|
+
get back the reference a workflow can use.
|
|
213
|
+
|
|
214
|
+
Takes exactly one of two sources:
|
|
215
|
+
|
|
216
|
+
`file_path` is read from the machine this MCP server runs on, which is
|
|
217
|
+
not necessarily the machine dw.serve runs on - that is the point of the
|
|
218
|
+
tool. Over a `dw.serve --mcp` endpoint that machine *is* the server, so
|
|
219
|
+
there `file_path` is confined to the directories the server works in,
|
|
220
|
+
and the refusal comes before the file is looked for so it cannot be used
|
|
221
|
+
to probe which paths exist (#138). A stdio `dw-mcp` is unconfined,
|
|
222
|
+
because there the file really is the caller's own.
|
|
223
|
+
|
|
224
|
+
`content` is the file's bytes, base64-encoded, for the case `file_path`
|
|
225
|
+
cannot reach: a headless agent driving a mounted `dw.serve --mcp`
|
|
226
|
+
endpoint has no filesystem in common with that server, so a voice
|
|
227
|
+
sample or small image that exists only on the agent's own machine has
|
|
228
|
+
no path either side can name. No path is confined or read for this
|
|
229
|
+
path - the bytes just go straight to the upload route - so it does not
|
|
230
|
+
reopen #138. It is capped well under `file_path`'s limit
|
|
231
|
+
(MAX_INLINE_UPLOAD_BYTES, a few MB) because these bytes ride in the tool
|
|
232
|
+
call itself rather than being streamed off disk, and `asset_name` is
|
|
233
|
+
required so the upload has a name and extension to be validated against.
|
|
234
|
+
|
|
235
|
+
`asset_name` is the name it is stored under - 'cast/priya-voice.wav'
|
|
236
|
+
rather than the random one an upload gets by default. A recurring cast
|
|
237
|
+
referenced as 'asset:uploads/084eaecc....wav' in every workflow cannot
|
|
238
|
+
be told apart without opening each file, which is the whole reason to
|
|
239
|
+
name one (2026-09-11). With `file_path`, the extension comes from the
|
|
240
|
+
uploaded file when the name has none; with `content`, `asset_name` must
|
|
241
|
+
carry its own extension since there is no file to take one from.
|
|
242
|
+
|
|
243
|
+
`shared` puts it in the library every workspace shares rather than in
|
|
244
|
+
the session's own, which is what a recurring cast needs: assets are
|
|
245
|
+
per workspace, so a cast uploaded while making episode one was
|
|
246
|
+
invisible from the workspace episode four was made in.
|
|
247
|
+
"""
|
|
248
|
+
if (file_path is None) == (content is None):
|
|
249
|
+
raise DwApiError("Pass exactly one of file_path or content.")
|
|
250
|
+
|
|
251
|
+
if content is not None:
|
|
252
|
+
return _upload_inline(client, content, asset_name=asset_name, shared=shared)
|
|
253
|
+
|
|
254
|
+
path = os.path.abspath(os.path.expanduser(str(file_path)))
|
|
255
|
+
roots = _remote_roots(client)
|
|
256
|
+
if roots is not None:
|
|
257
|
+
_confine_source(path, roots, file_path)
|
|
258
|
+
if not os.path.isfile(path):
|
|
259
|
+
raise DwApiError(f"No such file: {file_path}")
|
|
260
|
+
|
|
261
|
+
extension = os.path.splitext(path)[1].lower()
|
|
262
|
+
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
|
|
263
|
+
raise DwApiError(
|
|
264
|
+
f"{os.path.basename(path)} is not a kind the asset library takes "
|
|
265
|
+
f"({', '.join(sorted(ALLOWED_UPLOAD_EXTENSIONS))})."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
size = os.path.getsize(path)
|
|
269
|
+
if size > MAX_UPLOAD_BYTES:
|
|
270
|
+
raise DwApiError(
|
|
271
|
+
f"{os.path.basename(path)} is {size} bytes, over the "
|
|
272
|
+
f"{MAX_UPLOAD_BYTES} byte upload limit."
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
with open(path, "rb") as handle:
|
|
277
|
+
body = handle.read()
|
|
278
|
+
except OSError as e:
|
|
279
|
+
raise DwApiError(f"Could not read {file_path}: {e}")
|
|
280
|
+
|
|
281
|
+
params = {"filename": os.path.basename(path)}
|
|
282
|
+
if asset_name:
|
|
283
|
+
params["asset_name"] = asset_name
|
|
284
|
+
if shared:
|
|
285
|
+
params["shared"] = "true"
|
|
286
|
+
result = client.post_bytes("/api/uploads", body, params=params)
|
|
287
|
+
# 'path' from a server with no asset library is an absolute path on that
|
|
288
|
+
# machine; from one with a library it is already the reference. Report
|
|
289
|
+
# whichever it gave, named for what it is
|
|
290
|
+
return {
|
|
291
|
+
"reference": result.get("path"),
|
|
292
|
+
"url": result.get("url"),
|
|
293
|
+
"uploaded": os.path.basename(path),
|
|
294
|
+
"size": size,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _upload_inline(client, content, asset_name=None, shared=False):
|
|
299
|
+
"""The `content=` path of upload_asset - bytes with no path behind
|
|
300
|
+
them, so nothing here is confined or read off any disk (#203)."""
|
|
301
|
+
if not asset_name:
|
|
302
|
+
raise DwApiError(
|
|
303
|
+
"content requires asset_name, so the upload has a name and an "
|
|
304
|
+
"extension to validate - there is no file to take either from."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
extension = os.path.splitext(asset_name)[1].lower()
|
|
308
|
+
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
|
|
309
|
+
raise DwApiError(
|
|
310
|
+
f"{asset_name} is not a kind the asset library takes "
|
|
311
|
+
f"({', '.join(sorted(ALLOWED_UPLOAD_EXTENSIONS))})."
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
body = base64.b64decode(content, validate=True)
|
|
316
|
+
except Exception as e:
|
|
317
|
+
raise DwApiError(f"content could not be decoded as base64: {e}")
|
|
318
|
+
|
|
319
|
+
if len(body) > MAX_INLINE_UPLOAD_BYTES:
|
|
320
|
+
raise DwApiError(
|
|
321
|
+
f"content is {len(body)} bytes, over the "
|
|
322
|
+
f"{MAX_INLINE_UPLOAD_BYTES} byte limit for an inline upload. A "
|
|
323
|
+
f"file this large should be reached by file_path instead, from "
|
|
324
|
+
f"a machine that has it on disk."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
params = {"filename": asset_name, "asset_name": asset_name}
|
|
328
|
+
if shared:
|
|
329
|
+
params["shared"] = "true"
|
|
330
|
+
result = client.post_bytes("/api/uploads", body, params=params)
|
|
331
|
+
return {
|
|
332
|
+
"reference": result.get("path"),
|
|
333
|
+
"url": result.get("url"),
|
|
334
|
+
"uploaded": asset_name,
|
|
335
|
+
"size": len(body),
|
|
336
|
+
}
|
dw_mcp/authoring.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Writing workflows. Validation is free and comes first; saving overwrites,
|
|
2
|
+
so it is annotated destructive at the tool layer.
|
|
3
|
+
|
|
4
|
+
Path confinement is the server's job (dw/security.py already refuses
|
|
5
|
+
traversal and anything outside the workflow directory). Nothing here
|
|
6
|
+
re-implements it - a second, subtly different check is how the two drift.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dw_mcp.client import DwApiError, api_path, coerce_json_object
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_workflow(
|
|
13
|
+
client,
|
|
14
|
+
workflow=None,
|
|
15
|
+
name=None,
|
|
16
|
+
inline_workflow=None,
|
|
17
|
+
workflow_path=None,
|
|
18
|
+
workspace=None,
|
|
19
|
+
arguments=None,
|
|
20
|
+
):
|
|
21
|
+
"""Schema- and signature-check a workflow without queuing anything. This
|
|
22
|
+
is free (no GPU work) and should be called before any run or save. Give
|
|
23
|
+
either an inline definition (`workflow`, or `inline_workflow` - the same
|
|
24
|
+
thing `run_workflow` calls it) or the name of a stored one (`name`, or
|
|
25
|
+
`workflow_path` - the same thing `run_workflow` calls it), as
|
|
26
|
+
`list_workflows` reports it. A validated document can be handed straight
|
|
27
|
+
to `run_workflow` under either spelling.
|
|
28
|
+
|
|
29
|
+
`arguments` is the same dict `run_workflow` takes, checked against the
|
|
30
|
+
variables the workflow declares and against this workspace's libraries.
|
|
31
|
+
Without it the answer is about the stored definition and its stock
|
|
32
|
+
defaults - which is everything except the part the caller wrote
|
|
33
|
+
(2026-09-11).
|
|
34
|
+
|
|
35
|
+
`plan.cached_steps` is evaluated against the pinned workspace's own
|
|
36
|
+
`outputs/` - a run sitting in a different workspace, however identical
|
|
37
|
+
its arguments and seed, does not count as a hit. Pin `workspace` to the
|
|
38
|
+
one an earlier run actually used if you want to see it credited."""
|
|
39
|
+
workflow = coerce_json_object(workflow, "workflow")
|
|
40
|
+
inline_workflow = coerce_json_object(inline_workflow, "inline_workflow")
|
|
41
|
+
if workflow is not None and inline_workflow is not None:
|
|
42
|
+
raise DwApiError(
|
|
43
|
+
"`workflow` and `inline_workflow` are the same thing - provide only one."
|
|
44
|
+
)
|
|
45
|
+
if name is not None and workflow_path is not None:
|
|
46
|
+
raise DwApiError(
|
|
47
|
+
"`name` and `workflow_path` are the same thing - provide only one."
|
|
48
|
+
)
|
|
49
|
+
inline = workflow if workflow is not None else inline_workflow
|
|
50
|
+
stored = name if name is not None else workflow_path
|
|
51
|
+
if (inline is None) == (stored is None):
|
|
52
|
+
raise DwApiError(
|
|
53
|
+
"Provide exactly one of `workflow`/`inline_workflow` (an inline "
|
|
54
|
+
"definition) or `name`/`workflow_path` (a stored workflow)."
|
|
55
|
+
)
|
|
56
|
+
params = {"workspace": workspace} if workspace else None
|
|
57
|
+
payload = {"workflow_path": stored} if inline is None else {"workflow": inline}
|
|
58
|
+
if arguments is not None:
|
|
59
|
+
# An explicit {} still means "check a run with no values supplied" -
|
|
60
|
+
# distinct from omitting arguments entirely, which means "check the
|
|
61
|
+
# document" (#364). A falsy-but-not-None check here would drop that
|
|
62
|
+
# distinction before it ever reaches the server.
|
|
63
|
+
payload["arguments"] = arguments
|
|
64
|
+
# The server resolves a name against its own workflow directory, so
|
|
65
|
+
# validation sees the same base directory a run would
|
|
66
|
+
return client.post_json("/api/validate", payload, params=params)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save_workflow(client, name, workflow=None, patch=None):
|
|
70
|
+
"""Write a workflow into the server's writable workflow directory,
|
|
71
|
+
overwriting any file already under that name there. A name that resolves
|
|
72
|
+
to one of the server's read-only sources (an examples directory) is not
|
|
73
|
+
overwritten: the copy lands in the writable directory and shadows it from
|
|
74
|
+
then on. The server validates before writing.
|
|
75
|
+
|
|
76
|
+
Give exactly one of `workflow` (the full document to write) or `patch`
|
|
77
|
+
(a JSON Merge Patch, RFC 7396, applied to the currently stored
|
|
78
|
+
definition): a dict whose keys overwrite the stored ones, recursively
|
|
79
|
+
for nested dicts, so bumping one argument means sending just that
|
|
80
|
+
argument rather than the whole document. A key set to `null` deletes it.
|
|
81
|
+
A list replaces the stored list whole - a merge patch has no notion of
|
|
82
|
+
list position, so changing one entry of a `for_each` list still means
|
|
83
|
+
sending that whole list."""
|
|
84
|
+
if (workflow is None) == (patch is None):
|
|
85
|
+
raise DwApiError(
|
|
86
|
+
"Provide exactly one of `workflow` (a full replacement) or "
|
|
87
|
+
"`patch` (a JSON merge patch onto the stored version)."
|
|
88
|
+
)
|
|
89
|
+
workflow = coerce_json_object(workflow, "workflow")
|
|
90
|
+
patch = coerce_json_object(patch, "patch")
|
|
91
|
+
if patch is not None:
|
|
92
|
+
current = client.get_json(api_path("api", "workflows", name))
|
|
93
|
+
workflow = _merge_patch(current, patch)
|
|
94
|
+
return client.put_json(api_path("api", "workflows", name), {"workflow": workflow})
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _merge_patch(target, patch):
|
|
98
|
+
"""RFC 7396 JSON Merge Patch: each dict key in `patch` merges
|
|
99
|
+
recursively into `target`; any other value replaces `target` outright;
|
|
100
|
+
`None` deletes the key from the result."""
|
|
101
|
+
if not isinstance(patch, dict):
|
|
102
|
+
return patch
|
|
103
|
+
result = dict(target) if isinstance(target, dict) else {}
|
|
104
|
+
for key, value in patch.items():
|
|
105
|
+
if value is None:
|
|
106
|
+
result.pop(key, None)
|
|
107
|
+
else:
|
|
108
|
+
result[key] = _merge_patch(result.get(key), value)
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def delete_workflow(client, name):
|
|
113
|
+
"""Remove a workflow from the server's workflow directory."""
|
|
114
|
+
return client.delete_json(api_path("api", "workflows", name))
|