Files
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

1159 lines
51 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Make a timestamped detached copy, cycle a Revit onto it, run a batch, close.
python tools/snapshot_cycle.py --fresh-detach --run "python tools/sweep.py"
python tools/snapshot_cycle.py --use "<a snapshot>.rvt"
python tools/snapshot_cycle.py --fresh-detach --dry-run
The commission, 2026-09-01, verbatim: "snapshot功能重构。功能变成保存一份本地
detach模型带时间戳,然后关闭detach模型的revit session,重开这个新的detach模型,
做你需要的所有操作,然后自动关闭。"
Five steps, and each one is a thing that can fail on its own, so each one
journals:
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 IT IS NOT THE OBVIOUS ONE
------------------------------------------------------------------
The obvious route -- and the one this job was commissioned expecting -- is to
open the cloud model a second time INSIDE a session that is already running,
as a background document, with
OpenOptions().DetachFromCentralOption = DetachAndPreserveWorksets
and `SaveAs` it. **That route does not exist for a cloud model**, and this is
not a version guard or a maybe:
* Setting any `DetachFromCentralOption` on a cloud `ModelPath` raises
*"Detach option is not valid for cloud model."*
* Autodesk's answer on the API forum is that the Revit team does not allow
cloud models to be detached, that **SaveAs is the recommended way** to get a
detached copy, and that the API is aligned to the UI here.
* The UI agrees: the "Detach from Central" checkbox is greyed out for
anything opened from Autodesk Docs, because cloud worksharing is not the
worksharing that option belongs to.
forums.autodesk.com/t5/revit-api-forum/how-to-open-and-detach-a-cloud-
workshared-model/td-p/10371092
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)
And the live model on this job IS a cloud model. Two further facts finish the
argument even if that one were worked around:
* Revit holds one Document per model per session. The live session already
has that model open, so it cannot open it again.
* The one call that would make a copy out of the document already in memory
is `doc.SaveAs`, and `SaveAs` RE-POINTS the document it is called on. Run
in the owner's session it would leave HIS Revit sitting in the local copy,
disconnected from the cloud. There is no SaveCopyAs in the API. That is the
single 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, `runners/detach_snapshot.py` opens the cloud model plainly
in it, `SaveAs`s to a new LOCAL CENTRAL (which is what severs it), and quits.
That runner also handles the file-path case, where a real open-detached is
legal and is still used.
What is left of "the button does it in-session" is the part that genuinely
needs the live session and nothing else: the model's IDENTITY. A cloud model
is found by its region and two GUIDs, and nothing on this machine knows those
except `doc.GetCloudModelPath()` inside a session that has the model open. So
the button reads them, writes the request, spawns this pipeline, and gets out
of the way -- and both of them write the identity to
`<detached_dir>/source.json`, which is what lets every LATER run start from a
terminal with no Revit anywhere. 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 --
live and sandbox ran side by side all morning on 2026-09-01 -- but the
untested number is three, so this SEQUENCES instead of waiting:
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 ordering is also why the detach comes before the relaunch and not after:
it is the only arrangement where the transient Revit and the sandbox are
never resident together.
WHAT IT WILL NOT KILL
----------------------
`taskkill` is only ever aimed at a pid this pipeline can PROVE is a sandbox:
* one it launched itself in this run;
* one whose OWN BRIDGE reported a document whose file is under a configured
snapshot root -- the same proof `vibe/bridgeguard.py` accepts, made by
the document rather than by a human. The live cloud model has no local
file and can never satisfy it;
* one named with `--sandbox-pid` that is a live `Revit.exe`.
Anything else is refused with a sentence saying so. And a pid that a bridge
port reports as a REFUSED session -- i.e. the live model -- is refused even
when it was named explicitly, because the flag is the caller's assertion and
the refusal is the model's own.
Host CPython 3.11. `vibe` modules are loaded by path, the same way
`tools/snapshot_defer.py` does it, because importing the package runs
`vibe/__init__.py`, which pulls in the Revit API and dies.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
import types
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TOOLS = os.path.join(ROOT, 'tools')
VIBE = os.path.join(ROOT, 'WillTools.extension', 'lib', 'vibe')
EXT = os.path.join(ROOT, 'WillTools.extension')
RVT = os.path.join(TOOLS, 'rvt.py')
RUNNER = os.path.join(EXT, 'runners', 'detach_snapshot.py')
if 'vibe' not in sys.modules:
_stub = types.ModuleType('vibe')
_stub.__path__ = [VIBE]
_stub.__file__ = os.path.join(VIBE, '__init__.py')
sys.modules['vibe'] = _stub
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
from vibe import bridgeguard as G # noqa: E402
from vibe import joblocate as JL # noqa: E402
from vibe import knowledge as K # noqa: E402
from vibe import snapshotmeta as S # noqa: E402
from vibe import yamlkit # noqa: E402
import snapshot_defer as DEFER # noqa: E402
PORT_ENV = 'WILLTOOLS_BRIDGE_PORT'
DEFAULT_PORT = 48884
# How many ports up from the configured one to look for the sandbox. pyRevit
# walks upward one at a time per running Revit, and nobody on this machine
# runs four at once; a wider scan only makes a failure slower to report.
PORT_SPAN = 4
# How long Revit gets to start, load pyRevit, open a 300 MB model and answer
# a request. Measured nowhere -- generous on purpose, and reported as a
# timeout rather than assumed away.
SANDBOX_WAIT_MINUTES = 15.0
POLL_SECONDS = 10.0
# Revit does not release a model the instant its window closes. Same reason
# as `snapshot_defer.SETTLE_SECONDS`, shorter because nothing here is about
# to open the file that was just closed.
CLOSE_SETTLE_SECONDS = 10.0
# ------------------------------------------------------------------ settings
def read_knowledge(path):
"""A job/plant knowledge YAML merged onto `knowledge.DEFAULTS`.
Read with `vibe.yamlkit`, not PyYAML: yamlkit ships with the extension,
has no third-party dependency, and is the same reader the extension uses
under IronPython -- so a file this pipeline can read is a file the buttons
can read, and a file it cannot is a file worth hearing about.
A file that will not parse is NOT silently ignored. It comes back as an
error string, and `main` refuses on it, because "there is no denylist" and
"I could not read the denylist" are the same two answers the bridge guard
keeps apart and this is the other end of the same wire.
"""
merged = K._deep_copy(K.DEFAULTS)
if not path:
return merged, None
if not os.path.isfile(path):
return merged, 'no such knowledge file: {0}'.format(path)
try:
with open(path, 'r', encoding='utf-8') as handle:
text = handle.read()
data = yamlkit.parse(text)
except Exception as exc:
return merged, 'could not read {0}: {1}'.format(path, exc)
if not isinstance(data, dict):
return merged, '{0} did not parse as a mapping'.format(path)
if not data and _has_content(text):
# yamlkit returns an empty tree for a document it cannot make sense
# of -- a list at the top level, prose, a shape outside its subset --
# rather than raising. An empty result from a file with lines in it
# is therefore "I could not read this", and reading it as "this file
# configures nothing" would run the pipeline on defaults while the
# file plainly says otherwise.
return merged, ('{0} has content but yielded no settings -- it is '
'probably not in the YAML subset vibe.yamlkit reads '
'(block mappings, block and flow lists, comments)'
.format(path))
K._deep_update(merged, data)
return merged, None
def resolve_job_layer(args):
"""Which job knowledge.yaml this run reads, off-Revit. A `joblocate.JobLocateResult`.
`args.knowledge` (the `--knowledge` flag, defaulting to
`WILLTOOLS_JOB_KNOWLEDGE`) is the explicit case and always wins. Failing
that, `WILLTOOLS_JOBS` is asked -- the same variable `designspec.root_dir`
honours inside Revit, read here because off-Revit there is no `doc.Title`
to pick a job folder by. See `vibe.joblocate` for the full order and why
an ambiguous WILLTOOLS_JOBS refuses rather than guesses.
"""
return JL.resolve_job_knowledge(
explicit=args.knowledge,
jobs_root=os.environ.get(JL.JOBS_ENV),
job_name=getattr(args, 'job', None))
def _layer_clause(job_result):
"""Why a setting that should come from the job layer might be unset.
Two different facts, two different sentences -- collapsing them is
exactly the bug this module exists to fix (see the header). A layer that
was READ and simply does not set this key is not the same event as no
layer having been read at all, and a blocker message that cannot tell
them apart sends the reader to edit a file that was never opened, or to
hunt for a setting that was never looked for.
"""
if job_result is not None and job_result.path:
return ' (found at {0}, but it does not set this)'.format(
job_result.path)
if job_result is not None and job_result.reason:
return ' (none was read: {0})'.format(job_result.reason)
return ' (none was read)'
def _has_content(text):
"""True when a document has any line that is not blank or a comment."""
for line in (text or '').splitlines():
line = line.strip()
if line and not line.startswith('#'):
return True
return False
def resolve(args, know):
"""Every setting this run uses, from flags then knowledge then DEFAULTS.
Returned as one dict rather than read at the point of use, so `--dry-run`
prints exactly what the real run would do and the two cannot drift.
"""
# Every key is read with a LITERAL path through `knowledge.get`, not
# through a local `cfg()` wrapper. A wrapper reads better by two
# characters and makes every one of these keys invisible to
# `tools/knowledge_drift.py`, which reports a key nothing reads -- and a
# setting the file offers that the tool ignores is the exact drift that
# detector exists to catch.
detached_dir = args.detached_dir or K.get(know, 'snapshot.detached_dir')
revit_year = args.revit or K.get(know, 'snapshot.revit_year')
revit_exe = args.revit_exe or K.get(know, 'snapshot.revit_exe') \
or S.revit_exe_guess(revit_year)
if not revit_year:
# `pyrevit run` (the detach step, below) takes an optional
# `--revit=<year>` and this job layer is exactly the shape that
# leaves it unset: `snapshot.revit_exe` configured,
# `snapshot.revit_year` never separately stated. On a machine with
# more than one Revit version attached that omission is not neutral
# -- see `vibe.snapshotmeta.revit_year_from_exe` for the incident.
# `revit_exe`'s own path almost always carries the year, so recover
# it from there before falling back to "let pyRevit choose".
revit_year = S.revit_year_from_exe(revit_exe)
batch = list(args.run or []) or \
list(K.get(know, 'snapshot.batch', []) or [])
titles, roots = G.config_from(know, K.get)
# `config_from` reads the detached dir out of the knowledge tree, which
# misses it when it arrived on the command line instead. Without this, a
# `--detached-dir` run cannot recognise its own snapshots as sandboxes and
# would refuse to close the one it opened last time.
if detached_dir and not any(G.is_under(detached_dir, r) for r in roots):
roots.append(detached_dir)
return {
'detached_dir': detached_dir,
'layout': K.get(know, 'snapshot.layout', S.FOLDER),
'worksets': K.get(know, 'snapshot.worksets', 'preserve'),
'revit_year': revit_year,
'revit_exe': revit_exe,
'pyrevit_exe': args.pyrevit or K.get(know, 'snapshot.pyrevit_exe')
or _pyrevit_guess(),
'batch': batch,
'refuse_titles': titles,
'sandbox_roots': roots,
'wait_minutes': float(
args.wait_minutes if args.wait_minutes is not None
else K.get(know, 'snapshot.sandbox_wait_minutes',
SANDBOX_WAIT_MINUTES)),
'port': int(args.port if args.port is not None
else K.get(know, 'bridge.port', DEFAULT_PORT)),
'port_span': int(K.get(know, 'bridge.port_span', PORT_SPAN)),
'log': args.log or (S.log_path(detached_dir) if detached_dir else None),
# Only a live session knows a cloud model's GUIDs. Stating them in the
# job layer is what lets a host-only run find the model with no Revit
# anywhere -- see `source_from`.
'source_cloud': K.get(know, 'snapshot.source_cloud'),
'source_path': K.get(know, 'snapshot.source_path'),
'source_title': K.get(know, 'snapshot.source_title'),
}
def _pyrevit_guess():
"""Where pyRevit installs its CLI, when nobody configured one. A GUESS.
Delegates to `vibe.snapshotmeta.pyrevit_exe_guess` -- this used to be its
own copy of the same path, and `tools/snapshot_defer.py` used to have no
fallback at all, which is what let a run started with `--pyrevit`
forgotten die with no journal line. One guess, three callers now.
"""
return S.pyrevit_exe_guess()
def blockers(settings, args, source=None, job_result=None):
"""Every reason this run cannot start, as sentences. Empty means go.
All of them, not the first one: a pipeline that reports one missing
setting per attempt is a pipeline configured over four runs.
`job_result` (a `vibe.joblocate.JobLocateResult`) is what says, for each
setting that could have come from the job layer, whether that layer was
read at all and did not set it, or was never read in the first place --
see `_layer_clause`. `None` (every existing caller that predates it)
reads as "no information", which prints the same "no job knowledge layer
was read" clause as an explicit not-found result.
"""
out = []
if args.fresh_detach:
if not settings['detached_dir']:
out.append('snapshot.detached_dir is not set, so there is '
'nowhere to write the copy (--detached-dir, or '
'snapshot.detached_dir in the job knowledge layer{0})'
.format(_layer_clause(job_result)))
if not settings['pyrevit_exe']:
out.append('the pyRevit CLI was not found -- it is what starts '
'the Revit that writes the copy (--pyrevit, or '
'snapshot.pyrevit_exe in the job knowledge layer{0})'
.format(_layer_clause(job_result)))
if not has_source(source):
out.append('a fresh detach needs to know WHICH model to copy, '
'and a cloud model is only identifiable from a '
'session that has it open. Press the Snapshot button '
'once (it writes the identity and caches it), or pass '
'--request / --source, or state '
'snapshot.source_cloud in the job knowledge layer'
'{0}.'.format(_layer_clause(job_result)))
if not args.use and not args.fresh_detach:
out.append('say --fresh-detach or --use <a snapshot>')
if args.use and not os.path.isfile(args.use):
out.append('--use names no file: {0}'.format(args.use))
if not settings['revit_exe']:
out.append('no Revit.exe to open the sandbox with (--revit-exe, or '
'snapshot.revit_exe, or snapshot.revit_year in the job '
'knowledge layer{0})'.format(_layer_clause(job_result)))
elif not os.path.isfile(settings['revit_exe']):
out.append('Revit.exe is not at {0} (set snapshot.revit_exe)'
.format(settings['revit_exe']))
if not settings['batch']:
out.append('no batch to run -- pass --run "<command>" or set '
'snapshot.batch in the job knowledge layer{0}'
.format(_layer_clause(job_result)))
return out
# ------------------------------------------------------- what may be killed
def image_of(pid, run=None):
"""The executable name behind a pid, or None when it cannot be read.
None is a real answer here and is treated as one: `may_kill` refuses on
it rather than assuming. Being wrong in that direction costs a message;
being wrong the other way ends the owner's Revit session.
"""
if not pid:
return None
run = run or (lambda argv: subprocess.run(
argv, capture_output=True, text=True, timeout=30).stdout)
try:
out = run(['tasklist', '/FI', 'PID eq {0}'.format(int(pid)), '/NH',
'/FO', 'CSV'])
except Exception:
return None
for line in (out or '').splitlines():
line = line.strip()
if not line or line.startswith('INFO:'):
continue
if line.startswith('"'):
name = line.split('","')[0].strip('"')
if name:
return name
return None
def sandbox_pids_from(seen, roots):
"""Pids that PROVED they are a sandbox, out of a port survey.
The proof is the same one `vibe/bridgeguard.py` accepts from the other
side of the wire: the session said, over its own bridge, that its open
document's file lives under a configured snapshot root. A live cloud
model has no local file at all and can never satisfy this, and neither
can a Revit somebody opened on something else.
This is what lets the button spawn the pipeline without knowing a pid --
and therefore what stops a stale sandbox being left resident beside the
live session and the transient detach Revit, which would be three.
"""
out = []
for row in (seen or ()):
if row.get('verdict') == 'refused' or not row.get('pid'):
continue
path = row.get('doc_path')
if not path:
continue
for root in (roots or ()):
if root and G.is_under(path, root):
try:
out.append(int(row['pid']))
except (TypeError, ValueError):
pass
break
return out
def may_kill(pid, launched=(), explicit=None, image=None, live_pids=(),
proven=()):
"""None when `pid` may be closed, else the one-line refusal.
Four ways a pid can be allowed, in order of how much they prove:
launched here this run started it. Nothing to argue about.
proven its own bridge said its model is under a snapshot
root -- the document's statement, not a human's.
--sandbox-pid the caller asserts it, AND it is a live Revit.exe.
(nothing else) refused.
And one way it can be forbidden even so: `live_pids` is every pid a
bridge port answered a REFUSAL from, which is the model's own statement
that it is the live one. That beats the flag, because a flag is a human
remembering a number and the refusal is the document itself.
"""
try:
pid = int(pid or 0)
except (TypeError, ValueError):
pid = 0
if not pid:
return ('no sandbox pid to close -- nothing was launched by this run '
'and --sandbox-pid was not given')
if pid in set(int(p) for p in live_pids or ()):
return ('REFUSING to close pid {0}: a bridge on this machine answers '
'from that process with a live-model refusal, so it is the '
'session the owner is working in.'.format(pid))
if pid in set(int(p) for p in launched or ()):
return None
if pid in set(int(p) for p in proven or ()):
return None
try:
explicit = int(explicit) if explicit else 0
except (TypeError, ValueError):
explicit = 0
if pid != explicit:
return ('REFUSING to close pid {0}: this run did not launch it and it '
'was not named with --sandbox-pid, so nothing here can prove '
'it is the sandbox rather than the live model.'.format(pid))
if not image:
return ('REFUSING to close pid {0}: it was named with --sandbox-pid '
'but its process image could not be read, so it cannot be '
'confirmed to be a Revit at all.'.format(pid))
if image.strip().lower() != 'revit.exe':
return ('REFUSING to close pid {0}: --sandbox-pid named it but it is '
'{1}, not Revit.exe.'.format(pid, image))
return None
# ---------------------------------------------------------------- the bridge
def bridge_env(port, extra=None):
"""The environment a bridge call (or a batch command) runs with.
The port travels in the environment rather than on a command line because
the batch commands are the owner's own strings -- `python tools/sweep.py`
-- and they call `rvt.py` themselves. An environment variable reaches
through that; a flag would have to be threaded through every one of them.
"""
env = dict(os.environ)
env[PORT_ENV] = str(int(port))
if extra:
env.update(extra)
return env
def ask_status(port, python=None, run=None):
"""`rvt.py status` on one port, parsed. None when nothing answered."""
python = python or sys.executable
run = run or (lambda argv, env: subprocess.run(
argv, cwd=ROOT, env=env, capture_output=True, text=True, timeout=120))
try:
result = run([python, RVT, 'status'], bridge_env(port))
except Exception:
return None
for text in ((result.stdout or '').strip(), (result.stderr or '').strip()):
if not text:
continue
try:
body = json.loads(text)
except Exception:
continue
if isinstance(body, dict):
return body
return None
def status_verdict(body, want_title):
"""What one `/status` answer means for us.
('up' | 'refused' | 'other-model' | 'silent', sentence)
`refused` is the one worth the extra branch. It means this port belongs to
a session whose document is on `bridge.refuse_titles` -- the live model.
It is NOT "not up yet", and retrying it forever is how a pipeline waits
out a timeout while the answer was there in the first second.
"""
if not isinstance(body, dict):
return 'silent', 'nothing answered on this port'
if body.get('refused'):
return 'refused', (body.get('error') or
'the bridge refused: live model')
if not body.get('ok'):
return 'silent', str(body.get('error') or 'the bridge answered not-ok')
here = G.normalize_title(body.get('doc_title'))
if want_title and here != G.normalize_title(want_title):
return 'other-model', ('this Revit has "{0}" open, not "{1}"'
.format(body.get('doc_title'), want_title))
return 'up', 'ready'
def find_sandbox(port, span, want_title, want_path=None, ask=None,
stop_at_first=True):
"""(port, seen) -- the session holding our snapshot, plus what was seen.
Returns `(None, seen)` when no port has it. `seen` is every port that
answered anything, with its verdict, so a failure can print the whole
picture instead of "timed out" -- which is the difference between "the
sandbox is slow" and "the live Revit took the port and yours is on 48885
but has the wrong model open".
`stop_at_first=False` is the survey: it visits every port even after a
match, which is what the pre-flight scan wants -- its whole job is to
find the LIVE session's pid, and that session may be sitting on a port
above one that answers.
"""
ask = ask or ask_status
seen = []
found = None
for candidate in range(int(port), int(port) + int(span)):
body = ask(candidate)
verdict, why = status_verdict(body, want_title)
seen.append({'port': candidate, 'verdict': verdict, 'why': why,
'pid': (body or {}).get('pid'),
'doc_path': (body or {}).get('doc_path'),
'doc_title': (body or {}).get('doc_title')})
if verdict != 'up':
continue
if want_path:
here = (body or {}).get('doc_path')
# A MISSING path is a mismatch, not an excuse to skip the check.
# That was a real hole: the live cloud session reports no local
# `PathName` at all, and its title is identical to the snapshot's
# -- so "no path, so nothing to compare" accepted the live model
# as the sandbox and would have pointed the whole batch at it.
# This is the same asymmetry `bridgeguard` uses from the other
# side: no file means no proof, and no proof is a no.
if not here:
seen[-1]['verdict'] = 'other-model'
seen[-1]['why'] = ('right title, but it reports no local file '
'-- that is a cloud session, not the '
'snapshot at {0}'.format(want_path))
continue
if not G.same_model(here, want_path):
seen[-1]['verdict'] = 'other-model'
seen[-1]['why'] = ('right title, but the file is {0}, not {1}'
.format(here, want_path))
continue
if found is None:
found = candidate
if stop_at_first:
return found, seen
return found, seen
def live_pids_from(seen):
"""The pids of every session that answered a live-model refusal."""
out = []
for row in seen or ():
if row.get('verdict') == 'refused' and row.get('pid'):
try:
out.append(int(row['pid']))
except (TypeError, ValueError):
continue
return out
# ------------------------------------------------------------ doing the work
def journal(log, event, run_id, **fields):
if not log:
return
row = {'event': event, 'run': run_id}
row.update(fields)
S.append(log, row)
def kill(pid, timeout=60):
"""`taskkill /PID <pid>` -- graceful first, then forced. (ok, note)
Graceful first because Revit flushes journals and drops file locks on the
way out and a forced kill skips all of it; forced afterwards because a
Revit sitting on "save changes?" will not take the polite one, and a
sandbox nobody is watching has nothing worth saving -- it is a copy, and
the pipeline never wrote to it.
"""
try:
subprocess.run(['taskkill', '/PID', str(int(pid))],
capture_output=True, text=True, timeout=timeout)
except Exception as exc:
return False, 'graceful taskkill failed: {0}'.format(exc)
deadline = time.time() + timeout
while time.time() < deadline:
if not DEFER.process_alive(pid):
return True, 'closed'
time.sleep(2)
try:
subprocess.run(['taskkill', '/F', '/T', '/PID', str(int(pid))],
capture_output=True, text=True, timeout=timeout)
except Exception as exc:
return False, 'forced taskkill failed: {0}'.format(exc)
time.sleep(2)
return (not DEFER.process_alive(pid)), 'forced'
def launch_sandbox(revit_exe, model_path):
"""Start Revit on one model. Returns the pid.
`WILLTOOLS_SANDBOX_MODEL` goes into that process's environment and is the
sandbox's proof of identity: `vibe/bridgeguard.py` lets a document whose
title is on the denylist through when its file is the one named there.
A Revit started from the taskbar has no such variable, which is exactly
the distinction that has to be drawn -- the snapshot copy carries the
same title as the live model on purpose.
"""
argv = S.revit_exe_argv(revit_exe, model_path)
env = dict(os.environ)
env[G.ENV_SANDBOX] = model_path
proc = subprocess.Popen(argv, env=env, cwd=os.path.dirname(revit_exe))
return proc.pid
def batch_popen(command, log_path, env, cwd=None):
"""Run one batch command, tee'd into `log_path`. Returns the exit code.
Tee, not redirect: the operator watching this run and the file that has
to survive it both need the output, and a run whose only record is a
console nobody kept is a run that did not report.
"""
shell = not isinstance(command, (list, tuple))
with open(log_path, 'w', encoding='utf-8', errors='replace') as handle:
handle.write('$ {0}\n\n'.format(
command if shell else S.quote(command)))
handle.flush()
proc = subprocess.Popen(
command, shell=shell, cwd=cwd or ROOT, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
errors='replace')
for line in iter(proc.stdout.readline, ''):
sys.stdout.write(line)
handle.write(line)
handle.flush()
proc.stdout.close()
return proc.wait()
SOURCE_CACHE = 'source.json'
def source_cache_path(detached_dir):
return os.path.join(detached_dir, SOURCE_CACHE) if detached_dir else None
def source_from(args, settings=None):
"""The `source` block the detach runner needs. (source, request-or-None)
Four places, in order of how directly they were asked:
--request the button's handoff, written by the live session
--source a model on disk, stated on the command line
the job layer `snapshot.source_cloud` / `snapshot.source_path`
the cache `<detached_dir>/source.json`, left by the last run
THE CACHE IS WHAT MAKES THIS RUNNABLE WITHOUT A REVIT AT ALL, and it is
worth being explicit about why one is needed. A cloud model is found by
its region and its two GUIDs, and those live nowhere on this machine
except inside a session that has the model open -- `doc.GetCloudModelPath()`
is the only thing that knows them. So the FIRST cycle has to come from
the button, or from a hand-written `snapshot.source_cloud`; after that
the identity is on disk and the pipeline needs nobody.
The cache is identity, not state: it says which model to copy, never
anything about a copy that was made.
"""
if args.request:
with open(args.request, 'r', encoding='utf-8') as handle:
request = json.loads(handle.read())
return request.get('source') or {}, request
if args.source:
return {'path': args.source, 'cloud': None,
'title': os.path.splitext(
os.path.basename(args.source))[0]}, None
settings = settings or {}
cloud = settings.get('source_cloud')
path = settings.get('source_path')
if cloud or path:
return {'path': path, 'cloud': cloud,
'title': settings.get('source_title')}, None
cache = source_cache_path(settings.get('detached_dir'))
if cache and os.path.isfile(cache):
try:
with open(cache, 'r', encoding='utf-8') as handle:
return json.loads(handle.read()) or {}, None
except Exception:
return {}, None
return {}, None
def remember_source(detached_dir, source):
"""Write the identity down so the next run needs no Revit. Never raises.
A failure to cache must not become a failure to make the copy -- the run
that is happening matters more than the run that might.
"""
path = source_cache_path(detached_dir)
if not path or not source or not (source.get('cloud') or
source.get('path')):
return None
try:
if not os.path.isdir(detached_dir):
os.makedirs(detached_dir)
with open(path, 'w', encoding='utf-8') as handle:
handle.write(json.dumps(source, indent=2, sort_keys=True))
return path
except Exception:
return None
def has_source(source):
"""True when this block names a model something could actually reopen."""
return bool((source or {}).get('cloud') or (source or {}).get('path'))
def base_title(source, args):
"""The name the copy is saved under -- the SaveAs base."""
if args.base:
return args.base
title = (source or {}).get('title')
if title:
return title
path = (source or {}).get('path')
if path:
return os.path.splitext(os.path.basename(path))[0]
return 'untitled'
# ------------------------------------------------------------------- the run
def _early_log(args):
"""Best-effort `<detached_dir>/snapshots.jsonl`, before `resolve()` has
run and therefore before `settings['log']` exists.
Three of this run's failure branches -- an ambiguous job layer, a job
knowledge file that will not parse, and the ordinary `blockers()` list --
all happen before `settings` is built, and until 2026-09-02 all three
only ever reached `print(..., file=sys.stderr)`. That is a real journal
gap and not a small one: the button spawns this whole pipeline with
`CreateNoWindow = True` and no output redirection at all
(`SnapshotClose.pushbutton/script.py`'s `_spawn`), so stderr from a
button-launched run is not merely easy to miss, it is not connected to
anything that could show it. `--detached-dir` is what the button always
passes explicitly, so it is tried first; a bare `--request` (a run
started from the request file alone) falls back to the same
folder-structure recovery `tools/snapshot_defer.py`'s
`fallback_log_for` uses.
"""
if args.detached_dir:
return S.log_path(args.detached_dir)
if args.request:
try:
requests_dir = os.path.dirname(os.path.abspath(args.request))
except Exception:
return None
if os.path.basename(requests_dir) == '_requests':
return S.log_path(os.path.dirname(requests_dir))
return None
def _journal_early_failure(args, message, log=None):
"""`print` the refusal (as every caller already did) AND, when a log can
be found at all, journal it too -- see `_early_log`.
`log` lets a caller that already has `settings['log']` (the `blockers()`
branch, which runs after `resolve()`) hand over the real, resolved path
instead of `_early_log`'s cruder guess from `--detached-dir` alone.
"""
print('cannot start: {0}'.format(message), file=sys.stderr)
log = log or _early_log(args)
if log:
journal(log, S.CYCLE_FAILED,
'c{0}'.format(time.strftime('%Y%m%d-%H%M%S')),
error=message)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--fresh-detach', action='store_true',
help='write a new timestamped copy first')
ap.add_argument('--use', metavar='MODEL',
help='skip the detach and cycle onto this file')
ap.add_argument('--run', action='append', metavar='COMMAND',
help='a host command to run inside the sandbox; repeat '
'for more. Default: snapshot.batch')
ap.add_argument('--request', help='the request file the button wrote '
'(carries the cloud GUIDs)')
ap.add_argument('--source', help='a model path to copy, when there is no '
'request file')
ap.add_argument('--base', help='the SaveAs base name for the copy')
ap.add_argument('--sandbox-pid', type=int, default=0,
help='close this Revit before opening the new one. Only '
'a pid this pipeline launched, or one named here, '
'is ever closed.')
ap.add_argument('--knowledge', metavar='YAML',
default=os.environ.get('WILLTOOLS_JOB_KNOWLEDGE'),
help='a job knowledge file to read settings from')
ap.add_argument('--job', metavar='NAME',
help='which job folder under WILLTOOLS_JOBS to read '
'(only needed when more than one exists there and '
'--knowledge was not given)')
ap.add_argument('--detached-dir')
ap.add_argument('--revit', help='Revit year, e.g. 2024')
ap.add_argument('--revit-exe')
ap.add_argument('--pyrevit', help='path to pyrevit.exe')
ap.add_argument('--log', help='snapshots.jsonl to journal into')
ap.add_argument('--port', type=int, default=None)
ap.add_argument('--wait-minutes', type=float, default=None)
ap.add_argument('--keep-open', action='store_true',
help='leave the sandbox Revit running after the batch')
ap.add_argument('--dry-run', action='store_true',
help='print the whole plan and do nothing at all')
args = ap.parse_args(argv)
job_result = resolve_job_layer(args)
if job_result.path is None and job_result.blocking:
# Ambiguous WILLTOOLS_JOBS, a --job that names nothing, or
# WILLTOOLS_JOBS pointed at something that is not a folder: all
# three are real misconfigurations, not "nothing to read yet", and
# guessing which job was meant is worse than stopping here to ask.
_journal_early_failure(args, job_result.reason)
return 2
know, know_error = read_knowledge(job_result.path)
if know_error:
_journal_early_failure(args, know_error)
return 2
settings = resolve(args, know)
source, _request = source_from(args, settings)
stops = blockers(settings, args, source, job_result)
if stops:
for line in stops:
_journal_early_failure(args, line, log=settings.get('log'))
return 2
run_id = 'c{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
stamp = S.stamp()
if args.use:
target = os.path.abspath(args.use)
folder = os.path.dirname(target)
title = os.path.splitext(os.path.basename(target))[0]
plan = {'path': target, 'folder': folder, 'title': title,
'stamp': S.split_stamped(os.path.basename(folder))[1] or stamp,
'layout': None, 'warning': None}
else:
plan = S.plan(settings['detached_dir'], base_title(source, args),
stamp, layout=settings['layout'])
target = plan['path']
folder = plan['folder']
log = settings['log']
if args.dry_run:
print('run id {0}'.format(run_id))
print('job knowledge {0}'.format(
'{0} ({1})'.format(job_result.path, job_result.how)
if job_result.path else '(none read -- {0})'.format(
job_result.reason or 'defaults only')))
print('detach {0}'.format(
'fresh, from {0}'.format(
'cloud GUIDs' if (source or {}).get('cloud') else
(source or {}).get('path') or '(no source)')
if args.fresh_detach else 'no -- reusing {0}'.format(args.use)))
print('target {0}'.format(target))
print('sandbox Revit {0}'.format(settings['revit_exe']))
print('close first pid {0}'.format(args.sandbox_pid or 'none'))
print('bridge ports {0}..{1}'.format(
settings['port'], settings['port'] + settings['port_span'] - 1))
print('refuse titles {0}'.format(
', '.join(settings['refuse_titles']) or '(none configured)'))
print('sandbox roots {0}'.format(
', '.join(settings['sandbox_roots']) or '(none configured)'))
print('log {0}'.format(log))
print('batch:')
for i, command in enumerate(settings['batch'], 1):
print(' {0} {1}'.format(
S.batch_log_name(i, command), command))
if args.fresh_detach:
print('\ndetach command:\n {0}'.format(S.quote(
S.pyrevit_run_argv(settings['pyrevit_exe'], RUNNER,
settings['revit_year']))))
print('\nsandbox command:\n {0}'.format(S.quote(
S.revit_exe_argv(settings['revit_exe'], target))))
print('\nNothing was launched, closed or written.')
return 0
started = time.time()
journal(log, S.CYCLE_START, run_id, path=target,
sandbox_pid=args.sandbox_pid or None,
batch_count=len(settings['batch']))
launched = []
exit_code = 0
crash = None
try:
# ---- see who is on the bridge BEFORE anything is closed. This is the
# only chance to learn that a pid named with --sandbox-pid is in fact
# the live model, and it is worth the two seconds.
_found, seen = find_sandbox(settings['port'], settings['port_span'],
None, stop_at_first=False)
live = live_pids_from(seen)
proven = sandbox_pids_from(seen, settings['sandbox_roots'])
for row in seen:
print('port {0}: {1} -- {2}'.format(row['port'], row['verdict'],
row['why']))
# ---- 2a. close the current sandbox FIRST, so the transient detach
# Revit and the sandbox are never resident together. The pid comes
# from the flag when one was given and from the survey otherwise --
# the button that spawns this cannot know a pid, and a stale sandbox
# left running is how the third Revit gets there.
closing = [args.sandbox_pid] if args.sandbox_pid else list(proven)
for victim in closing:
refusal = may_kill(victim, launched, args.sandbox_pid,
image_of(victim), live, proven)
if refusal:
journal(log, S.CYCLE_FAILED, run_id, error=refusal)
print(refusal, file=sys.stderr)
return 3
ok, note = kill(victim)
journal(log, S.SANDBOX_CLOSED, run_id, pid=victim,
ok=ok, note=note, phase='before-detach',
proof='--sandbox-pid' if args.sandbox_pid
else 'bridge said its model is under a snapshot root')
print('closed sandbox pid {0}: {1}'.format(victim, note))
if not ok:
error = ('the existing sandbox (pid {0}) would not close, so '
'nothing further was started -- three Revits is the '
'state this pipeline will not create.'.format(victim))
journal(log, S.CYCLE_FAILED, run_id, error=error)
print(error, file=sys.stderr)
return 3
if closing:
time.sleep(CLOSE_SETTLE_SECONDS)
# ---- 1. the copy.
if args.fresh_detach:
remember_source(settings['detached_dir'], source)
req_path = write_detach_request(settings, source, plan, log,
run_id)
print('detaching -> {0}'.format(target))
code = DEFER.main(['--request', req_path, '--runner', RUNNER,
'--pyrevit', settings['pyrevit_exe'],
'--machine-free', 'ignore'] +
(['--revit', str(settings['revit_year'])]
if settings['revit_year'] else []))
if code != 0 or not os.path.isfile(target):
error = ('the detach step did not produce {0} (exit {1}). '
'Its own reason is in this log, from the runner.'
.format(target, code))
journal(log, S.CYCLE_FAILED, run_id, error=error)
print(error, file=sys.stderr)
return 4
if not os.path.isfile(target):
error = 'there is no model at {0} to open'.format(target)
journal(log, S.CYCLE_FAILED, run_id, error=error)
print(error, file=sys.stderr)
return 4
# ---- 2b. the new sandbox.
pid = launch_sandbox(settings['revit_exe'], target)
launched.append(pid)
journal(log, S.SANDBOX_OPENED, run_id, pid=pid, path=target)
print('sandbox Revit started, pid {0}'.format(pid))
# ---- 3. wait for it to be the thing we asked for.
port, seen = wait_for_sandbox(settings, plan.get('title') or
os.path.splitext(
os.path.basename(target))[0],
target, pid)
if port is None:
error = ('the sandbox never answered with {0} open. Ports seen: '
'{1}'.format(os.path.basename(target),
'; '.join('{0}={1}'.format(r['port'],
r['verdict'])
for r in seen) or 'none'))
journal(log, S.CYCLE_FAILED, run_id, error=error, ports=seen)
print(error, file=sys.stderr)
exit_code = 5
else:
print('sandbox bridge confirmed on port {0}'.format(port))
exit_code = run_batch(settings, folder, port, log, run_id)
except Exception as exc:
# A crash here must NOT skip the close below, and it must not skip
# the journal either: a CYCLE_START with nothing after it is read as
# "a Revit may still be open", and that reading has to be reserved
# for when it is actually true.
import traceback
crash = traceback.format_exc()
exit_code = 6
print(crash, file=sys.stderr)
finally:
# ---- 5. close what we opened, whatever happened above.
for pid in launched:
if args.keep_open:
print('leaving sandbox pid {0} open (--keep-open)'.format(pid))
journal(log, S.SANDBOX_CLOSED, run_id, pid=pid, ok=False,
note='left open by --keep-open')
continue
refusal = may_kill(pid, launched)
if refusal:
journal(log, S.SANDBOX_CLOSED, run_id, pid=pid, ok=False,
note=refusal)
continue
ok, note = kill(pid)
journal(log, S.SANDBOX_CLOSED, run_id, pid=pid, ok=ok, note=note,
phase='after-batch')
print('closed sandbox pid {0}: {1}'.format(pid, note))
seconds = round(time.time() - started, 1)
if exit_code == 0:
journal(log, S.CYCLE_DONE, run_id, path=target, seconds=seconds,
batch_count=len(settings['batch']))
elif crash:
journal(log, S.CYCLE_FAILED, run_id, path=target, seconds=seconds,
error=crash.splitlines()[-1], traceback=crash)
else:
journal(log, S.CYCLE_FAILED, run_id, path=target, seconds=seconds,
error='the batch or the confirmation failed (exit {0}); the '
'sandbox was closed anyway'.format(exit_code))
return exit_code
def write_detach_request(settings, source, plan, log, run_id):
"""The request file `runners/detach_snapshot.py` reads. Same schema as the
button's, because it is the same runner and two schemas is one too many."""
request = {
'run': run_id,
'base': plan.get('title'),
'layout': plan.get('layout'),
'worksets': settings['worksets'],
'detached_dir': settings['detached_dir'],
'plan': plan,
'log': log,
'requested_at': time.strftime('%Y-%m-%d %H:%M:%S'),
'requested_by': 'tools/snapshot_cycle.py',
'source': source,
}
folder = os.path.join(settings['detached_dir'], '_requests')
if not os.path.isdir(folder):
os.makedirs(folder)
path = os.path.join(folder, run_id + '.json')
with open(path, 'w', encoding='utf-8') as handle:
handle.write(json.dumps(request, indent=2))
return path
def wait_for_sandbox(settings, want_title, want_path, pid, sleep=None,
clock=None, ask=None):
"""Poll until the sandbox answers with our model open. (port, seen)
Gives up rather than raising, for the same reason `snapshot_defer`'s wait
does: a timeout is a reportable outcome. It also gives up EARLY when the
process it is waiting for has died -- polling a dead Revit for fifteen
minutes tells nobody anything.
"""
sleep = sleep or time.sleep
clock = clock or time.time
deadline = clock() + settings['wait_minutes'] * 60.0
seen = []
while True:
port, seen = find_sandbox(settings['port'], settings['port_span'],
want_title, want_path, ask=ask)
if port is not None:
return port, seen
if pid and not DEFER.process_alive(pid):
return None, seen
if clock() >= deadline:
return None, seen
sleep(POLL_SECONDS)
def run_batch(settings, folder, port, log, run_id):
"""Every --run command, in order, tee'd into the snapshot folder.
Stops at the first failure. A sweep that reports problems is meant to be
followed by something that reads them, and running step two over step
one's wreckage produces a report nobody can trust.
"""
env = bridge_env(port)
worst = 0
for i, command in enumerate(settings['batch'], 1):
name = S.batch_log_name(i, command)
path = os.path.join(folder, name)
print('\n--- {0}\n$ {1}'.format(name, command))
started = time.time()
try:
code = batch_popen(command, path, env)
except Exception as exc:
code = -1
journal(log, S.BATCH_DONE, run_id, command=command, exit_code=code,
log=path, error='{0}: {1}'.format(type(exc).__name__, exc))
else:
journal(log, S.BATCH_DONE, run_id, command=command,
exit_code=code, log=path,
seconds=round(time.time() - started, 1))
print('--- exit {0}, log {1}'.format(code, path))
if code != 0:
worst = code
break
return worst
if __name__ == '__main__':
sys.exit(main())