Skip to content

Commit f7e49b8

Browse files
committed
Merge remote-tracking branch 'origin/main' into panopticon/task-cli-override
2 parents 03b78b2 + 53bd2f8 commit f7e49b8

3 files changed

Lines changed: 191 additions & 61 deletions

File tree

src/panopticon/taskservice/store_sqlalchemy.py

Lines changed: 74 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
One adapter serves every SQL backend SQLAlchemy speaks; **"in-memory" is just an in-memory
44
SQLite engine**. The pure, frozen domain models (:mod:`panopticon.core.models`) never touch
55
the ORM — this adapter owns mutable *row* classes and each knows how to translate itself
6-
``to_domain`` / ``from_domain``. Parent→child links are ORM ``relationship``\\ s (loaded
6+
``to_domain`` / ``from_domain``. Each row class states its domain→column mapping **once**, in
7+
``column_values``, which both the insert (``from_domain``) and the update (``_apply_columns``)
8+
go through — so a newly added column can't be carried on create and silently dropped on update.
9+
Parent→child links are ORM ``relationship``\\ s (loaded
710
eagerly via ``selectin``), so reading a task pulls in its history and responsibilities and
811
writing one cascades — no hand-written load/insert code.
912
@@ -72,6 +75,18 @@ class _Base(DeclarativeBase):
7275
metadata = _Base.metadata
7376

7477

78+
def _apply_columns(row: _Base, values: dict[str, Any]) -> None:
79+
"""Overwrite ``row``'s columns from a ``column_values`` mapping, leaving its ``id`` alone.
80+
81+
The counterpart to each row class's ``from_domain``: an update writes exactly the columns an
82+
insert does, so a newly added column can't be persisted on create and then silently dropped
83+
on update (which is how ``Repo.agent_cli`` was lost — see ``column_values``).
84+
"""
85+
for key, value in values.items():
86+
if key != "id":
87+
setattr(row, key, value)
88+
89+
7590
class _RepoRow(_Base):
7691
__tablename__ = "repo"
7792

@@ -104,22 +119,32 @@ def to_domain(self) -> Repo:
104119
disabled_workflows=list(self.disabled_workflows or []),
105120
)
106121

122+
@classmethod
123+
def column_values(cls, repo: Repo) -> dict[str, Any]:
124+
"""The domain→row column mapping: the one place a new repo column gets wired up.
125+
126+
Both the insert (:meth:`from_domain`) and the update (:func:`_apply_columns`) read it, so
127+
the two can't drift — the failure mode this replaces was ``agent_cli`` being carried on
128+
create and dropped by a hand-copied update, making a repo un-switchable to another CLI.
129+
"""
130+
return {
131+
"id": repo.id,
132+
"name": repo.name,
133+
"git_url": repo.git_url,
134+
"default_base": repo.default_base,
135+
"env_file": repo.env_file,
136+
"credential_dir": repo.credential_dir,
137+
"image_layer_file": repo.image_layer_file,
138+
"capabilities": dict(repo.capabilities),
139+
"hook_file": repo.hook_file,
140+
"agent_cli": repo.agent_cli,
141+
"enabled_workflows": list(repo.enabled_workflows),
142+
"disabled_workflows": list(repo.disabled_workflows),
143+
}
144+
107145
@classmethod
108146
def from_domain(cls, repo: Repo) -> _RepoRow:
109-
return cls(
110-
id=repo.id,
111-
name=repo.name,
112-
git_url=repo.git_url,
113-
default_base=repo.default_base,
114-
env_file=repo.env_file,
115-
credential_dir=repo.credential_dir,
116-
image_layer_file=repo.image_layer_file,
117-
capabilities=dict(repo.capabilities),
118-
hook_file=repo.hook_file,
119-
agent_cli=repo.agent_cli,
120-
enabled_workflows=list(repo.enabled_workflows),
121-
disabled_workflows=list(repo.disabled_workflows),
122-
)
147+
return cls(**cls.column_values(repo))
123148

124149

125150
class _TaskRow(_Base):
@@ -179,30 +204,41 @@ def to_domain(self) -> Task:
179204
history=[h.to_domain() for h in self.history],
180205
)
181206

