Record the annotated rebuild, bounded graphics matrices, private release-shape smoke, and remaining integration gates. Preserve STEP20C NEXT and production multi-output NOT YET SUPPORTED.
333 lines
20 KiB
Python
333 lines
20 KiB
Python
"""Fail-closed, one-shot gate for the private G release-shape smoke."""
|
|
import fcntl
|
|
from collections import Counter
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
REPO = HERE.parents[3]
|
|
OVERNIGHT = REPO / 'tooling/probes/step20c-overnight'
|
|
BASE = REPO / 'tooling/probes/step20c-static-e2-completion'
|
|
LAB = Path('/home/fy59/swayfx-mosaic-overnight-mcolx0gq')
|
|
GROOT = LAB / 'release-shape'
|
|
WORK = GROOT / 'smoke'
|
|
CASE = 'G-smoke'
|
|
BINARY = GROOT / 'stage-static/usr/bin/swayfx-mosaic'
|
|
BINARY_SHA = '8063478b2d5948da3eb8b1a329978882dc1d045cf36932cc2145a78af20ba76a'
|
|
BUILD_ID = '83896fdb36f3fd21522becec0e8c5219e15f4b8e'
|
|
WLROOTS = Path('/usr/lib/libwlroots-0.20.so').resolve()
|
|
WLROOTS_SHA = '7edca9a7d65855addda68e29ba5db94d57c803eb5fa7d0f86db3136b90ca9837'
|
|
PATCH_SHA = '6e8a5b50149732ac70ecd79ab8db9258956f59036aba1bf17a477866bc44a5c1'
|
|
REVIEW = LAB / 'reviews/release-patch-review.json'
|
|
REVIEW_SHA = '29e7ea8b9124fae59a231ff6abcab3981e1ac9c697da51c05c334e888970897a'
|
|
BUILD_JSON = GROOT / 'results/phase-g-build.json'
|
|
PLANS = dict(chain=2, grab=4, click=8, capture=17, camera=2, transition=1, move=10)
|
|
# Contract: the move budget counts pointer relocations, including the eight
|
|
# click injections; the event trace therefore has only two standalone `move`
|
|
# actions plus eight `left`/`right` click actions.
|
|
|
|
# The three Mosaic source files whose G runtime traces bind the smoke.
|
|
TRACE_SOURCES = {
|
|
str(GROOT / 'mosaic-source/sway/desktop/output.c'): 'f703fab0828ee886ce690acfa727a1f70d0dad5f9ec1f5c153db646c73d10485',
|
|
str(GROOT / 'mosaic-source/sway/desktop/transaction.c'): '1bb30b3f171f680f8b2e6ed28aa7aad0a6854d8d77360ac8b8e314918c0982f2',
|
|
str(GROOT / 'mosaic-source/sway/desktop/xdg_shell.c'): '123313e80fbf5d55a8e9bf2edca5a0f15a7601643a5af2904da6ba9b3914d29f',
|
|
}
|
|
|
|
def sha(path):
|
|
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
|
|
def read(path):
|
|
return json.loads(Path(path).read_text())
|
|
|
|
def require(ok, message):
|
|
if not ok:
|
|
raise ValueError(message)
|
|
|
|
def validate_binary_identity(path, digest):
|
|
require(Path(path).resolve() == BINARY.resolve() and digest == BINARY_SHA and sha(path) == BINARY_SHA, 'only exact G ELF accepted')
|
|
|
|
def validate_budget_room(state):
|
|
sys.path.insert(0, str(OVERNIGHT))
|
|
import ledger
|
|
ledger._validate(state)
|
|
require(CASE not in state['runs'] and state['used']['run'] < ledger.LIMITS['run'], 'run quota/unique id')
|
|
require(state['campaign_used']['G'] < ledger.CAMPAIGNS['G'], 'G campaign quota')
|
|
require(state['used']['capture'] + PLANS['capture'] <= ledger.LIMITS['capture'], 'global PNG quota')
|
|
require(all(state['used'][k] == ledger.LIMITS[k] for k in ('scene_build','mosaic_build','stage')), 'G smoke must not extend exhausted build/stage budget')
|
|
|
|
def validate_wlroots(path, digest):
|
|
require(Path(path).resolve()==WLROOTS and digest==WLROOTS_SHA and sha(path)==WLROOTS_SHA, 'wlroots exact bytes')
|
|
|
|
def validate_file_map(files):
|
|
require(isinstance(files,dict) and bool(files), 'frozen file map missing')
|
|
for path,digest in files.items():
|
|
require(Path(path).is_file() and sha(path)==digest, 'frozen input changed: '+path)
|
|
|
|
def validate_one_shot(work):
|
|
work=Path(work)
|
|
require(not (work/'run').exists() and not (work/'consumed.json').exists(), 'G smoke is one-shot; no retry')
|
|
|
|
def validate_capture_batch(work, captures):
|
|
from PIL import Image
|
|
require(len(captures)==17 and len({row.get('file') for row in captures})==17, 'incomplete/duplicate G capture batch')
|
|
expected={'H2-reference-1.png','H2-reference-2.png','E1-top.png','E1-A.png','E1-mosaic-H1.png','E1-mosaic-H2.png','E1-dismissed-H2.png','E2-top.png','E2-A.png','E2-mosaic-H1.png','E2-mosaic-H2.png','standard-H1.png','standard-H2.png','standard-A-H1.png','standard-A-H2.png','final-H2.png','final-H1.png'}
|
|
require({row.get('file') for row in captures}==expected, 'G capture frame set incomplete')
|
|
for row in captures:
|
|
path=Path(work)/'run'/row['file']
|
|
require(path.is_file() and sha(path)==row['sha256'] and row.get('exit')==0, 'G capture bytes changed')
|
|
with Image.open(path) as image: require(image.size==(800,600), 'G capture frame dimensions')
|
|
|
|
def validate_window_results(work):
|
|
names=('grab-serials.json','xdg-protocol-order.json','popup-root-geometry.json','popup-nested-geometry.json','applied-clips.json','mosaic-pixels.json','standard-return.json','standard-popup-input.json','final-restoration.json','cleanup.json','terminal-exit.json')
|
|
missing=[name for name in names if not (Path(work)/'results'/name).is_file()]
|
|
require(not missing, 'G window/lifecycle evidence incomplete: '+','.join(missing))
|
|
|
|
def durable(path, data):
|
|
path = Path(path)
|
|
tmp = path.with_name('.' + path.name + '.tmp-' + str(os.getpid()))
|
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
try:
|
|
with os.fdopen(fd, 'w') as stream:
|
|
json.dump(data, stream, indent=2); stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
|
|
os.replace(tmp, path)
|
|
dfd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
try: os.fsync(dfd)
|
|
finally: os.close(dfd)
|
|
finally:
|
|
if tmp.exists(): tmp.unlink()
|
|
|
|
def exclusive(path, data):
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
with os.fdopen(fd, 'w') as stream:
|
|
json.dump(data, stream, indent=2); stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
|
|
dfd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
try: os.fsync(dfd)
|
|
finally: os.close(dfd)
|
|
|
|
def source_bindings(auth):
|
|
rows = auth['trace_sources']
|
|
require(rows == TRACE_SOURCES, 'G source binding set changed')
|
|
require(all(sha(path) == digest for path, digest in rows.items()), 'G source hash changed')
|
|
return {Path(path).name: digest for path, digest in rows.items()}
|
|
|
|
def _check_build_identity():
|
|
build = read(BUILD_JSON)
|
|
require(build['status'] == 'BUILDABLE', 'G build receipt status')
|
|
require(build['source']['accepted_review'] == 'ACCEPTED', 'G accepted patch review')
|
|
require(build['source']['patch_sha256'] == PATCH_SHA, 'G patch identity')
|
|
require(build['source']['review_json_sha256'] == REVIEW_SHA, 'G review receipt binding')
|
|
require(build['source']['release_manifest_sha256'] == sha(GROOT/'release-manifest.json'), 'G release manifest receipt binding')
|
|
manifest=read(GROOT/'release-manifest.json')
|
|
require(manifest['patch_sha256']==PATCH_SHA and manifest['result_source_hashes'], 'G release manifest identity')
|
|
for rel,digest in manifest['result_source_hashes'].items(): require(sha(GROOT/rel)==digest, 'G release source changed: '+rel)
|
|
require(build['elf']['sha256'] == BINARY_SHA and build['elf']['build_id'] == BUILD_ID, 'G ELF receipt identity')
|
|
require(Path(build['elf']['path']).resolve() == BINARY.resolve(), 'G stage path identity')
|
|
require(build['headless_runtime'] == 'NOT RUN' and build['graphics'] == 'NOT TESTED', 'no inherited G runtime result')
|
|
validate_binary_identity(BINARY, BINARY_SHA)
|
|
readelf = subprocess.run(['readelf', '-n', str(BINARY)], capture_output=True, text=True, timeout=5)
|
|
require(readelf.returncode == 0 and BUILD_ID in readelf.stdout, 'G ELF build ID bytes')
|
|
validate_wlroots(WLROOTS, WLROOTS_SHA)
|
|
review = read(REVIEW)
|
|
require(sha(REVIEW) == REVIEW_SHA and review['status'] == 'ACCEPTED' and review['independent'] is True, 'accepted exact G patch review')
|
|
require(review['patch_sha256'] == PATCH_SHA, 'reviewed G patch digest')
|
|
require(sha(review['report_file']) == review['report_sha256'] == '52e0db6963c44d6e17eab92404c9a882ca03a0bbae9d59ff15c43bdfb0aab0b8', 'accepted patch review report bytes')
|
|
plan=read(LAB/'reviews/release-plan-review.json')
|
|
require(plan.get('status')=='ACCEPTED_PLAN_ONLY' and plan.get('plan_sha256')=='b64201aa3f6a51c385ca8f6934ad20c07ea3853b5a08766e2ae174f5da229547', 'accepted plan binding')
|
|
return build
|
|
|
|
def _frozen_files():
|
|
freeze = read(WORK / 'freeze.json')
|
|
require(freeze.get('status') == 'READY_FOR_REVIEW' and isinstance(freeze.get('files'), dict), 'G smoke freeze missing')
|
|
files = freeze['files']
|
|
require(files == expected_files(), 'G freeze file set changed')
|
|
validate_file_map(files)
|
|
return freeze
|
|
|
|
def expected_files():
|
|
"""Complete inputs: C2 closure plus G sources/build/patch and fixture code."""
|
|
c2 = read(LAB / 'horizontal/freeze.json')
|
|
rows = dict(c2['files'])
|
|
# Reused runtime modules and their sealed generator/input closure.
|
|
for path in [HERE/'adapter.py', HERE/'gate.py', HERE/'dispatch.py', HERE/'tests.py',
|
|
HERE/'README.md', HERE/'__init__.py',
|
|
OVERNIGHT/'horizontal_adapter.py', OVERNIGHT/'horizontal_gate.py',
|
|
OVERNIGHT/'ledger.py', BASE/'run.py', BASE/'client-contract.json',
|
|
*(BASE/n for n in ('model.py','observations.py','ipc_predicates.py','pixels.py','predicates.py','lifecycle.py','transition.py','support.py')),
|
|
WORK/'runner.py', WORK/'results/prepare-tests.json']:
|
|
rows[str(path)] = sha(path)
|
|
# Bind every source input of the G SceneFX and Mosaic trees.
|
|
for root in (GROOT/'scene-source', GROOT/'mosaic-source'):
|
|
for path in root.rglob('*'):
|
|
if path.is_file() and '.git' not in path.parts:
|
|
rows[str(path)] = sha(path)
|
|
fixed = [
|
|
REVIEW, LAB/'reviews/release-patch-review.md',
|
|
GROOT/'release-removal.patch', GROOT/'release-manifest.json',
|
|
LAB/'parent/release-plan.json', LAB/'reviews/release-plan-review.json',
|
|
LAB/'reviews/release-plan-review.md', BUILD_JSON, GROOT/'REPORT-BUILD.md',
|
|
GROOT/'results/static-stage-manifest.json', GROOT/'results/binary-elf.json',
|
|
GROOT/'raw/phase-g-commands.json',
|
|
GROOT/'scene-build/meson-info/intro-buildoptions.json',
|
|
GROOT/'mosaic-build/meson-info/intro-buildoptions.json',
|
|
GROOT/'mosaic-build/meson-info/intro-targets.json',
|
|
LAB/'horizontal/identity.json', LAB/'horizontal/compile.json',
|
|
LAB/'horizontal/protocol-scanner.json', LAB/'horizontal/offline-tests-repair-v1.json',
|
|
LAB/'horizontal/C2/runner.py',
|
|
Path('/usr/lib/libwlroots-0.20.so').resolve(), BINARY,
|
|
]
|
|
# The offline completion-predicate test consumes this immutable, completed
|
|
# C2 evidence. Freeze the actual events and the records that validate them.
|
|
c2work=LAB/'horizontal/C2'
|
|
c2_evidence=[c2work/'actions.json',c2work/'run/events.jsonl',
|
|
c2work/'results/captures.json',c2work/'results/clicks.json',c2work/'results/runtime.json']
|
|
c2_evidence.extend(c2work/'results'/name for name in (
|
|
'grab-serials.json','xdg-protocol-order.json','popup-root-geometry.json',
|
|
'popup-nested-geometry.json','applied-clips.json','mosaic-pixels.json',
|
|
'standard-return.json','standard-popup-input.json','final-restoration.json',
|
|
'cleanup.json','terminal-exit.json'))
|
|
c2_evidence.extend(c2work/'run'/name for name in (
|
|
'H2-reference-1.png','H2-reference-2.png','E1-top.png','E1-A.png',
|
|
'E1-mosaic-H1.png','E1-mosaic-H2.png','E1-dismissed-H2.png','E2-top.png',
|
|
'E2-A.png','E2-mosaic-H1.png','E2-mosaic-H2.png','standard-H1.png',
|
|
'standard-H2.png','standard-A-H1.png','standard-A-H2.png','final-H2.png','final-H1.png'))
|
|
fixed.extend(c2_evidence)
|
|
fixed.extend(path for info in (GROOT/'scene-build/meson-info',GROOT/'mosaic-build/meson-info') for path in info.iterdir() if path.is_file())
|
|
for path in fixed:
|
|
require(path.is_file(), 'required G freeze input missing: ' + str(path))
|
|
rows[str(path)] = sha(path)
|
|
# Complete DESTDIR identity, including the four separate future notices.
|
|
for path in (GROOT/'stage-static').rglob('*'):
|
|
if path.is_file(): rows[str(path)] = sha(path)
|
|
# Exact C17 fixture binaries and generated protocol sources are also present through C2 closure.
|
|
return dict(sorted(rows.items()))
|
|
|
|
def verify_review():
|
|
freeze = _frozen_files()
|
|
freeze_sha = sha(WORK/'freeze.json')
|
|
review_path = GROOT/'reviews/release-smoke-review.json'
|
|
require(review_path.is_file(), 'independent G smoke byte review missing')
|
|
review = read(review_path)
|
|
require(review.get('status') == 'ACCEPTED' and review.get('scope') == 'OVERNIGHT_G_SMOKE_FINAL_BYTES', 'G smoke review scope/status')
|
|
require(review.get('independent') is True and review.get('read_only') is True and review.get('blockers') == [], 'G smoke review independence/blockers')
|
|
require(review.get('freeze_sha256') == freeze_sha and review.get('files') == freeze['files'], 'G smoke review bindings')
|
|
require(Path(review['report_file']).is_file() and sha(review['report_file']) == review['report_sha256'], 'G smoke report bytes')
|
|
return review
|
|
|
|
def preflight(require_review=True):
|
|
require(sys.flags.optimize == 0, 'assertions disabled')
|
|
require(LAB.is_dir() and LAB.stat().st_uid == os.getuid() and stat.S_IMODE(LAB.stat().st_mode) == 0o700, 'registered owned LAB mode')
|
|
require(WORK.is_dir() and WORK.stat().st_uid == os.getuid(), 'G smoke workspace owner')
|
|
validate_one_shot(WORK)
|
|
require((WORK/'results').is_dir(), 'G results directory')
|
|
require((WORK/'results/clients').is_symlink() and (WORK/'results/clients').resolve()==(LAB/'horizontal/clients').resolve(), 'results client link changed')
|
|
for name in ('nested-client','pointer-daemon','static-reference'):
|
|
p=WORK/name
|
|
require(p.is_symlink() and p.resolve()==(LAB/'horizontal/clients'/name).resolve(), 'C2 client symlink changed: '+name)
|
|
for proc in Path('/proc').iterdir():
|
|
if proc.name.isdigit():
|
|
try: exe=(proc/'exe').resolve(strict=True)
|
|
except (FileNotFoundError, PermissionError, ProcessLookupError): continue
|
|
require(exe != BINARY.resolve(), 'G binary already running')
|
|
build = _check_build_identity()
|
|
sys.path.insert(0, str(OVERNIGHT)); import ledger
|
|
validate_budget_room(read(LAB/'budget.json'))
|
|
freeze = _frozen_files()
|
|
# Refuse inherited A bytes and any other ELF even if it is executable.
|
|
require(freeze['files'].get(str(BINARY)) == BINARY_SHA, 'G ELF absent from freeze')
|
|
require(all(sha(path)==digest for path,digest in TRACE_SOURCES.items()), 'G runtime sources changed')
|
|
from adapter import generate_g
|
|
require((WORK/'runner.py').read_text() == generate_g(), 'generated G C2 runner changed')
|
|
if require_review: verify_review()
|
|
return dict(binary=str(BINARY), binary_sha256=BINARY_SHA, build_id=BUILD_ID,
|
|
wlroots_sha256=WLROOTS_SHA, trace_sources=TRACE_SOURCES,
|
|
pristine=dict(path=str(LAB/'stage-static/usr/bin/swayfx-mosaic'), sha256='979dcd95ab43fa1e465d6e9d262485b73d186cedacd0f957059dd24f482ce6f4'))
|
|
|
|
def begin_run():
|
|
auth = preflight(require_review=True)
|
|
token = WORK/'consumed.json'
|
|
exclusive(token, dict(case=CASE, freeze_sha256=sha(WORK/'freeze.json'), consumed_at_ns=time.time_ns()))
|
|
# The durable unique token comes before the locked global ledger reservation.
|
|
sys.path.insert(0, str(OVERNIGHT))
|
|
import ledger
|
|
ledger.reserve(LAB/'budget.json', 'run', campaign='G', run_id=CASE, duration_s=150)
|
|
exclusive(WORK/'actions.json', dict(planned=PLANS, reserved=dict.fromkeys(PLANS,0), events=[]))
|
|
return LAB, CASE, WORK, auth
|
|
|
|
def debit(lab, case, work, *kinds):
|
|
require(Path(lab).resolve()==LAB and case==CASE and Path(work).resolve()==WORK, 'G action binding')
|
|
require(kinds and all(k in PLANS for k in kinds), 'unknown G action')
|
|
lockpath=WORK/'actions.lock'
|
|
with lockpath.open('a+') as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
state=read(WORK/'actions.json')
|
|
require(state.get('planned')==PLANS and set(state.get('reserved',{}))==set(PLANS), 'G action ledger schema')
|
|
require(len(state.get('events',[]))==sum(state['reserved'].values()), 'G action ledger count mismatch')
|
|
require(dict(Counter(e.get('kind') for e in state['events']))=={k:v for k,v in state['reserved'].items() if v}, 'G action event/count mismatch')
|
|
for i,event in enumerate(state['events']):
|
|
require(event.get('sequence')==i and event.get('kind') in PLANS and type(event.get('ns')) is int, 'G action event schema')
|
|
require(all(state['reserved'][k]+kinds.count(k)<=PLANS[k] for k in kinds), 'G local action quota')
|
|
for kind in kinds:
|
|
if kind=='capture':
|
|
sys.path.insert(0,str(OVERNIGHT));import ledger
|
|
ledger.reserve(LAB/'budget.json','capture',run_id=CASE)
|
|
state['reserved'][kind]+=1
|
|
state['events'].append(dict(sequence=len(state['events']),kind=kind,ns=time.monotonic_ns()))
|
|
durable(WORK/'actions.json',state)
|
|
|
|
def binding_sources(auth):
|
|
require(auth['binary']==str(BINARY) and auth['binary_sha256']==BINARY_SHA, 'G binary source binding')
|
|
return source_bindings(auth)
|
|
|
|
def finish_predicate(state, events, captures, clicks, runtime, capture_ok, window_ok):
|
|
"""Evaluate the real completion gate over recorded action and runtime evidence."""
|
|
counts=dict(Counter(e.get('kind') for e in state.get('events',[])))
|
|
ledger_ok=(state.get('planned')==PLANS and set(state.get('reserved',{}))==set(PLANS)
|
|
and counts=={k:v for k,v in state.get('reserved',{}).items() if v}
|
|
and all(type(v) is int and 0<=v<=PLANS.get(k,-1) for k,v in state.get('reserved',{}).items()))
|
|
event_capture_ok=sum(e.get('kind')=='capture' for e in events)==PLANS['capture']
|
|
injections=[e for e in events if e.get('kind')=='injection']
|
|
click_actions=[e for e in injections if e.get('action') in ('left','right')]
|
|
standalone_moves=[e for e in injections if e.get('action')=='move']
|
|
injection_ok=(len(injections)==PLANS['click']+2
|
|
and len(click_actions)==PLANS['click']
|
|
and len(standalone_moves)==2
|
|
and len(click_actions)+len(standalone_moves)==len(injections))
|
|
grabs=sum(e.get('grab') is True for e in injections)
|
|
complete=(ledger_ok and state.get('reserved')==PLANS and capture_ok and window_ok
|
|
and event_capture_ok and injection_ok and grabs==PLANS['grab']
|
|
and len(clicks)==PLANS['click'] and runtime.get('status')=='PASS')
|
|
return dict(complete=complete, action_ledger_valid=ledger_ok,
|
|
event_capture_count_valid=event_capture_ok, injections=len(injections),
|
|
click_injections=len(click_actions), standalone_move_injections=len(standalone_moves),
|
|
pointer_counts_valid=injection_ok, grabs=grabs)
|
|
|
|
def finish_run(lab, case, work):
|
|
verify_review()
|
|
require(Path(lab).resolve()==LAB and case==CASE and Path(work).resolve()==WORK, 'G finish binding')
|
|
state=read(WORK/'actions.json')
|
|
events_path=WORK/'run/events.jsonl'
|
|
events=[json.loads(line) for line in events_path.read_text().splitlines()] if events_path.is_file() else []
|
|
captures=read(WORK/'results/captures.json') if (WORK/'results/captures.json').is_file() else []
|
|
clicks=read(WORK/'results/clicks.json') if (WORK/'results/clicks.json').is_file() else []
|
|
runtime=read(WORK/'results/runtime.json') if (WORK/'results/runtime.json').is_file() else {'status':'INCONCLUSIVE','failure':'runtime result missing'}
|
|
try: validate_capture_batch(WORK,captures);capture_ok=True
|
|
except Exception: capture_ok=False
|
|
try: validate_window_results(WORK);window_ok=True
|
|
except Exception: window_ok=False
|
|
predicate=finish_predicate(state,events,captures,clicks,runtime,capture_ok,window_ok)
|
|
observed=dict(status='PASS' if predicate['complete'] else 'INCONCLUSIVE', runtime=runtime,
|
|
planned=PLANS, reserved=state.get('reserved'), action_ledger_valid=predicate['action_ledger_valid'],
|
|
captures=len(captures), capture_bytes_valid=capture_ok, event_capture_count_valid=predicate['event_capture_count_valid'],
|
|
clicks=len(clicks), injections=predicate['injections'], click_injections=predicate['click_injections'],
|
|
standalone_move_injections=predicate['standalone_move_injections'], grabs=predicate['grabs'],
|
|
pointer_counts_valid=predicate['pointer_counts_valid'],
|
|
window_evidence_complete=window_ok,
|
|
measurement_event=any(e.get('kind')=='measurements-completed' and e.get('status')=='PASS' for e in events))
|
|
durable(WORK/'finish.json',observed)
|
|
return observed
|