# -*- coding: utf-8 -*- """Phase C: write one timestamped detached copy, then quit. NO UI, NO USER. pyrevit run --revit=2025 --purge This is not a button. It runs in a Revit that `pyrevit run` started from a journal, with no engineer in front of it, AFTER the engineer's own Revit has exited -- that ordering is the whole point. The machine cannot hold two copies of a 300+ MB model (10+ GB open), so this session exists only because the other one is already gone. WHY IT IS NOT IN A `.pushbutton` FOLDER ---------------------------------------- pyRevit builds the ribbon from `*.tab / *.panel / *.pushbutton` folder names and ignores everything else in an extension, so a plain `runners/` directory is invisible to the UI -- which is correct: this must never appear as a button somebody can press while a model is open. The extension's `lib/` is on `sys.path` for the session either way, so `from vibe import snapshotmeta` resolves normally. HOW IT IS TOLD WHAT TO DO -------------------------- `pyrevit run` passes NO arguments to the script it runs. The handoff is therefore a request file written by the button, named by the environment variable `WILLTOOLS_SNAPSHOT_REQUEST`, which `tools/snapshot_defer.py` sets on this process. The request carries the source model's identity, where the copy goes, and where to journal the outcome. WHAT IT IS ALLOWED TO WRITE ---------------------------- Exactly one thing: a NEW `.rvt` file, at a path that did not exist before, via `SaveAs`. It opens the source DETACHED where detaching is legal (see the next section), so the document in memory has no connection to the central and cannot sync to it. It never calls `SynchronizeWithCentral`, never `Save`, never opens a Transaction, and never touches the model it copied. If the target path already exists it does not overwrite -- it journals a skip and quits. A CLOUD MODEL CANNOT BE OPENED DETACHED, AND THIS USED TO GET IT WRONG ---------------------------------------------------------------------- Until 2026-09-01 this file set `DetachFromCentralOption.DetachAndPreserveWorksets` on EVERY open, including the cloud-GUID path -- which is the path this job actually takes, because the live model is an Autodesk Docs model. That throws. "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, that **SaveAs is the recommended way** to get a detached copy of one, and that the API is aligned to the UI -- where 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. Sources, read 2026-09-01: 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 So the detach option is now applied ONLY to a file-path source. For a cloud source the model is opened plainly and the SEVERING IS THE SaveAs: a cloud workshared document saved to a local path with `SaveAsCentral = True` becomes a new local central with no route back to the cloud model. Same end state, reached the way the API allows. This is a real defect that had never fired, because phase C end-to-end was never run on the cloud job (QNA/review-2026-08-30.md listed it as needing the bridge). It would have failed on the first live press. WHY IT DOES NOT REBUILD THE INDEX ---------------------------------- Phase A already did, from the live document, at the same sync state -- the same numbers. Re-running it here would overwrite a good index with one built in a session whose job-folder resolution depends on the copy's title, which is exactly the thing this design had to be careful about. The detached copy's purpose is FUTURE re-scans: extractors that do not exist yet, run months from now against a state nobody can go back to. Its provenance says so in those words, so a later reader does not mistake it for the source of today's index. ROUTES ------ `pyrevit run` uses the attached clone, so this session loads pyRevit normally and `startup.py` registers the bridge API. The routes SERVER is a separate question: pyRevit only activates one when `routes_server` is on in the user config, and when it does, `serverinfo._get_next_available_port` starts at the configured port and walks UP past every port already registered by a running Revit. There is nothing to clash with here anyway -- this session only exists after the engineer's Revit has exited -- but if he has restarted in the meantime, his session keeps 48884 and this one takes 48885. """ # pylint: disable=import-error,invalid-name,broad-except import json import os import time import traceback from vibe import snapshotmeta as S ENV = 'WILLTOOLS_SNAPSHOT_REQUEST' def _log(request, event, **fields): path = (request or {}).get('log') if not path: return row = {'event': event, 'run': (request or {}).get('run')} row.update(fields) S.append(path, row) def _read_request(): path = os.environ.get(ENV) if not path: raise RuntimeError( '{0} is not set. This script is launched by ' 'tools/snapshot_defer.py, which writes it.'.format(ENV)) handle = open(path, 'rb') try: return json.loads(handle.read().decode('utf-8')) finally: handle.close() def _fallback_log(): """`/snapshots.jsonl`, recovered with no request to read. Same trick as `tools/snapshot_defer.py`'s `fallback_log_for`, and for the same reason: the request file IS where `log` lives, so a request that cannot be read has nothing to send a journal line to by the ordinary route. But `WILLTOOLS_SNAPSHOT_REQUEST` always names a file at `/_requests/.json`, so the well-known log path can be recovered from that FILENAME alone. None when the env var is unset or does not have that shape -- a guessed path is worse than no line. """ path = os.environ.get(ENV) if not path: return None try: requests_dir = os.path.dirname(os.path.abspath(path)) if os.path.basename(requests_dir) != '_requests': return None return S.log_path(os.path.dirname(requests_dir)) except Exception: return None def _source_model_path(request): """A Revit `ModelPath` for the model to copy, and how it was identified. Cloud GUIDs first, the file path second. Which of the two a job has was never something to assume -- the engineer here works in a detached local file, and a tool that only knew how to reopen a cloud model would have failed on it with a message about the cloud. """ from Autodesk.Revit.DB import ModelPathUtils src = (request or {}).get('source') or {} cloud = src.get('cloud') if cloud: from System import Guid return (ModelPathUtils.ConvertCloudGUIDsToCloudPath( cloud['region'], Guid(cloud['project_guid']), Guid(cloud['model_guid'])), 'cloud GUIDs', True) path = src.get('path') if not path: raise RuntimeError( 'the request names neither cloud GUIDs nor a file path, so there ' 'is nothing to reopen. Cloud lookup said: {0}' .format(src.get('cloud_error') or 'nothing')) if not os.path.isfile(path): raise RuntimeError('the source model is gone: {0}'.format(path)) return (ModelPathUtils.ConvertUserVisiblePathToModelPath(path), 'file path', False) def _open_detached(app, model_path, worksets, is_cloud=False): """The source, open in memory. Detached where the API allows detaching. `is_cloud` is not a style choice: setting ANY `DetachFromCentralOption` on a cloud model raises "Detach option is not valid for cloud model" and the copy never happens. See this module's header for the sources. The cloud model is opened plainly and severed by the `SaveAs` instead. """ from Autodesk.Revit.DB import (DetachFromCentralOption, OpenOptions, WorksetConfiguration, WorksetConfigurationOption) opts = OpenOptions() if is_cloud: # No detach option at all. Worksets still open in full, because the # copy is meant to be a complete model and a half-opened one measures # short without saying so. try: opts.SetOpenWorksetsConfiguration(WorksetConfiguration( WorksetConfigurationOption.OpenAllWorksets)) except Exception: pass return app.OpenDocumentFile(model_path, opts) if (worksets or 'preserve') == 'discard': opts.DetachFromCentralOption = \ DetachFromCentralOption.DetachAndDiscardWorksets else: opts.DetachFromCentralOption = \ DetachFromCentralOption.DetachAndPreserveWorksets try: opts.SetOpenWorksetsConfiguration(WorksetConfiguration( WorksetConfigurationOption.OpenAllWorksets)) except Exception: # A non-workshared source has no workset configuration to set. # Not fatal, and not worth losing the copy over. pass return app.OpenDocumentFile(model_path, opts) def _save_as(doc, path, worksets, is_cloud=False): """The one write this whole feature is allowed to make. A NEW file. For a CLOUD source this is not merely the write, it is the detach: a cloud workshared document saved to a local path as a new CENTRAL has no route back to the cloud model. `SaveAsCentral` is therefore forced on for a cloud source whatever `worksets` says -- `worksets: discard` describes what a detach-from-central does to worksets, and a cloud model was never detached in the first place, so there is nothing for it to describe. """ from Autodesk.Revit.DB import (ModelPathUtils, SaveAsOptions, WorksharingSaveAsOptions) opts = SaveAsOptions() opts.OverwriteExistingFile = False if is_cloud or (worksets or 'preserve') != 'discard': # A detached-with-worksets document can only be saved as a new # CENTRAL. Without this the SaveAs raises, and the message it raises # says nothing useful about why. try: ws = WorksharingSaveAsOptions() ws.SaveAsCentral = True opts.SetWorksharingOptions(ws) except Exception: pass doc.SaveAs(ModelPathUtils.ConvertUserVisiblePathToModelPath(path), opts) def main(): request = None started = time.time() try: request = _read_request() except Exception: # There is no `request['log']` to journal to -- the request file IS # where it lives, and reading it is exactly what failed. Until # 2026-09-02 that made this branch genuinely silent: the deferred # host tool's own FAILED line covers "the pyRevit run finished but # nothing was written", which is a true sentence here too, but it # cannot say WHY, and this is the one exception in the whole feature # that carries a `RuntimeError` message worth keeping. # `_fallback_log` recovers `/snapshots.jsonl` from the # env var's own path, with no JSON parse needed, so this failure # gets a line of its own instead of dissolving into the defer tool's # generic one. fallback = _fallback_log() if fallback: S.append(fallback, { 'event': S.WATCHER_CRASHED, 'run': None, 'error': 'the runner could not read its own request: {0}' .format(traceback.format_exc().splitlines()[-1]), 'traceback': traceback.format_exc()}) raise plan = request.get('plan') or {} target = plan.get('path') try: if not target: raise RuntimeError('the request carries no target path') if os.path.isfile(target): _log(request, S.SKIPPED, path=target, reason='a copy with this timestamp is already there') return folder = os.path.dirname(target) if folder and not os.path.isdir(folder): os.makedirs(folder) from pyrevit import HOST_APP app = HOST_APP.app model_path, how, is_cloud = _source_model_path(request) doc = _open_detached(app, model_path, request.get('worksets'), is_cloud) try: _save_as(doc, target, request.get('worksets'), is_cloud) finally: try: doc.Close(False) # False: save nothing, ever except Exception: pass prov = S.provenance( 'C', S.FRESH_DETACH, source_path=(request.get('source') or {}).get('path'), # None, not plan['stamp']: that is THIS copy's stamp (already in # the folder name and the `at` field). The source is the live # central being reopened and carries no snapshot stamp of its own. source_stamp=None, source_title=(request.get('source') or {}).get('title'), revit='{0} ({1})'.format(getattr(app, 'VersionNumber', '?'), getattr(app, 'VersionBuild', '?')), job_folder=request.get('job_folder'), note=('an archival copy, not the source of any current index. ' 'Phase A read the live document at the same sync state and ' 'produced identical numbers. This file exists for re-scans ' 'by extractors that did not exist when it was written, and ' 'as the sandbox tools/snapshot_cycle.py drives. Reopened ' 'from {0}; severed by {1}.' .format(how, 'SaveAs to a new local central (a cloud model ' 'cannot be opened detached)' if is_cloud else 'open-detached from central'))) try: handle = open(os.path.join(os.path.dirname(target), 'snapshot.json'), 'wb') try: handle.write(json.dumps(prov, indent=2, sort_keys=True).encode('utf-8')) finally: handle.close() except Exception: pass _log(request, S.DONE, path=target, how=how, seconds=round(time.time() - started, 1), provenance=prov) except Exception: _log(request, S.FAILED, path=target, seconds=round(time.time() - started, 1), error=traceback.format_exc().splitlines()[-1], traceback=traceback.format_exc()) raise main()