flyloft 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flyloft/__init__.py +0 -0
- flyloft/_version.py +24 -0
- flyloft/cli/__init__.py +0 -0
- flyloft/cli/main.py +196 -0
- flyloft/config/__init__.py +0 -0
- flyloft/config/load.py +61 -0
- flyloft/config/models.py +68 -0
- flyloft/publication/__init__.py +0 -0
- flyloft/publication/body.py +28 -0
- flyloft/publication/gh.py +48 -0
- flyloft/publication/pr_body.md.j2 +59 -0
- flyloft/publication/publisher.py +7 -0
- flyloft/record/__init__.py +0 -0
- flyloft/record/actions.py +294 -0
- flyloft/record/consistency.py +64 -0
- flyloft/record/db.py +9 -0
- flyloft/record/migrate.py +21 -0
- flyloft/record/models.py +189 -0
- flyloft/record/store.py +324 -0
- flyloft/record/views.py +57 -0
- flyloft/runs/__init__.py +0 -0
- flyloft/runs/askpass.sh +2 -0
- flyloft/runs/client.py +18 -0
- flyloft/runs/collect.py +37 -0
- flyloft/runs/observe.py +21 -0
- flyloft/runs/ssh.py +105 -0
- flyloft/runs/staging.py +33 -0
- flyloft/service/__init__.py +0 -0
- flyloft/service/factory.py +13 -0
- flyloft/service/tick.py +190 -0
- flyloft/tools/__init__.py +0 -0
- flyloft/tools/__main__.py +5 -0
- flyloft/tools/server.py +146 -0
- flyloft/turns/__init__.py +0 -0
- flyloft/turns/assembly.py +130 -0
- flyloft/turns/launch.py +137 -0
- flyloft/turns/roles/__init__.py +20 -0
- flyloft/turns/roles/critic.md.j2 +73 -0
- flyloft/turns/roles/implementer.md.j2 +87 -0
- flyloft/turns/substrates/__init__.py +35 -0
- flyloft/turns/substrates/claude_code.py +72 -0
- flyloft/turns/substrates/opencode.py +89 -0
- flyloft/turns/substrates/scripted.py +94 -0
- flyloft/workspace/__init__.py +0 -0
- flyloft/workspace/git.py +67 -0
- flyloft-0.1.0.dist-info/METADATA +10 -0
- flyloft-0.1.0.dist-info/RECORD +49 -0
- flyloft-0.1.0.dist-info/WHEEL +4 -0
- flyloft-0.1.0.dist-info/entry_points.txt +2 -0
flyloft/__init__.py
ADDED
|
File without changes
|
flyloft/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
flyloft/cli/__init__.py
ADDED
|
File without changes
|
flyloft/cli/main.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from uuid import UUID, uuid4
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from flyloft.config.load import Config, load_campaign_file
|
|
10
|
+
from flyloft.record import actions, store
|
|
11
|
+
from flyloft.record.db import connect
|
|
12
|
+
from flyloft.record.migrate import apply_pending
|
|
13
|
+
from flyloft.record.models import SteeringBody
|
|
14
|
+
from flyloft.record.views import campaign_views
|
|
15
|
+
from flyloft.turns.roles import ROLES
|
|
16
|
+
from flyloft.workspace import git as ws
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(no_args_is_help=True, help="flyloft: agent swarms for empirical research on Slurm clusters.")
|
|
19
|
+
campaign_app = typer.Typer(no_args_is_help=True, help="Start, inspect, and steer campaigns.")
|
|
20
|
+
run_app = typer.Typer(no_args_is_help=True, help="Act on runs.")
|
|
21
|
+
db_app = typer.Typer(no_args_is_help=True, help="Database maintenance.")
|
|
22
|
+
app.add_typer(campaign_app, name="campaign")
|
|
23
|
+
app.add_typer(run_app, name="run")
|
|
24
|
+
app.add_typer(db_app, name="db")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _user() -> str:
|
|
28
|
+
return os.environ.get("USER") or getpass.getuser()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _open():
|
|
32
|
+
cfg = Config.load()
|
|
33
|
+
return cfg, connect(cfg.root.database_url)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@db_app.command("migrate")
|
|
37
|
+
def db_migrate():
|
|
38
|
+
"""Apply pending schema migrations."""
|
|
39
|
+
_, conn = _open()
|
|
40
|
+
applied = apply_pending(conn)
|
|
41
|
+
typer.echo(f"applied: {applied or 'nothing'}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def serve():
|
|
46
|
+
"""Run the service loop in the foreground."""
|
|
47
|
+
from flyloft.service.factory import build_service
|
|
48
|
+
from flyloft.service.tick import run_forever
|
|
49
|
+
|
|
50
|
+
cfg = Config.load()
|
|
51
|
+
service = build_service(cfg)
|
|
52
|
+
service.startup()
|
|
53
|
+
run_forever(service, cfg.root.tick_interval)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@campaign_app.command("start")
|
|
57
|
+
def campaign_start(file: Path):
|
|
58
|
+
"""Create a campaign from a campaign file, one agent per role, and post its start entry."""
|
|
59
|
+
cfg, conn = _open()
|
|
60
|
+
cf = load_campaign_file(file)
|
|
61
|
+
try:
|
|
62
|
+
cfg.validate_campaign(cf)
|
|
63
|
+
except ValueError as e:
|
|
64
|
+
typer.echo(str(e), err=True)
|
|
65
|
+
raise typer.Exit(code=2)
|
|
66
|
+
project = cfg.projects[cf.project]
|
|
67
|
+
campaign_id = uuid4()
|
|
68
|
+
branch = f"flyloft/{campaign_id.hex[:8]}"
|
|
69
|
+
repo = ws.ensure_clone(cfg.root.data_dir, project)
|
|
70
|
+
ws.create_branch(repo, project.base_branch, branch)
|
|
71
|
+
agents = []
|
|
72
|
+
for name, role in ROLES.items():
|
|
73
|
+
agent_id = uuid4()
|
|
74
|
+
worktree = cfg.root.data_dir / "campaigns" / str(campaign_id) / "worktrees" / str(agent_id)
|
|
75
|
+
ws.add_worktree(repo, branch, worktree, detach=not role.edits_code)
|
|
76
|
+
agents.append((agent_id, name, cf.roles[name], worktree))
|
|
77
|
+
with conn.transaction():
|
|
78
|
+
store.create_campaign(
|
|
79
|
+
conn,
|
|
80
|
+
id=campaign_id,
|
|
81
|
+
project=cf.project,
|
|
82
|
+
cluster=cf.cluster or project.cluster,
|
|
83
|
+
question=cf.question,
|
|
84
|
+
criterion=cf.criterion,
|
|
85
|
+
baseline=cf.baseline,
|
|
86
|
+
constraints=cf.constraints,
|
|
87
|
+
submission_cap=cf.submission_cap,
|
|
88
|
+
branch=branch,
|
|
89
|
+
)
|
|
90
|
+
for agent_id, name, selection, worktree in agents:
|
|
91
|
+
store.create_agent(
|
|
92
|
+
conn,
|
|
93
|
+
id=agent_id,
|
|
94
|
+
campaign_id=campaign_id,
|
|
95
|
+
role=name,
|
|
96
|
+
substrate=selection.harness,
|
|
97
|
+
model=selection.model or cfg.harnesses[selection.harness].model,
|
|
98
|
+
workspace_path=str(worktree),
|
|
99
|
+
)
|
|
100
|
+
actions.steer(
|
|
101
|
+
conn,
|
|
102
|
+
campaign_id=campaign_id,
|
|
103
|
+
author=_user(),
|
|
104
|
+
summary="Campaign started",
|
|
105
|
+
body=SteeringBody(type="start", text=f"Campaign started by {_user()} from {file}"),
|
|
106
|
+
)
|
|
107
|
+
typer.echo(str(campaign_id))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@campaign_app.command("list")
|
|
111
|
+
def campaign_list():
|
|
112
|
+
"""List campaigns with their state and their agents' states."""
|
|
113
|
+
_, conn = _open()
|
|
114
|
+
for c in store.list_campaigns(conn):
|
|
115
|
+
agents = ", ".join(f"{a.role}:{a.state}" for a in store.agents_for_campaign(conn, c.id))
|
|
116
|
+
typer.echo(f"{c.id} {c.project:<12} {c.state:<8} {agents} {c.question[:50]}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@campaign_app.command("show")
|
|
120
|
+
def campaign_show(campaign_id: UUID):
|
|
121
|
+
"""Hypotheses, runs, findings, pull request, and submissions for one campaign."""
|
|
122
|
+
_, conn = _open()
|
|
123
|
+
c = store.get_campaign(conn, campaign_id)
|
|
124
|
+
v = campaign_views(conn, c.id)
|
|
125
|
+
typer.echo(f"Campaign {c.id} [{c.state}]" + (f" blocked: {c.block_reason}" if c.block_reason else ""))
|
|
126
|
+
typer.echo(f"Question: {c.question}\nCriterion: {c.criterion}\nBaseline: {c.baseline}")
|
|
127
|
+
typer.echo(f"Branch: {c.branch} PR: {c.pr_url or '(none)'}")
|
|
128
|
+
typer.echo(f"Submissions: {len(v.runs)} of {c.submission_cap}")
|
|
129
|
+
typer.echo("\nHypotheses:")
|
|
130
|
+
for h in v.hypotheses:
|
|
131
|
+
typer.echo(f" {h.id} [{h.state}] {h.title}: {h.change}")
|
|
132
|
+
typer.echo("\nRuns:")
|
|
133
|
+
for r in v.runs:
|
|
134
|
+
seen = r.last_observed_at.strftime("%H:%M:%S") if r.last_observed_at else "never"
|
|
135
|
+
typer.echo(f" {r.id} {r.purpose:<8} {r.commit_sha[:10]} [{r.state}] seen {seen} {r.metrics or ''}")
|
|
136
|
+
typer.echo("\nFindings:")
|
|
137
|
+
for f in v.findings:
|
|
138
|
+
typer.echo(f" {f.id} [{f.state}] {f.claim} (hypothesis {f.hypothesis_id})")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@campaign_app.command("log")
|
|
142
|
+
def campaign_log(
|
|
143
|
+
campaign_id: UUID, full: bool = typer.Option(False, "--full", help="Print bodies and assignment paths.")
|
|
144
|
+
):
|
|
145
|
+
"""Entries and turns interleaved by time."""
|
|
146
|
+
_, conn = _open()
|
|
147
|
+
items = []
|
|
148
|
+
for e in store.entries_for_campaign(conn, campaign_id):
|
|
149
|
+
line = f"{e.created_at:%Y-%m-%d %H:%M:%S} [{e.kind}] {e.author_kind}:{e.author[:8]} {e.summary}"
|
|
150
|
+
if full:
|
|
151
|
+
line += "\n " + json.dumps(e.body)
|
|
152
|
+
items.append((e.created_at, line))
|
|
153
|
+
for t in store.turns_for_campaign(conn, campaign_id):
|
|
154
|
+
line = f"{t.started_at:%Y-%m-%d %H:%M:%S} [turn] {t.role} via {t.substrate} exit={t.exit_status}"
|
|
155
|
+
if full:
|
|
156
|
+
line += f"\n assignment: {t.assignment_path}"
|
|
157
|
+
items.append((t.started_at, line))
|
|
158
|
+
for _, line in sorted(items, key=lambda x: x[0]):
|
|
159
|
+
typer.echo(line)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@campaign_app.command("steer")
|
|
163
|
+
def campaign_steer(campaign_id: UUID, text: str):
|
|
164
|
+
"""Post an instruction. A blocked campaign becomes active."""
|
|
165
|
+
_, conn = _open()
|
|
166
|
+
actions.steer(
|
|
167
|
+
conn,
|
|
168
|
+
campaign_id=campaign_id,
|
|
169
|
+
author=_user(),
|
|
170
|
+
summary=text[:80],
|
|
171
|
+
body=SteeringBody(type="instruction", text=text),
|
|
172
|
+
)
|
|
173
|
+
typer.echo("posted")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@campaign_app.command("stop")
|
|
177
|
+
def campaign_stop(campaign_id: UUID):
|
|
178
|
+
"""Stop new work. Outstanding runs finish; open hypotheses are abandoned."""
|
|
179
|
+
_, conn = _open()
|
|
180
|
+
actions.steer(conn, campaign_id=campaign_id, author=_user(), summary="Stop", body=SteeringBody(type="stop"))
|
|
181
|
+
typer.echo("stopped")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@run_app.command("cancel")
|
|
185
|
+
def run_cancel(run_id: UUID):
|
|
186
|
+
"""Cancel a run. The service issues scancel on its next tick."""
|
|
187
|
+
_, conn = _open()
|
|
188
|
+
run = store.get_run(conn, run_id)
|
|
189
|
+
actions.steer(
|
|
190
|
+
conn,
|
|
191
|
+
campaign_id=run.campaign_id,
|
|
192
|
+
author=_user(),
|
|
193
|
+
summary=f"Cancel run {run_id}",
|
|
194
|
+
body=SteeringBody(type="cancel_run", run_id=run_id),
|
|
195
|
+
)
|
|
196
|
+
typer.echo("cancel requested")
|
|
File without changes
|
flyloft/config/load.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tomllib
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from flyloft.turns.roles import ROLES
|
|
7
|
+
|
|
8
|
+
from .models import CampaignFile, ClusterConfig, FlyloftConfig, HarnessConfig, ProjectConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _read(path: Path) -> dict:
|
|
12
|
+
with path.open("rb") as f:
|
|
13
|
+
return tomllib.load(f)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _named(dir: Path, model):
|
|
17
|
+
out = {}
|
|
18
|
+
if dir.is_dir():
|
|
19
|
+
for p in sorted(dir.glob("*.toml")):
|
|
20
|
+
out[p.stem] = model(name=p.stem, **_read(p))
|
|
21
|
+
return out
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Config:
|
|
26
|
+
config_dir: Path
|
|
27
|
+
root: FlyloftConfig
|
|
28
|
+
projects: dict[str, ProjectConfig]
|
|
29
|
+
clusters: dict[str, ClusterConfig]
|
|
30
|
+
harnesses: dict[str, HarnessConfig]
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def load(cls, config_dir: Path | None = None) -> "Config":
|
|
34
|
+
config_dir = Path(config_dir or os.environ.get("FLYLOFT_CONFIG_DIR", Path.home() / ".config" / "flyloft"))
|
|
35
|
+
root_data = _read(config_dir / "flyloft.toml")
|
|
36
|
+
return cls(
|
|
37
|
+
config_dir=config_dir,
|
|
38
|
+
root=FlyloftConfig(**root_data),
|
|
39
|
+
projects=_named(config_dir / "projects", ProjectConfig),
|
|
40
|
+
clusters=_named(config_dir / "clusters", ClusterConfig),
|
|
41
|
+
harnesses=_named(config_dir / "harnesses", HarnessConfig),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def validate_campaign(self, cf: CampaignFile) -> None:
|
|
45
|
+
if cf.project not in self.projects:
|
|
46
|
+
raise ValueError(f"unknown project '{cf.project}'")
|
|
47
|
+
for role in cf.roles:
|
|
48
|
+
if role not in ROLES:
|
|
49
|
+
raise ValueError(f"unknown role '{role}'")
|
|
50
|
+
for role in ROLES:
|
|
51
|
+
if role not in cf.roles:
|
|
52
|
+
raise ValueError(f"campaign file needs a [roles.{role}] table")
|
|
53
|
+
if cf.roles[role].harness not in self.harnesses:
|
|
54
|
+
raise ValueError(f"unknown harness '{cf.roles[role].harness}' for role '{role}'")
|
|
55
|
+
cluster = cf.cluster or self.projects[cf.project].cluster
|
|
56
|
+
if cluster not in self.clusters:
|
|
57
|
+
raise ValueError(f"unknown cluster '{cluster}'")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_campaign_file(path: Path) -> CampaignFile:
|
|
61
|
+
return CampaignFile(**_read(Path(path)))
|
flyloft/config/models.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ResourceProfile(BaseModel):
|
|
8
|
+
cpus: int = 1
|
|
9
|
+
mem: str = "1G"
|
|
10
|
+
time: str = "00:10:00"
|
|
11
|
+
gpus: int = 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProjectConfig(BaseModel):
|
|
15
|
+
name: str
|
|
16
|
+
repo_url: str
|
|
17
|
+
base_branch: str
|
|
18
|
+
brief_path: str
|
|
19
|
+
cluster: str
|
|
20
|
+
run_command: str
|
|
21
|
+
results_path: str
|
|
22
|
+
resources: ResourceProfile = ResourceProfile()
|
|
23
|
+
environment_setup: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClusterConfig(BaseModel):
|
|
27
|
+
name: str
|
|
28
|
+
host: str
|
|
29
|
+
user: str
|
|
30
|
+
remote_work_dir: str
|
|
31
|
+
account: str | None = None
|
|
32
|
+
partition: str | None = None
|
|
33
|
+
password_env: str | None = None
|
|
34
|
+
container_image: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HarnessConfig(BaseModel):
|
|
38
|
+
name: str
|
|
39
|
+
kind: Literal["claude-code", "opencode", "scripted"]
|
|
40
|
+
model: str | None = None
|
|
41
|
+
base_url_env: str | None = None
|
|
42
|
+
api_key_env: str | None = None
|
|
43
|
+
max_turns: int = 30
|
|
44
|
+
script: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FlyloftConfig(BaseModel):
|
|
48
|
+
database_url: str
|
|
49
|
+
data_dir: Path
|
|
50
|
+
tick_interval: float = 15.0
|
|
51
|
+
max_concurrent_turns: int = 2
|
|
52
|
+
max_outstanding_jobs: int = 4
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RoleSelection(BaseModel):
|
|
56
|
+
harness: str
|
|
57
|
+
model: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CampaignFile(BaseModel):
|
|
61
|
+
project: str
|
|
62
|
+
question: str
|
|
63
|
+
criterion: str
|
|
64
|
+
baseline: str
|
|
65
|
+
constraints: str = ""
|
|
66
|
+
submission_cap: int
|
|
67
|
+
roles: dict[str, RoleSelection]
|
|
68
|
+
cluster: str | None = None
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from jinja2 import Environment, FileSystemLoader
|
|
4
|
+
|
|
5
|
+
from flyloft.record.models import Campaign
|
|
6
|
+
from flyloft.record.views import campaign_views
|
|
7
|
+
|
|
8
|
+
_env = Environment(
|
|
9
|
+
loader=FileSystemLoader(Path(__file__).parent), autoescape=False, trim_blocks=True, lstrip_blocks=True
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def pr_title(campaign: Campaign) -> str:
|
|
14
|
+
return f"flyloft: {campaign.question[:70]}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def render_pr_body(conn, campaign: Campaign) -> str:
|
|
18
|
+
v = campaign_views(conn, campaign.id)
|
|
19
|
+
baseline = next((r for r in v.runs if r.purpose == "baseline" and r.state == "finished"), None)
|
|
20
|
+
return _env.get_template("pr_body.md.j2").render(
|
|
21
|
+
campaign=campaign,
|
|
22
|
+
baseline=baseline,
|
|
23
|
+
runs=v.runs,
|
|
24
|
+
hypotheses=v.hypotheses,
|
|
25
|
+
findings=v.findings,
|
|
26
|
+
critiques=v.critiques,
|
|
27
|
+
assessments=v.assessments,
|
|
28
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def owner_repo(repo_url: str) -> str:
|
|
6
|
+
m = re.search(r"github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?/?$", repo_url)
|
|
7
|
+
if not m:
|
|
8
|
+
raise ValueError(f"not a GitHub repository URL: {repo_url}")
|
|
9
|
+
return f"{m.group(1)}/{m.group(2)}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GhPublisher:
|
|
13
|
+
def publish(
|
|
14
|
+
self, *, repo_url: str, base_branch: str, branch: str, title: str, body: str, pr_url: str | None
|
|
15
|
+
) -> str:
|
|
16
|
+
repo = owner_repo(repo_url)
|
|
17
|
+
if pr_url:
|
|
18
|
+
subprocess.run(
|
|
19
|
+
["gh", "pr", "edit", pr_url, "--repo", repo, "--title", title, "--body-file", "-"],
|
|
20
|
+
input=body,
|
|
21
|
+
text=True,
|
|
22
|
+
check=True,
|
|
23
|
+
capture_output=True,
|
|
24
|
+
)
|
|
25
|
+
return pr_url
|
|
26
|
+
cp = subprocess.run(
|
|
27
|
+
[
|
|
28
|
+
"gh",
|
|
29
|
+
"pr",
|
|
30
|
+
"create",
|
|
31
|
+
"--repo",
|
|
32
|
+
repo,
|
|
33
|
+
"--draft",
|
|
34
|
+
"--base",
|
|
35
|
+
base_branch,
|
|
36
|
+
"--head",
|
|
37
|
+
branch,
|
|
38
|
+
"--title",
|
|
39
|
+
title,
|
|
40
|
+
"--body-file",
|
|
41
|
+
"-",
|
|
42
|
+
],
|
|
43
|
+
input=body,
|
|
44
|
+
text=True,
|
|
45
|
+
check=True,
|
|
46
|
+
capture_output=True,
|
|
47
|
+
)
|
|
48
|
+
return cp.stdout.strip().splitlines()[-1]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
flyloft campaign `{{ campaign.id }}` on branch `{{ campaign.branch }}`. Campaign state: {{ campaign.state }}.
|
|
2
|
+
|
|
3
|
+
**Question.** {{ campaign.question }}
|
|
4
|
+
|
|
5
|
+
**Success criterion.** {{ campaign.criterion }}
|
|
6
|
+
|
|
7
|
+
**Baseline.** {{ campaign.baseline }}
|
|
8
|
+
{% if baseline %}
|
|
9
|
+
Baseline run `{{ baseline.id }}` at commit `{{ baseline.commit_sha }}`: {{ baseline.metrics }}
|
|
10
|
+
{% endif %}
|
|
11
|
+
|
|
12
|
+
## Findings
|
|
13
|
+
|
|
14
|
+
Accepted by the critic.
|
|
15
|
+
{% for f in findings if f.state == "accepted" %}
|
|
16
|
+
|
|
17
|
+
### {{ f.claim }}
|
|
18
|
+
|
|
19
|
+
- Hypothesis `{{ f.hypothesis_id }}`
|
|
20
|
+
- Baseline run `{{ f.baseline_run_id }}`: {{ f.baseline_metrics }}
|
|
21
|
+
- Candidate runs: {% for rid in f.candidate_run_ids %}`{{ rid }}` {% endfor %}: {{ f.candidate_metrics }}
|
|
22
|
+
- Commits: {% for r in runs if (r.id | string) in f.candidate_run_ids %}`{{ r.commit_sha }}` {% endfor %}
|
|
23
|
+
- Limitations: {{ f.limitations }}
|
|
24
|
+
{% for c in critiques if c.finding_id == (f.id | string) %}
|
|
25
|
+
- Critic: {{ c.reasoning }}
|
|
26
|
+
{% endfor %}
|
|
27
|
+
{% else %}
|
|
28
|
+
|
|
29
|
+
None yet.
|
|
30
|
+
{% endfor %}
|
|
31
|
+
|
|
32
|
+
## Findings under review or superseded
|
|
33
|
+
{% for f in findings if f.state in ["candidate", "blocked", "superseded"] %}
|
|
34
|
+
- [{{ f.state }}] {{ f.claim }} (hypothesis `{{ f.hypothesis_id }}`){% for c in critiques if c.finding_id == (f.id | string) %} Critic: {{ c.reasoning }}{% if c.demands %} Demands: {{ c.demands }}{% endif %}{% endfor %}
|
|
35
|
+
{% else %}
|
|
36
|
+
None.
|
|
37
|
+
{% endfor %}
|
|
38
|
+
|
|
39
|
+
## Hypotheses not supported or abandoned
|
|
40
|
+
{% for h in hypotheses if h.state in ["refuted", "inconclusive", "unexecuted", "abandoned"] %}
|
|
41
|
+
- [{{ h.state }}] {{ h.title }}: {{ h.change }}.{% for a in assessments if (a.hypothesis_id | string) == (h.id | string) %} {{ a.reasoning }}{% endfor %}{% for f in findings if f.hypothesis_id == (h.id | string) and f.state == "withdrawn" %} Withdrawn finding: {{ f.claim }}{% endfor %}
|
|
42
|
+
{% else %}
|
|
43
|
+
None.
|
|
44
|
+
{% endfor %}
|
|
45
|
+
|
|
46
|
+
## Open
|
|
47
|
+
{% for h in hypotheses if h.state in ["proposed", "testing"] %}
|
|
48
|
+
- [{{ h.state }}] {{ h.title }}: {{ h.change }}
|
|
49
|
+
{% else %}
|
|
50
|
+
None.
|
|
51
|
+
{% endfor %}
|
|
52
|
+
|
|
53
|
+
## Runs
|
|
54
|
+
|
|
55
|
+
| Run | Purpose | Commit | State | Metrics |
|
|
56
|
+
|---|---|---|---|---|
|
|
57
|
+
{% for r in runs %}
|
|
58
|
+
| `{{ r.id }}` | {{ r.purpose }} | `{{ r.commit_sha }}` | {{ r.state }} | {{ r.metrics }} |
|
|
59
|
+
{% endfor %}
|
|
File without changes
|