WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
1. **A cloud model cannot be opened detached at all.** Setting any
|
||||
`DetachFromCentralOption` on a cloud `ModelPath` raises *"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.
|
||||
|
||||
2. **Revit holds one Document per model per session.** The live session
|
||||
already has that model open, so it cannot open it a second time.
|
||||
|
||||
3. **`SaveAs` re-points the document it is called on**, and there is no
|
||||
`SaveCopyAs` in 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 `SaveAs`s 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
|
||||
> `DetachAndPreserveWorksets` on 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 in `QNA/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_dir` or a `bridge.allow_roots`
|
||||
entry, or
|
||||
- its file is the model named by `WILLTOOLS_SANDBOX_MODEL` in that Revit's own
|
||||
environment, which only `tools/snapshot_cycle.py` sets, 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-pid` that is a live `Revit.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 in `startup.py` asks.
|
||||
- `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 now `resolve_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.py` reuses 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.
|
||||
Reference in New Issue
Block a user