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.
Files changed (49) hide show
  1. flyloft/__init__.py +0 -0
  2. flyloft/_version.py +24 -0
  3. flyloft/cli/__init__.py +0 -0
  4. flyloft/cli/main.py +196 -0
  5. flyloft/config/__init__.py +0 -0
  6. flyloft/config/load.py +61 -0
  7. flyloft/config/models.py +68 -0
  8. flyloft/publication/__init__.py +0 -0
  9. flyloft/publication/body.py +28 -0
  10. flyloft/publication/gh.py +48 -0
  11. flyloft/publication/pr_body.md.j2 +59 -0
  12. flyloft/publication/publisher.py +7 -0
  13. flyloft/record/__init__.py +0 -0
  14. flyloft/record/actions.py +294 -0
  15. flyloft/record/consistency.py +64 -0
  16. flyloft/record/db.py +9 -0
  17. flyloft/record/migrate.py +21 -0
  18. flyloft/record/models.py +189 -0
  19. flyloft/record/store.py +324 -0
  20. flyloft/record/views.py +57 -0
  21. flyloft/runs/__init__.py +0 -0
  22. flyloft/runs/askpass.sh +2 -0
  23. flyloft/runs/client.py +18 -0
  24. flyloft/runs/collect.py +37 -0
  25. flyloft/runs/observe.py +21 -0
  26. flyloft/runs/ssh.py +105 -0
  27. flyloft/runs/staging.py +33 -0
  28. flyloft/service/__init__.py +0 -0
  29. flyloft/service/factory.py +13 -0
  30. flyloft/service/tick.py +190 -0
  31. flyloft/tools/__init__.py +0 -0
  32. flyloft/tools/__main__.py +5 -0
  33. flyloft/tools/server.py +146 -0
  34. flyloft/turns/__init__.py +0 -0
  35. flyloft/turns/assembly.py +130 -0
  36. flyloft/turns/launch.py +137 -0
  37. flyloft/turns/roles/__init__.py +20 -0
  38. flyloft/turns/roles/critic.md.j2 +73 -0
  39. flyloft/turns/roles/implementer.md.j2 +87 -0
  40. flyloft/turns/substrates/__init__.py +35 -0
  41. flyloft/turns/substrates/claude_code.py +72 -0
  42. flyloft/turns/substrates/opencode.py +89 -0
  43. flyloft/turns/substrates/scripted.py +94 -0
  44. flyloft/workspace/__init__.py +0 -0
  45. flyloft/workspace/git.py +67 -0
  46. flyloft-0.1.0.dist-info/METADATA +10 -0
  47. flyloft-0.1.0.dist-info/RECORD +49 -0
  48. flyloft-0.1.0.dist-info/WHEEL +4 -0
  49. 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
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)))
@@ -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 %}
@@ -0,0 +1,7 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class Publisher(Protocol):
5
+ def publish(
6
+ self, *, repo_url: str, base_branch: str, branch: str, title: str, body: str, pr_url: str | None
7
+ ) -> str: ...
File without changes