207+
@classmethod
208+
def column_values(cls, task: Task) -> dict[str, Any]:
209+
"""The domain→row column mapping: the one place a new task column gets wired up.
210+
211+
Columns only — ``history`` is a relationship, persisted append-only by ``_update_task``
212+
(see the module docstring), never through this.
213+
"""
214+
return {
215+
"id": task.id,
216+
"repo_id": task.repo_id,
217+
"workflow": task.workflow,
218+
"state": task.state,
219+
"turn": task.turn.value,
220+
"blocked": task.blocked,
221+
"memo": task.memo,
222+
"initial_prompt": task.initial_prompt,
223+
"slug": task.slug,
224+
"url": task.url,
225+
"snoozed_until": task.snoozed_until,
226+
"branch": task.branch,
227+
"clone": task.clone,
228+
"claimed_by": task.claimed_by,
229+
"starting_model": task.starting_model,
230+
"agent_cli": task.agent_cli,
231+
"governor_task_id": task.governor_task_id,
232+
"created_at": task.created_at,
233+
"updated_at": task.updated_at,
234+
"sort_weight": task.sort_weight,
235+
"depends_on_task_ids": list(task.depends_on_task_ids),
236+
}
237+
182238
@classmethod
183239
def from_domain(cls, task: Task) -> _TaskRow:
184240
return cls(
185-
id=task.id,
186-
repo_id=task.repo_id,
187-
workflow=task.workflow,
188-
state=task.state,
189-
turn=task.turn.value,
190-
blocked=task.blocked,
191-
memo=task.memo,
192-
initial_prompt=task.initial_prompt,
193-
slug=task.slug,
194-
url=task.url,
195-
snoozed_until=task.snoozed_until,
196-
branch=task.branch,
197-
clone=task.clone,
198-
claimed_by=task.claimed_by,
199-
starting_model=task.starting_model,
200-
agent_cli=task.agent_cli,
201-
governor_task_id=task.governor_task_id,
202-
created_at=task.created_at,
203-
updated_at=task.updated_at,
204-
sort_weight=task.sort_weight,
205-
depends_on_task_ids=list(task.depends_on_task_ids),
241+
**cls.column_values(task),
206242
history=[_HistoryRow.from_domain(e, seq) for seq, e in enumerate(task.history)],
207243
)
208244

@@ -348,16 +384,7 @@ async def _update_repo(self, repo: Repo) -> None:
348384
row = await s.get(_RepoRow, repo.id)
349385
if row is None:
350386
raise NotFound(f"repo {repo.id!r} does not exist")
351-
row.name = repo.name
352-
row.git_url = repo.git_url
353-
row.default_base = repo.default_base
354-
row.env_file = repo.env_file
355-
row.credential_dir = repo.credential_dir
356-
row.image_layer_file = repo.image_layer_file
357-
row.capabilities = dict(repo.capabilities)
358-
row.hook_file = repo.hook_file
359-
row.enabled_workflows = list(repo.enabled_workflows)
360-
row.disabled_workflows = list(repo.disabled_workflows)
387+
_apply_columns(row, _RepoRow.column_values(repo))
361388

362389
# -- tasks: reads + persistence primitives (the base's template methods drive these) --
363390

@@ -398,19 +425,7 @@ async def _update_task(self, task: Task, stored: Sequence[HistoryEntry]) -> None
398425
row = await s.get(_TaskRow, task.id)
399426
if row is None: # defensive: single-writer, so it still exists after _stored_history
400427
raise NotFound(f"task {task.id!r} does not exist")
401-
row.state = task.state
402-
row.turn = task.turn.value
403-
row.blocked = task.blocked
404-
row.slug = task.slug
405-
row.url = task.url
406-
row.snoozed_until = task.snoozed_until
407-
row.branch = task.branch
408-
row.clone = task.clone
409-
row.claimed_by = task.claimed_by
410-
row.governor_task_id = task.governor_task_id
411-
row.updated_at = task.updated_at
412-
row.sort_weight = task.sort_weight
413-
row.depends_on_task_ids = list(task.depends_on_task_ids)
428+
_apply_columns(row, _TaskRow.column_values(task))
414429
# The current (last stored) entry's promises may have been fulfilled in place.
415430
if stored:
416431
_fulfil_current_promises(

tests/core/test_store.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from __future__ import annotations
1010

1111
from collections.abc import AsyncIterator
12-
from dataclasses import MISSING, fields, is_dataclass
12+
from dataclasses import MISSING, fields, is_dataclass, replace
1313
from pathlib import Path
1414
from typing import Any, get_args, get_origin, get_type_hints
1515

@@ -477,6 +477,42 @@ def test_rows_and_domain_models_stay_in_sync(domain: type) -> None:
477477
)
478478

479479

480+
def _fully_populated_repo() -> Repo:
481+
"""A repo touching every field of Repo with a non-default value."""
482+
return Repo(
483+
id="r-full",
484+
name="acme/widgets",
485+
git_url="https://github.com/acme/widgets.git",
486+
default_base="trunk",
487+
env_file="r-full.env",
488+
credential_dir="r-full-creds",
489+
image_layer_file="r-full.layer",
490+
capabilities={"docker_in_docker": True},
491+
hook_file="prep.sh",
492+
enabled_workflows=["spike"],
493+
disabled_workflows=["github-peer-reviewed"],
494+
agent_cli="codex",
495+
)
496+
497+
498+
def _mutated_repo(repo: Repo) -> Repo:
499+
"""``repo`` with every updatable field moved to a second distinct value."""
500+
return replace(
501+
repo,
502+
name="acme/gadgets",
503+
git_url="https://github.com/acme/gadgets.git",
504+
default_base="main",
505+
env_file="other.env",
506+
credential_dir="other-creds",
507+
image_layer_file="other.layer",
508+
capabilities={"docker_in_docker": False},
509+
hook_file="other.sh",
510+
enabled_workflows=["github-self-reviewed"],
511+
disabled_workflows=["spike"],
512+
agent_cli="claude",
513+
)
514+
515+
480516
def _fully_populated_task() -> Task:
481517
"""A task touching every field of Task/HistoryEntry/Responsibility with a non-default value."""
482518
return Task(
@@ -530,6 +566,38 @@ def _fully_populated_task() -> Task:
530566
)
531567

