Repository navigation
No way to move or copy outputs, runs and assets between workspaces #853
Copy link
Copy link
Closed
2 / 22 of 2 issues completedClosed
2 / 22 of 2 issues completed
Copy link
Labels
featureWork bigger than a fix: designed with Don, built in stages (harnest R11)Work bigger than a fix: designed with Don, built in stages (harnest R11)field-reportFiled from Don's own dw sessions (real use), not the harnessFiled from Don's own dw sessions (real use), not the harnessowner:testerTester's turn to actTester's turn to actpriority:3Backlog rank: high benefit, big liftBacklog rank: high benefit, big liftstatus:plan-approvedDon approved the feature plan (Don only)Don approved the feature plan (Don only)status:verifiedTester confirmed the fix via a real MCP callTester confirmed the fix via a real MCP calltarget:mini-aiFound against a non-lem server (DW_TARGET=mini-ai); Don triagesFound against a non-lem server (DW_TARGET=mini-ai); Don triagesverified-on:mps
Description
Field report
An agent needed to move a project's work (the Soviet-anthem assets and runs) from the default workspace into a workspace of its own. There's no tool for it: move_job only reorders the queue. It re-uploaded every file into the new workspace and deleted the originals from the default workspace by hand.
Assessment
- Nothing in the HTTP API or MCP moves or copies outputs, runs or assets between workspaces.
- The closest workaround is
keep_output(shared=true), which copies one generated file into the library every workspace under the root shares. It doesn't move runs or manifests, doesn't clean up the source, and only goes to the shared library, not to a specific workspace. - Re-uploading loses provenance: the file becomes an
uploads/asset (see the companion issue onasset_name), not a run output with a manifest.
Questions to settle (needs a spec)
- Copy or move? A move would need a confirm gate (
acknowledged_cost) like delete has. - What gets moved: asset-library entries, whole runs (
<workflow>/<run id>/with manifests), saved workflows, prompts? output:references in the destination's workflows or later runs that point at a run that has moved. Rewrite them, refuse the move, or leave them dangling and warn?- How the move is confined: both workspaces under the same root, using the existing path validators.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
featureWork bigger than a fix: designed with Don, built in stages (harnest R11)Work bigger than a fix: designed with Don, built in stages (harnest R11)field-reportFiled from Don's own dw sessions (real use), not the harnessFiled from Don's own dw sessions (real use), not the harnessowner:testerTester's turn to actTester's turn to actpriority:3Backlog rank: high benefit, big liftBacklog rank: high benefit, big liftstatus:plan-approvedDon approved the feature plan (Don only)Don approved the feature plan (Don only)status:verifiedTester confirmed the fix via a real MCP callTester confirmed the fix via a real MCP calltarget:mini-aiFound against a non-lem server (DW_TARGET=mini-ai); Don triagesFound against a non-lem server (DW_TARGET=mini-ai); Don triagesverified-on:mps
Activity
dkackman commentedon Oct 13, 2026
Don's ruling on scope (the questions above):
acknowledged_cost. This keeps it to no new destructive path and no rewriting of jobs-DBworkspace/run_dirrows.<workflow>/<run id>/with manifests). Saved workflows and prompts are out of scope for now.output:references: they're workspace-relative, so copying a run with the runs it references keeps them resolving. If a copied manifest names a run that wasn't copied, the copy still succeeds and returns a warning listing the unresolved references. It doesn't refuse and doesn't rewrite them.keep_output(shared=true).Shape: one MCP tool (e.g.
copy_to_workspace) plus one HTTP route. Mind the MCP surface budget. This also settles #854, where root placement for uploads is deferred here. Next: feature lead posts a staged plan for review. (claude-opus-5-5 / anthropic)dkackman commentedon Oct 13, 2026
Plan v2:
copy_to_workspace, which copies runs and asset-library entries between workspacesFeature lead: claude-opus-5-5 / anthropic. This plan works within Don's scope ruling of 2026-10-13: copy only, assets and whole runs, unresolved
output:refs produce a warning, same root. v2 folds in Don's approval of v1 (2026-10-13): the Q1–Q5 defaults, with media hard-linked and JSON sidecars always copied.Verdict: build, in two stages
defaultgets its own workspace. Today's root has five project workspaces (alaska,arresting,first-rain,happy-turtle, plus the Soviet-anthem work that prompted this). So this happens once per project. That's not often, but it happens.uploads/asset with no manifest and nooutput:reference.tests/test_mcp_server.py:1572), so the ceiling moves (decision Q4).docs/WORKSPACES.md).validate_workspace_nameandvalidate_pathwith a base.breaking-change. It writes no state that anything else depends on: copied files are ordinary runs and assets.keep_outputtake a destination workspace. It covers single files only: no runs, no manifests.featureissues. upload_asset: asset_name always lands under uploads/, unlike keep_output's library-root asset_name #854 (closed, docstring only) deferred "root placement for uploads" to here. This plan settles it: a copied asset keeps its library-relative path exactly,uploads/xstaysuploads/x, andupload_assetis unchanged.Problem and non-goals
Problem: nothing in the HTTP API or MCP copies a run or an asset from one workspace to another.
Non-goals:
delete_output/delete_asset, which already requireacknowledged_cost.keep_output(shared=true)keeps that job.output:references.ConfiguredWorkspace, meaning a server started with explicit folders and no root. The copy refuses it.What the sweep found that shapes the design
latest,vN,output:resolution (dw/runs.py:205),list_gallery(a directory scan) andget_output_*all read the run folder and its manifests, not the jobs DB. So a copied folder in<dest>/outputs/<identity>/<run id>/resolves with no further bookkeeping.list_jobs,get_jobandjob_provenanceread thejobstable, so a copied run appears in the gallery but has no job.YYYYMMDD-HHMMSS-<spec hash>[-N](dw/runs.py:330). The same run copied twice therefore lands on the same folder. A different run with that exact id is practically impossible.vNcollisions.versionis stored in each manifest and is numbered per workspace (open_runuses highest + 1). A copiedv3next to the destination's ownv3makesoutput:wf/v3resolve to whichever has the newer run id, silently (dw/runs.py:192-201)._pin_output(dw/realize.py:180) rewriteslatestandvNto explicit run ids inworkflow.jsonand in the manifest'sinputs. So the copy can resolve eachkind=="output"input against the destination after copying.manifest_relative_files,dw/runs.py:734) andworkflow.file. These are reported too, not rewritten.notes.jsonin the asset's folder (dw/notes.py).manifest.jsonkept-shots entry (record_kept_shots,dw/runs.py:791), whichassess_outputreads for shot boundaries.keep_outputhard-links and falls back tocopy2(dw/server/routes/assets.py:467). Output references refuse linked run folders (tests/test_security_symlinks.py), so the copy must never create symlinks.record_kept_shots(dw/runs.py:832) andwrite_realized_workflow(dw/runs.py:726) write withopen(path, "w"), notwrite_json_atomic, so a hard-linked sidecar would carry a later write in one workspace into the other (Don's review of v1).workspace_usagecaches for 60s, so the copy callsforget_workspace_usageon the destination, asdelete_outputdoes.The design
MCP tool
sourcedefaults to the session workspace (use_workspace).destinationmust already exist.create_workspacemakes one, and this tool doesn't create it implicitly.runsentries are<workflow>/<run id>, the form the gallery andoutput:use, or a bare<workflow>meaning every run of that workflow.assetsentries are library-relative paths, as written afterasset:. A folder (soviet-anthem/) copies everything under it.runsorassetsis required, andsource != destination.READ_ONLY_TOOLSnorDESTRUCTIVE_TOOLS: it never overwrites (below). It takes noacknowledged_cost(decision Q2).REST route
POST /api/workspaces/{destination}/copy, with body{source, runs, assets}, indw/server/routes/library.pynext to the other workspace routes. The MCP tool is a thin client of it.Engine module
The work goes in a new
dw/workspace_copy.py, so thatworkspace.pystays the layout owner. Its rules:validate_workspace_nameandnamed_workspace, so both are under the server's one root by construction.validate_path(p, base=<source folder>)and every destination path withvalidate_path(p, base=<dest folder>), so a..or an absolute entry is refused (400).require_writable).ConfiguredWorkspace) refuses with 409.overwrite: the copy never replaces anything.already_presentand skipped, not refused. It's the same run id, so it's the same run, and that makes re-running a copy idempotent.already_presentif the bytes are identical. If they differ, it's a conflict.copy2across devices, askeep_outputdoes.manifest.json,workflow.json,notes.json,job.json, and any other.jsonin a run or asset folder. They're edited in place (finding 7), and the version renumbering edits a copied manifest.workflow.json,notes.json,job.json, and the media.run_lock_path), so it can't race anopen_run.versionif the destination's identity doesn't already use it. If it does, the copied runs are renumbered after the destination's highest, in run-id order. The reply maps old to new.output:refs name run ids, not versions, so renumbering breaks none of them.notes.jsonentries are merged into the destination folder'snotes.json;manifest.json;provenance.workspacekeeps naming the source workspace, because that's where the file was made.{copied: {runs, assets}, already_present, renumbered: {"wf/<run>": {"from": 3, "to": 7}}, warnings}, plus bytes linked and bytes copied.unresolved_output_refs: per copied run, eachoutput:input whose run isn't in the destination after the copy.absolute_paths: manifest paths that still point into the source workspace.list_gallery, notlist_jobs" (decision Q5).forget_workspace_usage(destination).Stages
Stage A: copy runs
What it builds:
dw/workspace_copy.py, with the run half: the confinement, conflict, lock, file (link media, copy sidecars), version and warning rules.POST /api/workspaces/{destination}/copy. It acceptsrunsonly, and a non-emptyassetsgets a 400 saying that isn't supported yet.copy_to_workspacewithrunsonly.Tests owed:
<workflow>copying all its runs;already_presentidempotence;source == destination,ConfiguredWorkspaceand active job, each refused;output:wf/latestandoutput:wf/<run id>in the destination;notes.json, and renumber a copy'smanifest.json, and assert the source's files are unchanged;os.linksucceeds, and a plain copy when it raises.EXPECTED_TOOLSin both test files,TOOL_WIRING, and the tool count (62 → 63) inREADME.md,dw/server/guides.pyanddocs/MCP.md.scripts/dump_openapi.py).Docs owed:
docs/MCP.md: the tool table and its group.docs/SERVER.md: the route.docs/WORKSPACES.md: a "Copying between workspaces" section that says moving means copy then delete, that media is hard-linked and sidecars copied, and covers the no-jobs note.docs/ARCHITECTURE.md: one new row, workspace copy →dw/workspace_copy.py, enforced by its test.Deploy: server restart, and the MCP server picks up the new tool.
Estimate: $5–7.
Acceptance intent, over MCP (the tester sets up two scratch workspaces):
list_galleryin B shows its files.get_output_imagereads them throughoutput:<wf>/<run id>and throughoutput:<wf>/latest.copied.runsand includes the no-jobs note.already_present, and nothing changes.v1of the same workflow, and av1is copied in.renumberedmaps it tov2.output:<wf>/v1in B still resolves to B's own run.v1.warnings.unresolved_output_refsnames that reference.<workflow>: every run of it is copied...or an absolute path;runs.list_workspacesusage for B reflects the copy straight away.Stage B: copy assets (needs A)
What it builds:
dw/workspace_copy.py: file and folder entries, the identical-bytesalready_presentcheck, the conflict refusal, and thenotes.jsonand kept-shots merges (written fresh in the destination, never linked).assets.Tests owed:
uploads/staying underuploads/;provenance.workspaceunchanged;notes.jsonandmanifest.jsonunchanged;Docs owed:
docs/WORKSPACES.md,docs/MCP.mdanddocs/SERVER.mdupdated forassets.Deploy: server restart.
Estimate: $3–5.
Acceptance intent, over MCP:
keep_outputa shot from a run in A toproj/clip.mp4.copy_to_workspace(destination=B, assets=["proj/"]).list_assetsshowsproj/clip.mp4.assess_outputandget_output_frames(seams=true)onasset:proj/clip.mp4see the same shot boundaries as in A.uploads/<name>in B, not to the library root.runsandassetscopies both.already_present..., absolute paths and a nonexistent asset are refused.Order and dependencies
A, then B. Neither depends on another issue.
Risks
keep_output(overwrite), which unlinks first. Stage B confirms this for the asset paths; if it doesn't hold somewhere, that path copies instead. Sidecars, which are edited in place, are always copied (decision Q1). A useful side effect: copy-then-delete as a move frees no media disk and costs none.get_jobprovenance is empty for it. This is documented, not fixed (decision Q5).Decisions (Don's review of v1, 2026-10-13)
copy2. Always copy JSON sidecars. Owed: a test that editing a copied sidecar (a note, a renumbered manifest) leaves the source unchanged, and a test that a copied media file is a link whenos.linksucceeds.acknowledged_coston the copy? None.vNcollision? Renumber the colliding identity's copied runs after the destination's highest, and report the mapping.SURFACE_BUDGETby the measured size, with a dated note, once per stage.WORKSPACES.mdcovers it.claude-opus-5-5 / anthropic
dkackman commentedon Oct 13, 2026
Don's review of plan v1: approved, with the defaults on Q1–Q5 and one condition on Q1.
manifest.json,workflow.json,notes.json,job.json, and any other.jsonin a run or asset folder). The plan's in-place-edit argument holds for media but not for sidecars.record_kept_shots(dw/runs.py:832) andwrite_realized_workflow(dw/runs.py:726) write withopen(path, "w"), notwrite_json_atomic. A hard-linked sidecar would therefore carry a later write in one workspace into the other. Q3's version renumbering edits a copied manifest, so the same applies to it. Owed with this: a unit test that edits a copied sidecar (a note, plus a renumbered manifest) and asserts the source is unchanged, and a test that a copied media file is a link whenos.linksucceeds.acknowledged_cost.SURFACE_BUDGETby the measured size, with a dated note, once per stage.WORKSPACES.mdcovers it.Stages A then B as planned. (Don, recorded by claude-opus-5-5 / anthropic)
33 remaining items
dkackman commentedon Oct 14, 2026
Re-hand for the final check: SE-F048 only. claude-opus-5-5 / anthropic
Don ruled the SE-F048 failure a case error, and the build stays. harnest#107 is applied (dkackman/harnest@a0c5e92). The amended case expects
asset:proj/x.wavto be accepted asproj/x.wavin its own destination,qa-se-f048-c, and expectsasset:../../../../etc/hostsandasset:/etc/hoststo be refused.developis unchanged for this feature: the build isd084ea6aand the design record is at725764d7. Nothing needs deploying.docs/proposals/complete/workspace-copy-complete.md, unchanged.verify: run SE-F048 as amended, and close the parent if it passes. Nothing here needs CUDA.
dkackman commentedon Oct 14, 2026
Architecture review queued by the loop driver: this hand-off changed
dw/ordw_mcp/ondevelop(df2ca06187..61838a9c71: dw/script_lines.py dw/shot_span_preflight.py dw/tasks/script_check.py dw/validation.py). The reviewer checks it againstdocs/ARCHITECTURE.mdbefore the tester verifies.dkackman commentedon Oct 14, 2026
Architecture review: pass, nothing of #853's in the range. claude-opus-5-5 / anthropic
git logandgit diff --statofdf2ca061..61838a9cunderdw/anddw_mcp/. The range holds one commit,61838a9c(check-script: a wrong lines[].shot name passes validate and fails at run time #873:dw/script_lines.py,dw/shot_span_preflight.py,dw/tasks/script_check.py,dw/validation.py). No commit in it names No way to move or copy outputs, runs and assets between workspaces #853.0f5471ba,43cba931, merged at725764d7). The build is unchanged atd084ea6a, as the re-hand says, so there's nothing fordocs/ARCHITECTURE.mdor stage-c item 3 to check here.arch-approvedlabel, and none was needed.The tester can go ahead with SE-F048 as amended.
dkackman commentedon Oct 14, 2026
Final check: SE-F048 as amended passes, so I'm closing the parent. claude-opus-5-5 / anthropic, on the mini-ai server (mps on mini-ai.lan), develop @ 61838a9
I ran SE-F048 exactly as amended in dkackman/harnest@a0c5e92. As the re-hand said, I didn't re-run C-F395 to C-F405 or SE-F047: they passed in the last check, and the build hasn't changed since (
d084ea6a).Setup: I created
qa-se-f048-a,-band-c. In A, I ran the task-onlygain_audioworkflowqa-se-f048, giving run R20261014-003043-b76810b3(job 10cb74e4a3f7). I kept its wav asproj/x.wav. Baseline for B: 0 files and an empty gallery.Probes: each was
copy_to_workspace(destination="qa-se-f048-b", source="qa-se-f048-a", assets=[<entry>]). All were refused with a clean tool error. None had a traceback or disclosed a server path.../../../../etc/hosts'../../../../etc/hosts' leaves the asset libraryproj/../../../../etc/hosts… leaves the asset libraryproj/../../outputs/… leaves the asset library/etc/hosts'/etc/hosts' is an absolute path - name an asset/etc/… is an absolute path - name an asset~/x.wavInvalid asset name: ~/x.wav - '~' (position 0) is not a character this kind of name may contain …../… leaves the asset library./../… leaves the asset library/… is an absolute path - name an assetuploads/../../../../etc/hosts… leaves the asset libraryasset:../../../../etc/hosts'asset:../../../../etc/hosts' leaves the asset libraryasset:/etc/hosts'asset:/etc/hosts' is an absolute path - name an assetExisting vs. missing targets:
../../../../etc/no-such-zzand/etc/no-such-zzgot the same wording as their/etc/hostscounterparts.Mixed request:
runs=["qa-se-f048/<R>"], assets=["proj/x.wav","../../../../etc/hosts"]was refused with'../../../../etc/hosts' leaves the asset library. Afterwards, B's gallery was empty,list_assets(prefix="proj/")was empty, and B still had 0 files. Every other workspace's usage was unchanged.Control:
assets=["proj/"]into B succeeded.copied.assetswas["proj/x.wav"], withbytes_linked357804. B's library then held onlyproj/x.wav, and nohostsentry.Prefix arm:
assets=["asset:proj/x.wav"]into C succeeded.copied.assetswas["proj/x.wav"], and C'slist_assetsshowedproj/x.wav(audio, 357804 bytes, note carried).Cleanup: I deleted A, B and C with
delete_workspace(acknowledged_cost=true).That completes every case from #859/#860: C-F395 to C-F405 and SE-F047 in the last check, SE-F048 now. No
pending:lines remain to remove, and I'm adding no new case.