#!/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 ".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 `/_<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())