25592fa6c2
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.
254 lines
12 KiB
Markdown
254 lines
12 KiB
Markdown
# 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.
|