Files
willtools-demo/docs/snapshot-cycle.md
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

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.