Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
12 KiB
The snapshot cycle — how a sandbox gets made, driven and closed
Commission, 2026-09-01: "snapshot功能重构。功能变成保存一份本地detach模型带 时间戳,然后关闭detach模型的revit session,重开这个新的detach模型,做你需要的 所有操作,然后自动关闭。"
One command does all of it and needs nobody at the keyboard:
python tools/snapshot_cycle.py --fresh-detach
--knowledge is no longer required. Off-Revit there is no doc.Title to pick
a job folder by, so WILLTOOLS_JOBS is asked directly (see
WillTools.extension/lib/vibe/joblocate.py for the full rule): a single job
folder under it is used with no further input; several are a refusal that
names every one of them rather than a guess, and --job "<name>" is how you
pick one. --knowledge "<a job knowledge file>" still names the file outright
and beats all of that when you would rather not rely on WILLTOOLS_JOBS at all.
A blocker that used to just say snapshot.detached_dir is not set now says
whether a job layer was ever read in the first place, and where -- "found at
<path>, but it does not set this" and "none was read (<reason>)" are
different sentences, on purpose.
Five steps, each of which journals so a silent failure is not possible:
| 1 DETACH | write <detached_dir>/<title>_<stamp>/<title>.rvt |
| 2 CYCLE | close the sandbox Revit, launch a new one on that file |
| 3 CONFIRM | find the bridge port that is actually the sandbox |
| 4 BATCH | run each --run command against it, tee'd into the snapshot |
| 5 CLOSE | close the sandbox again, journal the run |
Which detach mechanism shipped, and why not the obvious one
The route this was commissioned expecting was an in-session background open:
Application.OpenDocumentFile(cloudPath, OpenOptions with DetachFromCentralOption.DetachAndPreserveWorksets) inside the live session,
then SaveAs. That route does not exist for a cloud model. Three separate
facts, and each one is on its own sufficient:
-
A cloud model cannot be opened detached at all. Setting any
DetachFromCentralOptionon a cloudModelPathraises "Detach option is not valid for cloud model." Autodesk's own answer on the API forum is that the Revit team does not allow cloud models to be detached and that SaveAs is the recommended way. The UI agrees — "Detach from Central" is greyed out for anything opened from Autodesk Docs, because cloud worksharing is not the worksharing that option belongs to.- https://forums.autodesk.com/t5/revit-api-forum/how-to-open-and-detach-a-cloud-workshared-model/td-p/10371092
- https://www.autodesk.com/support/technical/article/caas/sfdcarticles/sfdcarticles/Detach-from-Central-grayed-out-when-opening-from-BIM-360-shared-file-in-Revit.html
Both read 2026-09-01.
OpenDocumentFile's own 2024 reference confirms the background-open semantics ("opens the document into memory but does not make it visible to the user") and documents no detach behaviour for cloud paths at all. -
Revit holds one Document per model per session. The live session already has that model open, so it cannot open it a second time.
-
SaveAsre-points the document it is called on, and there is noSaveCopyAsin the API. Called on the live document it would leave the owner's Revit sitting in a local copy, disconnected from the cloud — the one outcome this whole feature exists not to cause.
So the copy is made by a Revit that is not his. pyrevit run starts a
headless session, WillTools.extension/runners/detach_snapshot.py opens the
cloud model plainly in it and SaveAss to a new local central — which
is what severs it — then quits. The same runner still does a real
open-detached for a file-path source, where detaching is legal; the branch
is _open_detached(..., is_cloud).
That branch is also a bug fix. Until 2026-09-01 the runner set
DetachAndPreserveWorksetson every open including the cloud path, which is the path this job actually takes. It would have thrown on the first live press. Phase C end-to-end had never been run against the cloud job — it was on the needs-bridge list inQNA/review-2026-08-30.md.
What is left of "the button does it in-session" is the one thing that
genuinely requires the live session: the model's identity. A cloud model
is found by its region and two GUIDs and nothing on the machine knows them
except doc.GetCloudModelPath() in a session with the model open. The button
reads them and writes <detached_dir>/source.json; every later run reads that
file and needs no Revit at all. One button press is the whole bootstrap.
Memory: never three
The old design waited for every Revit to exit, on the premise that the machine cannot hold two copies of a 300 MB model. That premise is retired — the live cloud session and a detached sandbox ran side by side for a whole morning on 2026-09-01, and two Revit 2024 processes were resident while this was written. What nobody has measured is three, so the pipeline sequences rather than waits:
close the sandbox -> run the detach Revit -> it exits -> open the new sandbox
At no instant are there more than the owner's live session and one other. That is why the detach comes before the relaunch: it is the only ordering where the transient Revit and the sandbox are never resident together.
tools/snapshot_defer.py's machine-wide gate is not deleted — it is now
--machine-free require, opt-in, and the close-model button still passes it.
The port race, and why /status can now refuse
pyRevit does not pin the routes port: serverinfo._get_next_available_port
starts at the configured one and walks up past every port a running Revit
already holds. So 48884 is "whichever Revit started first", not a session
anybody chose. Restart the sandbox while the live session is up and every
request afterwards lands in the live model — and it all looks normal, because
the snapshot copy carries the same doc.Title as the model it came from
(deliberately: snapshotmeta.plan's default layout keeps the title constant
so the job folder does not move).
Two mechanical fixes, and they need each other:
The bridge refuses. startup.py asks vibe/bridgeguard.py before every
route — /status included — and refuses when the open document's title is on
bridge.refuse_titles. The refusal names the title and the pid, and carries
refused: true so a caller can tell "the live Revit holds this port" apart
from "the sandbox is not up yet". Those two look identical from outside and
need opposite responses.
A title alone cannot decide it, so a document on the list can still prove it is a copy:
- its file lives under
snapshot.detached_diror abridge.allow_rootsentry, or - its file is the model named by
WILLTOOLS_SANDBOX_MODELin that Revit's own environment, which onlytools/snapshot_cycle.pysets, on the session it launched.
The live cloud model has no local PathName and can offer neither.
The client can be pointed. tools/rvt.py reads WILLTOOLS_BRIDGE_PORT,
and the pipeline scans bridge.port .. +bridge.port_span, picks the session
whose title and file match the snapshot it just opened, and hands that port
down to every batch command in the environment.
There is no off switch. If the knowledge layers cannot be read at all, the
bridge refuses everything and says so; WILLTOOLS_BRIDGE_REFUSE_TITLES
(semicolon separated) is the way to answer that, because it states the list
rather than disabling the check.
What it will not kill
taskkill is only ever aimed at a pid the pipeline can prove is a sandbox:
- one it launched itself this run;
- one whose own bridge reported a document under a snapshot root — the document's statement, not a human's;
- one named with
--sandbox-pidthat is a liveRevit.exe.
Anything else is refused with a sentence saying so. And a pid that any bridge port answered a live-model refusal from is refused even when named explicitly: the flag is a human remembering a number, the refusal is the document itself.
Graceful taskkill first, forced only after it will not go — Revit flushes
journals and drops file locks on the way out, and the sandbox has nothing
worth saving because nothing ever wrote to it.
The button
SnapshotClose.pushbutton still indexes the open model (phase A) and now
offers four choices:
| Snapshot, then run the sandbox cycle | index, write the identity, spawn the pipeline. Revit stays open. |
| Snapshot, then close Revit | the old ritual, unchanged |
| Snapshot only, stay open | index and stop |
| Cancel | nothing at all |
snapshot.after_close is kept, not retired. It gates the close-model
route and nothing else — that is what it has always meant. The cycle route
does not read it, because the cycle is not chained to an exit and "after
close" has nothing to say about it; making one key mean two things is how a
setting stops being trustworthy.
"After close" means the Revit PROCESS exiting, watched by its pid — not
the document closing inside a Revit that stays open. tools/snapshot_defer.py --wait-pid <pid> polls process_alive(pid); there is no document-close
event anywhere in this design. Closing one job document and opening a
different one in the same still-running Revit — which is a completely
ordinary thing to do, and is what actually happened on 2026-09-01 — never
starts the wait, because the pid it would watch never goes away. If you
picked CLOSE from the button and then only closed the document, the
after-close copy was never requested to begin with; if you picked CYCLE,
no pid is watched at all (see the row above), so this distinction does not
even apply to that route.
Configuration
All of it lands in knowledge.DEFAULTS with the existing comment discipline.
Every value that names a model or a machine belongs in the job (or plant)
layer; the tracked code knows only the keys.
| key | default | what it is |
|---|---|---|
snapshot.detached_dir |
null |
where copies go. null = off |
snapshot.after_close |
false |
chain the OLD route from the close |
snapshot.layout |
folder |
stamp in the folder, title stays put |
snapshot.revit_year |
null |
which Revit the detach opens. Unset falls back to whatever year is folded into snapshot.revit_exe's own path (vibe.snapshotmeta.revit_year_from_exe) before falling back to letting pyrevit run choose |
snapshot.revit_exe |
null |
Revit itself, for the sandbox. null = derive from the year (a guess, checked) |
snapshot.batch |
['python tools/sweep.py'] |
what runs inside the sandbox |
snapshot.sandbox_wait_minutes |
15 |
how long the sandbox gets to answer |
snapshot.worksets |
preserve |
forced to a new central for a cloud source |
snapshot.wait_minutes |
20 |
the OLD route's wait for Revit to exit |
snapshot.pyrevit_exe |
null |
the pyRevit CLI |
bridge.refuse_titles |
[] |
titles the bridge must never serve |
bridge.allow_roots |
[] |
extra folders whose models are sandboxes |
bridge.port |
48884 |
where the port scan starts |
bridge.port_span |
4 |
how far up it walks |
snapshot.source_cloud / source_path / source_title may also be stated by
hand, if you would rather not bootstrap from a button press.
Empty is the only honest default for refuse_titles: the tool does not know
which of a machine's models is somebody's live work.
Tests
WillTools.extension/tests/test_bridgeguard.py— the refusal decision, by calling it, plus AST checks that every route instartup.pyasks.WillTools.extension/tests/test_snapshot_cycle.py— the pid guard, the status verdicts, the port scan, settings resolution, the source cache, the dry run, and nowresolve_job_layer/ the blocker wording it feeds.WillTools.extension/tests/test_joblocate.py— the job-layer resolution RULE itself (found / not configured / ambiguous / explicit), independent of this pipeline;tools/knowledge_drift.pyreuses the same module.WillTools.extension/tests/test_snapshotmeta.py— the cycle ledger and the batch log naming, alongside the existing phase ledger.
Nothing here can open Revit, so nothing here proves the pipeline works live. The first live run is a scheduled thing, not something to slip into a session that has a sweep in flight — a Revit restart mid-sweep destroys the sweep.