532568

569+
def _mutated_task(task: Task) -> Task:
570+
"""``task`` with every updatable field moved to a second distinct value.
571+
572+
The appended history entry carries the new state (a task's state must match its history
573+
tail) — updating in place is not allowed for recorded transitions.
574+
"""
575+
return replace(
576+
task,
577+
state="REVIEW",
578+
turn=Actor.USER,
579+
blocked=False,
580+
memo="make the widget blue",
581+
initial_prompt="have another look",
582+
slug="fix-the-gadget",
583+
url="https://github.com/acme/widgets/pull/8",
584+
snoozed_until="2026-09-06T03:00:00+00:00",
585+
branch="panopticon/fix-the-gadget",
586+
clone="/clones/t-full-again",
587+
claimed_by="remote",
588+
starting_model="secondary",
589+
agent_cli="claude",
590+
governor_task_id=None,
591+
updated_at="t3",
592+
sort_weight=9,
593+
depends_on_task_ids=["t-dep-2"],
594+
history=[
595+
*task.history,
596+
HistoryEntry(at="t3", from_state="WORKING", to_state="REVIEW", trigger="advance"),
597+
],
598+
)
599+
600+
533601
def _assert_every_field_exercised(instances: list[Any], domain: type) -> None:
534602
"""Fail if any field of ``domain`` equals its default across *all* ``instances``.
535603
@@ -549,6 +617,52 @@ def _assert_every_field_exercised(instances: list[Any], domain: type) -> None:
549617
)
550618

551619

620+
def _assert_every_field_changed(before: Any, after: Any, domain: type, immutable: set[str]) -> None:
621+
"""Fail if any updatable field of ``domain`` holds the same value in ``before`` and ``after``.
622+
623+
The mirror of :func:`_assert_every_field_exercised` for the *update* path: it forces the
624+
mutation fixture to move every new field, so the round-trip below genuinely proves the field
625+
is written by an update — the gap that let ``Repo.agent_cli`` be carried on create and
626+
silently dropped on update.
627+
"""
628+
for f in fields(domain):
629+
if f.name in immutable:
630+
continue
631+
if getattr(before, f.name) == getattr(after, f.name):
632+
pytest.fail(
633+
f"{domain.__name__}.{f.name} is never changed — extend the mutation fixture"
634+
)
635+
636+
637+
async def test_full_repo_round_trips_on_create_and_update(store: Store) -> None:
638+
repo = _fully_populated_repo()
639+
updated = _mutated_repo(repo)
640+
_assert_every_field_exercised([repo], Repo)
641+
_assert_every_field_changed(repo, updated, Repo, immutable={"id"})
642+
643+
await store.create_repo(repo)
644+
assert await store.get_repo(repo.id) == repo # dataclass __eq__: every field survives
645+
646+
await store.update_repo(updated)
647+
assert await store.get_repo(repo.id) == updated # ...and an update writes every one of them
648+
649+
650+
# Identity and creation facts: set once by the insert and never rewritten, so the update
651+
# round-trip below doesn't assert on them. Any *new* field is guarded by default.
652+
_TASK_IMMUTABLE = {"id", "repo_id", "workflow", "created_at", "history"}
653+
654+
655+
async def test_full_task_update_round_trips_and_exercises_every_field(store: Store) -> None:
656+
await _seed_repo(store)
657+
task = _fully_populated_task()
658+
updated = _mutated_task(task)
659+
_assert_every_field_changed(task, updated, Task, immutable=_TASK_IMMUTABLE)
660+
661+
await store.create_task(task)
662+
await store.save_task(updated)
663+
assert await store.get_task(task.id) == updated
664+
665+
552666
async def test_full_task_round_trips_and_exercises_every_field(store: Store) -> None:
553667
await _seed_repo(store)
554668
task = _fully_populated_task()

tests/taskservice/test_client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,8 @@ def test_repo_agent_cli_defaults_and_round_trips_over_rest(client: TaskServiceCl
130130
assert codex["agent_cli"] == "codex"
131131
assert client.get_repo("r9")["agent_cli"] == "codex" # persisted
132132
patched = client.update_repo("r9", agent_cli="claude")
133-
assert patched["agent_cli"] == "claude" # PATCH updates it
133+
assert patched["agent_cli"] == "claude" # PATCH updates it...
134+
assert client.get_repo("r9")["agent_cli"] == "claude" # ...and the update is persisted
134135

135136

136137
def test_create_task_carries_agent_cli_over_rest(client: TaskServiceClient) -> None:

0 commit comments

Comments
 (0)