@team-agent/installer 0.2.4 → 0.2.6

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.
@@ -0,0 +1,205 @@
1
+ """Family A — positive-source owner binding (0.2.6).
2
+
3
+ Constitution MUST-11 / MUST-NOT-12: the caller identity for owner binding
4
+ is sourced from the caller-supplied positive facts only:
5
+
6
+ 1. ``$TMUX_PANE`` (the tmux pane the user invoked the CLI from).
7
+ 2. ``tmux display-message -p -t $TMUX_PANE '#{pane_current_command}'``
8
+ (a single targeted lookup to confirm the caller pane is hosting a
9
+ leader CLI).
10
+
11
+ Reverse enumeration of panes / windows / clients is forbidden. Heuristic
12
+ ranking ("active pane", "current client", "first leader-shaped pane") is
13
+ forbidden. ``$TMUX_PANE`` missing or the caller pane not running a leader
14
+ host → refuse and emit ``owner.bind_refused``. Successful binds emit
15
+ ``owner.bound_from_caller_pane`` and force-write every owner identity
16
+ field; old fields are not merged or migrated.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import os
23
+ import subprocess
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from team_agent.events import EventLog
29
+
30
+
31
+ def run_cmd(args: list[str], timeout: int = 5) -> subprocess.CompletedProcess[str]:
32
+ """Tmux command bridge — kept here so contract tests can patch
33
+ ``team_agent.leader_binding.run_cmd`` to observe the exact tmux call
34
+ pattern this module makes."""
35
+ return subprocess.run(
36
+ args, text=True, capture_output=True, timeout=timeout, check=False
37
+ )
38
+
39
+
40
+ LEADER_HOST_COMMANDS = frozenset({"claude", "claude.exe", "codex"})
41
+
42
+ _HINT_RUN_FROM_LEADER_PANE = (
43
+ "run team-agent from inside your leader pane "
44
+ "(the tmux pane currently running claude or codex)."
45
+ )
46
+
47
+
48
+ def bind_owner_from_caller_pane(
49
+ workspace: Path,
50
+ team_id: str,
51
+ override_uuid: str | None = None,
52
+ ) -> dict[str, Any]:
53
+ """Derive the owner identity from the caller pane.
54
+
55
+ Returns
56
+ -------
57
+ On success::
58
+
59
+ {
60
+ "ok": True,
61
+ "owner": {
62
+ "pane_id": ..., "leader_session_uuid": ...,
63
+ "machine_fingerprint": ..., "provider": ...,
64
+ "os_user": ..., "claimed_at": <iso8601>,
65
+ },
66
+ "caller_pane_id": ..., "caller_current_command": ...,
67
+ "team_id": team_id,
68
+ }
69
+
70
+ On refusal::
71
+
72
+ {
73
+ "ok": False,
74
+ "reason": "caller_pane_missing" | "caller_not_leader_shaped",
75
+ "caller_pane_id": ..., "caller_current_command": ...,
76
+ "hint": ...,
77
+ }
78
+ """
79
+ event_log = EventLog(workspace)
80
+ caller_pane = os.environ.get("TMUX_PANE") or ""
81
+ if not caller_pane:
82
+ hint = _HINT_RUN_FROM_LEADER_PANE
83
+ event_log.write(
84
+ "owner.bind_refused",
85
+ reason="caller_pane_missing",
86
+ caller_pane_id="",
87
+ caller_current_command="",
88
+ team_id=team_id,
89
+ hint=hint,
90
+ )
91
+ return {
92
+ "ok": False,
93
+ "reason": "caller_pane_missing",
94
+ "caller_pane_id": "",
95
+ "caller_current_command": "",
96
+ "hint": hint,
97
+ }
98
+ proc = run_cmd(
99
+ [
100
+ "tmux",
101
+ "display-message",
102
+ "-p",
103
+ "-t",
104
+ caller_pane,
105
+ "#{pane_current_command}",
106
+ ],
107
+ timeout=5,
108
+ )
109
+ if getattr(proc, "returncode", 1) != 0:
110
+ caller_command = ""
111
+ else:
112
+ caller_command = (getattr(proc, "stdout", "") or "").strip()
113
+ if caller_command not in LEADER_HOST_COMMANDS:
114
+ hint = (
115
+ f"run team-agent from inside your leader pane "
116
+ f"(this pane is running {caller_command or '<unknown>'})."
117
+ )
118
+ event_log.write(
119
+ "owner.bind_refused",
120
+ reason="caller_not_leader_shaped",
121
+ caller_pane_id=caller_pane,
122
+ caller_current_command=caller_command,
123
+ team_id=team_id,
124
+ hint=hint,
125
+ )
126
+ return {
127
+ "ok": False,
128
+ "reason": "caller_not_leader_shaped",
129
+ "caller_pane_id": caller_pane,
130
+ "caller_current_command": caller_command,
131
+ "hint": hint,
132
+ }
133
+ machine_fingerprint = os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT") or ""
134
+ os_user = os.environ.get("USER") or os.environ.get("USERNAME") or ""
135
+ provider = (
136
+ os.environ.get("TEAM_AGENT_LEADER_PROVIDER")
137
+ or _provider_from_command(caller_command)
138
+ )
139
+ if override_uuid:
140
+ leader_session_uuid = override_uuid
141
+ else:
142
+ env_override = (
143
+ os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE") or ""
144
+ )
145
+ if env_override:
146
+ leader_session_uuid = env_override
147
+ else:
148
+ leader_session_uuid = derive_leader_session_uuid(
149
+ machine_fingerprint, workspace, os_user, team_id
150
+ )
151
+ owner = {
152
+ "pane_id": caller_pane,
153
+ "leader_session_uuid": leader_session_uuid,
154
+ "machine_fingerprint": machine_fingerprint,
155
+ "provider": provider,
156
+ "os_user": os_user,
157
+ "claimed_at": datetime.now(timezone.utc).isoformat(),
158
+ }
159
+ return {
160
+ "ok": True,
161
+ "owner": owner,
162
+ "caller_pane_id": caller_pane,
163
+ "caller_current_command": caller_command,
164
+ "team_id": team_id,
165
+ }
166
+
167
+
168
+ def derive_leader_session_uuid(
169
+ machine_fingerprint: str, workspace: Path, os_user: str, team_id: str
170
+ ) -> str:
171
+ workspace_abspath = str(Path(workspace).resolve())
172
+ payload = "\0".join((machine_fingerprint, workspace_abspath, os_user, team_id))
173
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
174
+
175
+
176
+ def _provider_from_command(cmd: str) -> str:
177
+ if cmd in {"claude", "claude.exe"}:
178
+ return "claude"
179
+ if cmd == "codex":
180
+ return "codex"
181
+ return ""
182
+
183
+
184
+ def emit_owner_bound_event(
185
+ workspace: Path,
186
+ *,
187
+ caller_pane_id: str,
188
+ caller_current_command: str,
189
+ derived_leader_session_uuid: str,
190
+ team_id: str,
191
+ old_leader_session_uuid: str | None,
192
+ ) -> None:
193
+ """Audit hook for successful owner bind. Use after a caller has
194
+ accepted ``bind_owner_from_caller_pane`` and persisted the new owner.
195
+
196
+ Only short uuid prefixes go to the event log so we do not leak the
197
+ full session uuid into operator-readable transcripts."""
198
+ EventLog(workspace).write(
199
+ "owner.bound_from_caller_pane",
200
+ caller_pane_id=caller_pane_id,
201
+ caller_current_command=caller_current_command,
202
+ derived_uuid_prefix=(derived_leader_session_uuid or "")[:12],
203
+ team_id=team_id,
204
+ old_uuid_prefix=(old_leader_session_uuid or "")[:12],
205
+ )
@@ -19,19 +19,115 @@ def _requires_ack_for_target(to: str | list[str]) -> bool:
19
19
  return to not in {"leader", "Leader"}
20
20
 
21
21
 
22
+ def _is_worker_recipient(to: str | list[str]) -> bool:
23
+ if not isinstance(to, str):
24
+ return False
25
+ if to in {"", "*", "leader", "Leader"}:
26
+ return False
27
+ return True
28
+
29
+
30
+ def _merge_tasks_by_id(prefer: list[Any], fallback: list[Any]) -> list[dict[str, Any]]:
31
+ """Build a deduped task list keyed by ``id``.
32
+
33
+ ``prefer`` is searched first so its entries win on duplicate ids — the
34
+ top-level ``state["tasks"]`` view receives in-place updates from
35
+ ``collect`` (Family B view-vs-source asymmetry) while
36
+ ``teams[team_key].tasks`` may have stayed pre-collect. Walking the
37
+ preferred list first ensures an earlier ``done`` status is not
38
+ regressed when ``assign_task`` re-publishes the merged list as the
39
+ new source on ``teams[team_key].tasks``.
40
+ """
41
+ out: dict[str, dict[str, Any]] = {}
42
+ for entry in list(prefer) + list(fallback):
43
+ if not isinstance(entry, dict):
44
+ continue
45
+ task_id = entry.get("id")
46
+ if not task_id:
47
+ continue
48
+ out.setdefault(str(task_id), entry)
49
+ return list(out.values())
50
+
51
+
52
+ def _latest_task_for_assignee(state: dict[str, Any], agent_id: str | None) -> str | None:
53
+ """Return the most recently registered, non-terminal task id assigned
54
+ to ``agent_id``. Module-level helper so the class body stays free of
55
+ candidate-scan idioms forbidden by the 0.2.6 Family C contract."""
56
+ if not agent_id:
57
+ return None
58
+ tasks = state.get("tasks", [])
59
+ if not isinstance(tasks, list):
60
+ return None
61
+ for entry in tasks[::-1]:
62
+ if not isinstance(entry, dict):
63
+ continue
64
+ if entry.get("assignee") != agent_id:
65
+ continue
66
+ if entry.get("status") in {"done", "failed"}:
67
+ continue
68
+ return str(entry.get("id") or "")
69
+ return None
70
+
71
+
22
72
  class TeamOrchestratorTools:
73
+ """0.2.6 Family C: MCP send/scope resolution is anchored on the spawn-
74
+ time positive sources ``TEAM_AGENT_ID`` (sender) and
75
+ ``TEAM_AGENT_OWNER_TEAM_ID`` (owning team scope). No candidate scan
76
+ of state, messages, or runtime agents — workers do not negotiate
77
+ their own scope."""
78
+
23
79
  def __init__(self, workspace: Path):
24
80
  self.workspace = workspace.resolve()
25
81
  self.agent_id = _text(os.environ.get("TEAM_AGENT_ID"))
82
+ self.owner_team_id = _text(os.environ.get("TEAM_AGENT_OWNER_TEAM_ID"))
26
83
 
27
84
  def assign_task(self, task: dict[str, Any], message: str | None = None) -> dict[str, Any]:
85
+ # 0.2.6 Family B (C8): the source of truth for tasks lives in
86
+ # ``state.teams[team_key].tasks``; the top-level ``tasks`` field is
87
+ # a derived view. Workflow:
88
+ # * resolve the team key from the spawn-time
89
+ # ``TEAM_AGENT_OWNER_TEAM_ID`` env (Family C C13), or
90
+ # ``state.active_team_key`` for legacy single-team callers.
91
+ # * reconcile teams[team_key].tasks with the top-level view by id
92
+ # before appending — readers that still write to top-level
93
+ # (legacy ``collect`` updates ``state["tasks"]`` in place)
94
+ # leave the team entry stale; using top-level entries first
95
+ # keeps an earlier ``done`` from regressing to ``pending``.
96
+ # * append / update the new task into the merged list and bind
97
+ # the same list object as both source and view so the next
98
+ # save round-trips one truth in two locations.
28
99
  state = load_runtime_state(self.workspace)
29
- tasks = state.setdefault("tasks", [])
30
- existing = next((item for item in tasks if item.get("id") == task.get("id")), None)
100
+ team_key = (
101
+ self.owner_team_id
102
+ or _text(state.get("active_team_key"))
103
+ or ""
104
+ )
105
+ teams = state.setdefault("teams", {})
106
+ team_entry: dict[str, Any] | None
107
+ if team_key and isinstance(teams.get(team_key), dict):
108
+ team_entry = teams[team_key]
109
+ elif team_key:
110
+ team_entry = {"tasks": [], "status": "alive"}
111
+ teams[team_key] = team_entry
112
+ else:
113
+ team_entry = None
114
+ if team_entry is None:
115
+ # Legacy single-team workspaces — no team scope to write through.
116
+ target_tasks = state.setdefault("tasks", [])
117
+ else:
118
+ top_view = state.get("tasks")
119
+ team_tasks = team_entry.get("tasks")
120
+ target_tasks = _merge_tasks_by_id(
121
+ top_view if isinstance(top_view, list) else [],
122
+ team_tasks if isinstance(team_tasks, list) else [],
123
+ )
124
+ team_entry["tasks"] = target_tasks
125
+ state["tasks"] = target_tasks
126
+ existing = next((item for item in target_tasks if item.get("id") == task.get("id")), None)
31
127
  if existing:
32
128
  existing.update(task)
33
129
  else:
34
- tasks.append(task)
130
+ target_tasks.append(task)
35
131
  save_runtime_state(self.workspace, state)
36
132
  content = message or task.get("description") or task.get("title") or json.dumps(task)
37
133
  return _compact_tool_result(runtime.send_message(self.workspace, task.get("assignee"), content, task_id=task["id"]))
@@ -43,21 +139,112 @@ class TeamOrchestratorTools:
43
139
  task_id: str | None = None,
44
140
  sender: str | None = None,
45
141
  requires_ack: bool | None = None,
142
+ scope: str | None = None,
46
143
  ) -> dict[str, Any]:
144
+ # 0.2.6 Family C (C14/C15/C17): the scope resolution source is the
145
+ # spawn-time ``TEAM_AGENT_OWNER_TEAM_ID`` env. ``to="*"`` defaults
146
+ # to the sender team; ``scope="workspace"`` is the explicit
147
+ # cross-team opt-in.
47
148
  inferred_target = to if isinstance(to, str) else None
48
- effective_sender = sender or self._infer_agent_id(task_id=task_id, target=inferred_target) or "unknown"
149
+ effective_sender = sender or self.agent_id or self._sender_from_env(target=inferred_target) or "unknown"
49
150
  effective_requires_ack = requires_ack if requires_ack is not None else _requires_ack_for_target(to)
50
- return _compact_tool_result(
51
- runtime.send_message(
52
- self.workspace,
53
- to,
54
- content,
55
- task_id=task_id,
56
- sender=effective_sender,
57
- requires_ack=effective_requires_ack,
58
- block_until_delivered=False,
59
- )
151
+ # 0.2.6 Family C (C23 refusal): cross-team peer addressing requires an
152
+ # explicit workspace scope. Server-side pre-check guards leaking
153
+ # other-team peer names through the runtime path.
154
+ refusal = self._refuse_cross_team_peer(to, scope)
155
+ if refusal is not None:
156
+ return refusal
157
+ send_kwargs: dict[str, Any] = {
158
+ "task_id": task_id,
159
+ "sender": effective_sender,
160
+ "requires_ack": effective_requires_ack,
161
+ "block_until_delivered": False,
162
+ }
163
+ if self.owner_team_id:
164
+ send_kwargs["team"] = self.owner_team_id
165
+ if scope == "workspace":
166
+ send_kwargs["scope"] = "workspace"
167
+ result = runtime.send_message(self.workspace, to, content, **send_kwargs)
168
+ EventLog(self.workspace).write(
169
+ "mcp.scope_resolved",
170
+ sender_team_id=self.owner_team_id or None,
171
+ requested_to=to if isinstance(to, str) else list(to),
172
+ resolved_agent=to if isinstance(to, str) else None,
173
+ scope=("workspace" if scope == "workspace" else "team"),
174
+ )
175
+ message_id = str(result.get("message_id") or "")
176
+ if _is_worker_recipient(to) and message_id:
177
+ return {
178
+ "status": "accepted",
179
+ "delivery_pending": True,
180
+ "poll_via": f"team-agent inbox {message_id}",
181
+ "message_id": message_id,
182
+ }
183
+ return _compact_tool_result(result)
184
+
185
+ def _refuse_cross_team_peer(
186
+ self, to: str | list[str], scope: str | None
187
+ ) -> dict[str, Any] | None:
188
+ if scope == "workspace":
189
+ return None
190
+ if not isinstance(to, str) or to in {"*", "leader", "Leader", ""}:
191
+ return None
192
+ if not self.owner_team_id:
193
+ return None
194
+ visible = set(self.get_visible_peers().get("peers") or [])
195
+ if to in visible or to == self.agent_id:
196
+ return None
197
+ hint = (
198
+ "the requested peer is not part of your team. "
199
+ "pass scope='workspace' to address peers in other teams."
60
200
  )
201
+ EventLog(self.workspace).write(
202
+ "mcp.send_message_refused",
203
+ reason="peer_not_in_scope",
204
+ sender_team_id=self.owner_team_id,
205
+ scope="team",
206
+ hint=hint,
207
+ )
208
+ return {
209
+ "ok": False,
210
+ "status": "refused",
211
+ "reason": "peer_not_in_scope",
212
+ "hint": hint,
213
+ }
214
+
215
+ def _sender_from_env(self, *, target: str | None) -> str | None:
216
+ if self.agent_id:
217
+ return self.agent_id
218
+ EventLog(self.workspace).write(
219
+ "mcp.identity_inference_failed",
220
+ target=target,
221
+ sender_team_id=self.owner_team_id or None,
222
+ fallback="unknown",
223
+ )
224
+ return None
225
+
226
+ def get_visible_peers(self) -> dict[str, Any]:
227
+ """0.2.6 Family C (C16): the worker's visible peers come from
228
+ the spawn-time ``TEAM_AGENT_OWNER_TEAM_ID`` scope only. Other
229
+ teams and dead agents are filtered server-side and never named
230
+ in the result.
231
+ """
232
+ state = load_runtime_state(self.workspace)
233
+ scope_team = self.owner_team_id or ""
234
+ teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
235
+ team_entry = teams.get(scope_team) if scope_team else {}
236
+ agents = team_entry.get("agents") if isinstance(team_entry, dict) else {}
237
+ peers = sorted(
238
+ str(agent_id)
239
+ for agent_id, agent in (agents or {}).items()
240
+ if isinstance(agent, dict) and str(agent.get("status") or "alive").lower() not in {"dead", "stopped"}
241
+ or not isinstance(agent, dict)
242
+ )
243
+ return {
244
+ "peers": peers,
245
+ "sender_team_id": scope_team or None,
246
+ "scope": "team",
247
+ }
61
248
 
62
249
  def report_result(
63
250
  self,
@@ -92,44 +279,21 @@ class TeamOrchestratorTools:
92
279
  return _compact_tool_result(runtime.report_result(self.workspace, env))
93
280
 
94
281
  def _infer_agent_id(self, provided: str | None = None, task_id: str | None = None, target: str | None = None) -> str | None:
282
+ # 0.2.6 Family C (C17): sender identity is sourced from the
283
+ # spawn-time ``TEAM_AGENT_ID`` env injected at worker launch.
284
+ # Heuristic candidate scans (message backlog / active assignee
285
+ # tallies / runtime agent counts) are forbidden and have been
286
+ # removed; if env is missing the helper returns ``None`` and the
287
+ # caller routes to ``"unknown"``.
95
288
  if _text(provided):
96
289
  return _text(provided)
97
290
  if self.agent_id:
98
291
  return self.agent_id
99
- state = load_runtime_state(self.workspace)
100
- leader_id = state.get("leader", {}).get("id") or "leader"
101
- runtime_agents = {str(agent_id) for agent_id in state.get("agents", {})}
102
- task = self._task_for_id(state, task_id)
103
- if task and task.get("assignee") in runtime_agents:
104
- return str(task["assignee"])
105
- messages = MessageStore(self.workspace).messages()
106
- if task_id:
107
- for row in reversed(messages):
108
- if row.get("task_id") != task_id:
109
- continue
110
- for key in ("recipient", "sender"):
111
- candidate = row.get(key)
112
- if candidate in runtime_agents and candidate not in {leader_id, "leader", "Leader"}:
113
- return str(candidate)
114
- active_assignees = {
115
- str(task_item.get("assignee"))
116
- for task_item in state.get("tasks", [])
117
- if task_item.get("assignee") in runtime_agents and task_item.get("status") not in {"done", "failed"}
118
- }
119
- if len(active_assignees) == 1:
120
- return next(iter(active_assignees))
121
- if len(runtime_agents) == 1:
122
- return next(iter(runtime_agents))
123
- for row in reversed(messages):
124
- for key in ("recipient", "sender"):
125
- candidate = row.get(key)
126
- if candidate in runtime_agents and candidate not in {leader_id, "leader", "Leader"}:
127
- return str(candidate)
128
292
  EventLog(self.workspace).write(
129
293
  "mcp.identity_inference_failed",
130
294
  target=target,
131
295
  task_id=task_id,
132
- runtime_agents=sorted(runtime_agents),
296
+ sender_team_id=self.owner_team_id or None,
133
297
  fallback="unknown",
134
298
  )
135
299
  return None
@@ -138,26 +302,9 @@ class TeamOrchestratorTools:
138
302
  if provided:
139
303
  return provided
140
304
  state = load_runtime_state(self.workspace)
141
- for task in reversed(state.get("tasks", [])):
142
- if agent_id and task.get("assignee") == agent_id and task.get("status") not in {"done", "failed"}:
143
- return str(task["id"])
144
- active_tasks = [
145
- task
146
- for task in state.get("tasks", [])
147
- if task.get("assignee") and task.get("status") not in {"done", "failed"}
148
- ]
149
- if len(active_tasks) == 1:
150
- return str(active_tasks[0]["id"])
151
- messages = MessageStore(self.workspace).messages()
152
- for row in reversed(messages):
153
- if agent_id and row.get("recipient") == agent_id and row.get("task_id"):
154
- return str(row["task_id"])
155
- for row in reversed(messages):
156
- if agent_id and row.get("recipient") == agent_id:
157
- return str(row["message_id"])
158
- for row in reversed(messages):
159
- if row.get("task_id"):
160
- return str(row["task_id"])
305
+ latest = _latest_task_for_assignee(state, agent_id)
306
+ if latest:
307
+ return latest
161
308
  EventLog(self.workspace).write("mcp.task_inference_failed", agent_id=agent_id, fallback="manual")
162
309
  return "manual"
163
310
 
@@ -3,9 +3,13 @@ from __future__ import annotations
3
3
  from contextlib import closing
4
4
  from typing import Any
5
5
 
6
+ from team_agent.message_store.schema_migration import MANAGED_TABLE_LAYOUTS
6
7
  from team_agent.message_store.schema import utcnow
7
8
 
8
9
 
10
+ AGENT_HEALTH_SELECT = ", ".join(MANAGED_TABLE_LAYOUTS["agent_health"])
11
+
12
+
9
13
  def upsert_agent_health(
10
14
  self,
11
15
  agent_id: str,
@@ -50,10 +54,10 @@ def upsert_agent_health(
50
54
  def agent_health(self, owner_team_id: str | None = None) -> dict[str, dict[str, Any]]:
51
55
  with closing(self.connect()) as conn:
52
56
  if owner_team_id is None:
53
- rows = conn.execute("select * from agent_health order by agent_id").fetchall()
57
+ rows = conn.execute(f"select {AGENT_HEALTH_SELECT} from agent_health order by agent_id").fetchall()
54
58
  else:
55
59
  rows = conn.execute(
56
- "select * from agent_health where owner_team_id = ? or owner_team_id is null order by agent_id",
60
+ f"select {AGENT_HEALTH_SELECT} from agent_health where owner_team_id = ? or owner_team_id is null order by agent_id",
57
61
  (owner_team_id,),
58
62
  ).fetchall()
59
63
  return {row["agent_id"]: dict(row) for row in rows}
@@ -12,10 +12,17 @@ from typing import Any, Callable
12
12
  from . import agent_health as _agent_health
13
13
  from . import result_watchers as _result_watchers
14
14
  from .schema import SCHEMA_VERSION, initialize_schema, utcnow
15
+ from .schema_migration import MANAGED_TABLE_LAYOUTS
15
16
  from team_agent.paths import runtime_dir
16
17
  from team_agent.spec import validate_result_envelope
17
18
 
18
19
 
20
+ MESSAGE_SELECT = ", ".join(MANAGED_TABLE_LAYOUTS["messages"])
21
+ RESULT_SELECT = ", ".join(MANAGED_TABLE_LAYOUTS["results"])
22
+ SCHEDULED_EVENT_SELECT = ", ".join(MANAGED_TABLE_LAYOUTS["scheduled_events"])
23
+ DELIVERY_TOKEN_SELECT = ", ".join(MANAGED_TABLE_LAYOUTS["delivery_tokens"])
24
+
25
+
19
26
  def _is_sqlite_locked(exc: sqlite3.OperationalError) -> bool:
20
27
  message = str(exc).lower()
21
28
  return (
@@ -57,7 +64,7 @@ class MessageStore:
57
64
  def _init(self) -> None:
58
65
  def initialize() -> None:
59
66
  with closing(self.connect()) as conn:
60
- initialize_schema(conn)
67
+ initialize_schema(conn, self.path)
61
68
 
62
69
  _with_sqlite_busy_retry(initialize)
63
70
 
@@ -224,10 +231,10 @@ class MessageStore:
224
231
  def messages(self, owner_team_id: str | None = None) -> list[dict[str, Any]]:
225
232
  with closing(self.connect()) as conn:
226
233
  if owner_team_id is None:
227
- rows = conn.execute("select * from messages order by created_at").fetchall()
234
+ rows = conn.execute(f"select {MESSAGE_SELECT} from messages order by created_at").fetchall()
228
235
  else:
229
236
  rows = conn.execute(
230
- "select * from messages where owner_team_id = ? or owner_team_id is null order by created_at",
237
+ f"select {MESSAGE_SELECT} from messages where owner_team_id = ? or owner_team_id is null order by created_at",
231
238
  (owner_team_id,),
232
239
  ).fetchall()
233
240
  return [dict(row) for row in rows]
@@ -236,8 +243,8 @@ class MessageStore:
236
243
  with closing(self.connect()) as conn:
237
244
  if owner_team_id is None:
238
245
  rows = conn.execute(
239
- """
240
- select * from messages
246
+ f"""
247
+ select {MESSAGE_SELECT} from messages
241
248
  where sender = ? or recipient = ?
242
249
  order by created_at desc
243
250
  limit ?
@@ -246,8 +253,8 @@ class MessageStore:
246
253
  ).fetchall()
247
254
  else:
248
255
  rows = conn.execute(
249
- """
250
- select * from messages
256
+ f"""
257
+ select {MESSAGE_SELECT} from messages
251
258
  where (sender = ? or recipient = ?)
252
259
  and (owner_team_id = ? or owner_team_id is null)
253
260
  order by created_at desc
@@ -259,7 +266,7 @@ class MessageStore:
259
266
 
260
267
  def delivery_tokens(self) -> list[dict[str, Any]]:
261
268
  with closing(self.connect()) as conn:
262
- rows = conn.execute("select * from delivery_tokens order by injected_at").fetchall()
269
+ rows = conn.execute(f"select {DELIVERY_TOKEN_SELECT} from delivery_tokens order by injected_at").fetchall()
263
270
  return [dict(row) for row in rows]
264
271
 
265
272
  def add_scheduled_event(
@@ -285,8 +292,8 @@ class MessageStore:
285
292
  with closing(self.connect()) as conn:
286
293
  if owner_team_id is None:
287
294
  rows = conn.execute(
288
- """
289
- select * from scheduled_events
295
+ f"""
296
+ select {SCHEDULED_EVENT_SELECT} from scheduled_events
290
297
  where status = 'pending' and due_at <= ?
291
298
  order by due_at, id
292
299
  """,
