RunSpec captures requested experiment inputs. Parameters must be an object and a supplied seed must be an integer. The runner makes a JSON copy of parameters before creating the record.
@dataclass(frozen=True)classRunSpec:workflow:strscenario:strparameters:dict=field(default_factory=dict)seed:int|None=Noneparent_attempt_id:str|None=Noneattack:str|None=Nonecleanup:bool=Trueunaligned:bool|str=Falsedef__post_init__(self):ifnotisinstance(self.parameters,dict):raiseValueError("Scenario parameters must be a JSON object")ifself.seedisnotNoneandtype(self.seed)isnotint:raiseValueError("Scenario seed must be an integer")
TriggerReceipt binds a custom trigger to its final event and optional issue/PR/run. Subject kind and number must be provided together; IDs must be positive integers.
@dataclass(frozen=True)classTriggerReceipt:event_type:str|None=Nonesubject_kind:Literal["pr","issue"]|None=Nonesubject_number:int|None=Noneworkflow_run_id:int|None=Nonedef__post_init__(self):ifself.subject_kindnotin{None,"pr","issue"}:raiseValueError("Subject kind must be pr or issue")if(self.subject_kindisNone)!=(self.subject_numberisNone):raiseValueError("Subject kind and number must be supplied together")forvaluein(self.subject_number,self.workflow_run_id):ifvalueisnotNoneand(type(value)isnotintorvalue<=0):raiseValueError("GitHub identifiers must be positive integers")
RunContext supplies the recorded spec, mutable scenario state, explicit actor clients, default trigger/collector callbacks, and seeded rng. github(actor) raises if the identity is absent. save_artifact writes JSON inside the attempt. track_repository registers immutable ownership immediately after a custom creation; cleanup_repositories returns deletion error strings while retaining failed resources for inspection.
@dataclassclassRunContext:spec:RunSpecrecord:RunRecordstate:dictactors:dict[str,GitHubClient]default_trigger:Callable[[],TriggerReceipt]collect_target:Callable[[],dict]_repositories:list[tuple[str,str,int]]=field(default_factory=list)def__post_init__(self):self.rng=random.Random(self.spec.seed)@propertydefparameters(self)->dict:returnself.spec.parametersdefgithub(self,actor:str)->GitHubClient:ifactornotinself.actors:raiseValueError(f"Required GitHub actor is unavailable: {actor}")returnself.actors[actor]defsave_artifact(self,name:str,value):returnself.record.artifact(name,value)deftrack_repository(self,actor:str,name:str,repository_id:int)->None:"""Register a repository immediately after creating it through the raw API."""self.github(actor)ifnotnameortype(repository_id)isnotintorrepository_id<=0:raiseValueError("Repository ownership requires a name and immutable ID")identity=(actor,name,repository_id)ifidentitynotinself._repositories:self._repositories.append(identity)self.record.event("resource",actor=actor,name=name,id=repository_id,state="created")defcleanup_repositories(self)->list[str]:errors=[]foractor,name,repository_idinself._repositories[:]:try:self.github(actor)._delete_repository(name,repository_id)self.record.event("resource",actor=actor,name=name,id=repository_id,state="deleted")self._repositories.remove((actor,name,repository_id))exceptExceptionasexc:errors.append(str(exc))returnerrors
defgithub(self,actor:str)->GitHubClient:ifactornotinself.actors:raiseValueError(f"Required GitHub actor is unavailable: {actor}")returnself.actors[actor]
deftrack_repository(self,actor:str,name:str,repository_id:int)->None:"""Register a repository immediately after creating it through the raw API."""self.github(actor)ifnotnameortype(repository_id)isnotintorrepository_id<=0:raiseValueError("Repository ownership requires a name and immutable ID")identity=(actor,name,repository_id)ifidentitynotinself._repositories:self._repositories.append(identity)self.record.event("resource",actor=actor,name=name,id=repository_id,state="created")
RunRecord creates a UUID attempt directory and manifest, snapshots input bytes with SHA-256 hashes, appends durable journal events, and saves JSON artifacts with hashes. artifact rejects paths escaping the attempt. It is a local record, not a resume/reconciliation engine.
classRunRecord:def__init__(self,workspace_dir:str,spec:RunSpec):self.attempt_id=uuid.uuid4().hexself.timestamp=datetime.now(timezone.utc).isoformat()self.directory=Path(workspace_dir)/"runs"/self.attempt_idself.directory.mkdir(parents=True)self.manifest={"schema_version":1,"attempt_id":self.attempt_id,"timestamp":self.timestamp,"spec":asdict(spec),"inputs":{},}try:revision=subprocess.run(["git","rev-parse","HEAD"],cwd=workspace_dir,capture_output=True,text=True,check=True,).stdout.strip()self.manifest["source_revision"]=revisionstatus=subprocess.run(["git","status","--porcelain"],cwd=workspace_dir,capture_output=True,text=True,check=True,).stdoutself.manifest["source_dirty"]=bool(status.strip())except(OSError,subprocess.CalledProcessError):self.manifest["source_revision"]=Noneself.save_manifest()self.event("phase",phase="created")defsave_manifest(self)->None:write_json(self.directory/"manifest.json",self.manifest)defevent(self,kind:str,**data)->None:entry={"timestamp":datetime.now(timezone.utc).isoformat(),"kind":kind,**data}with(self.directory/"events.jsonl").open("a")ashandle:handle.write(json.dumps(entry,allow_nan=False)+"\n")handle.flush()os.fsync(handle.fileno())defsnapshot(self,label:str,source:str|Path,*,prefix:str="")->None:source=Path(source)files=sorted(source.rglob("*"))ifsource.is_dir()else[source]hashes=dict(self.manifest["inputs"].get(label,{}))forpathinfiles:ifnotpath.is_file()or"__pycache__"inpath.parts:continuerelative=path.relative_to(source)ifsource.is_dir()elsePath(path.name)relative=Path(prefix)/relativecontent=path.read_bytes()destination=self.directory/"inputs"/label/relativedestination.parent.mkdir(parents=True,exist_ok=True)destination.write_bytes(content)hashes[str(relative)]=hashlib.sha256(content).hexdigest()self.manifest["inputs"][label]=hashesself.save_manifest()defartifact(self,name:str,value)->Path:root=self.directory/"artifacts"path=root/nameifpath.resolve()==root.resolve()orroot.resolve()notinpath.resolve().parents:raiseValueError("Artifact path must stay inside the attempt")path.parent.mkdir(parents=True,exist_ok=True)write_json(path,value)self.event("artifact",path=str(path.relative_to(self.directory)),sha256=hashlib.sha256(path.read_bytes()).hexdigest(),)returnpath
defartifact(self,name:str,value)->Path:root=self.directory/"artifacts"path=root/nameifpath.resolve()==root.resolve()orroot.resolve()notinpath.resolve().parents:raiseValueError("Artifact path must stay inside the attempt")path.parent.mkdir(parents=True,exist_ok=True)write_json(path,value)self.event("artifact",path=str(path.relative_to(self.directory)),sha256=hashlib.sha256(path.read_bytes()).hexdigest(),)returnpath
write_json uses a temporary sibling file, flushes it, and replaces the destination. Values must be JSON serializable; non-finite numbers are rejected.