|
3 | 3 | One adapter serves every SQL backend SQLAlchemy speaks; **"in-memory" is just an in-memory |
4 | 4 | SQLite engine**. The pure, frozen domain models (:mod:`panopticon.core.models`) never touch |
5 | 5 | 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 |
7 | 10 | eagerly via ``selectin``), so reading a task pulls in its history and responsibilities and |
8 | 11 | writing one cascades — no hand-written load/insert code. |
9 | 12 |
|
@@ -72,6 +75,18 @@ class _Base(DeclarativeBase): |
72 | 75 | metadata = _Base.metadata |
73 | 76 |
|
74 | 77 |
|
| 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 | + |
75 | 90 | class _RepoRow(_Base): |
76 | 91 | __tablename__ = "repo" |
77 | 92 |
|
@@ -104,22 +119,32 @@ def to_domain(self) -> Repo: |
104 | 119 | disabled_workflows=list(self.disabled_workflows or []), |
105 | 120 | ) |
106 | 121 |
|
| 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 | + |
107 | 145 | @classmethod |
108 | 146 | 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)) |
123 | 148 |
|
124 | 149 |
|
125 | 150 | class _TaskRow(_Base): |
@@ -179,30 +204,41 @@ def to_domain(self) -> Task: |
179 | 204 | history=[h.to_domain() for h in self.history], |
180 | 205 | ) |
181 | 206 |
|
| 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 | + |
182 | 238 | @classmethod |
183 | 239 | def from_domain(cls, task: Task) -> _TaskRow: |
184 | 240 | 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), |
206 | 242 | history=[_HistoryRow.from_domain(e, seq) for seq, e in enumerate(task.history)], |
207 | 243 | ) |
208 | 244 |
|
@@ -348,16 +384,7 @@ async def _update_repo(self, repo: Repo) -> None: |
348 | 384 | row = await s.get(_RepoRow, repo.id) |
349 | 385 | if row is None: |
350 | 386 | 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)) |
361 | 388 |
|
362 | 389 | # -- tasks: reads + persistence primitives (the base's template methods drive these) -- |
363 | 390 |
|
@@ -398,19 +425,7 @@ async def _update_task(self, task: Task, stored: Sequence[HistoryEntry]) -> None |
398 | 425 | row = await s.get(_TaskRow, task.id) |
399 | 426 | if row is None: # defensive: single-writer, so it still exists after _stored_history |
400 | 427 | 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)) |
414 | 429 | # The current (last stored) entry's promises may have been fulfilled in place. |
415 | 430 | if stored: |
416 | 431 | _fulfil_current_promises( |
|
0 commit comments