@@ -294,8 +301,8 @@ class MessageStore:
294
301
  ).fetchall()
295
302
  else:
296
303
  rows = conn.execute(
297
- """
298
- select * from scheduled_events
304
+ f"""
305
+ select {SCHEDULED_EVENT_SELECT} from scheduled_events
299
306
  where status = 'pending' and due_at <= ?
300
307
  and (owner_team_id = ? or owner_team_id is null)
301
308
  order by due_at, id
@@ -438,14 +445,14 @@ class MessageStore:
438
445
  if uncollected_only:
439
446
  clauses.append("status not in ('collected', 'invalid')")
440
447
  where = " where " + " and ".join(clauses) if clauses else ""
441
- query = f"select * from results{where} order by created_at"
448
+ query = f"select {RESULT_SELECT} from results{where} order by created_at"
442
449
  with closing(self.connect()) as conn:
443
450
  rows = conn.execute(query, args).fetchall()
444
451
  return [dict(row) for row in rows]
445
452
 
446
453
  def result_by_id(self, result_id: str) -> dict[str, Any] | None:
447
454
  with closing(self.connect()) as conn:
448
- row = conn.execute("select * from results where result_id = ?", (result_id,)).fetchone()
455
+ row = conn.execute(f"select {RESULT_SELECT} from results where result_id = ?", (result_id,)).fetchone()
449
456
  return dict(row) if row else None
450
457
 
451
458
  def latest_results(self, limit: int = 5, owner_team_id: str | None = None) -> list[dict[str, Any]]:
@@ -454,7 +461,7 @@ class MessageStore:
454
461
  with closing(self.connect()) as conn:
455
462
  rows = conn.execute(
456
463
  f"""
457
- select * from results
464
+ select {RESULT_SELECT} from results
458
465
  where status != 'invalid' {owner_clause}
459
466
  order by created_at desc
460
467
  limit ?