25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
1159 lines
51 KiB
Python
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())
|