@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.
@@ -15,6 +15,11 @@ import sqlite3
15
15
  import time
16
16
  from typing import Any
17
17
 
18
+ from team_agent.message_store.schema_migration import MANAGED_TABLE_LAYOUTS
19
+
20
+
21
+ LEADER_NOTIFICATION_SELECT = ", ".join(MANAGED_TABLE_LAYOUTS["leader_notification_log"])
22
+
18
23
 
19
24
  def _sqlite_locked(exc: sqlite3.OperationalError) -> bool:
20
25
  message = str(exc).lower()
@@ -79,13 +84,12 @@ def claim_leader_notification_delivery(
79
84
  # Should not happen (INSERT OR IGNORE returned 0 → row must exist), but be defensive.
80
85
  return {"status": "claimed_by_you", "notified_message_id": proposed_message_id,
81
86
  "notified_at": now, "envelope_content_hash": envelope_hash}
82
- prev_message_id, prev_ts, prev_hash, prev_pane = row[0], row[1], row[2], row[3]
83
87
  return {
84
88
  "status": "already_notified_by",
85
- "notified_message_id": prev_message_id,
86
- "notified_at": prev_ts,
87
- "envelope_content_hash": prev_hash,
88
- "leader_pane_id_at_notify": prev_pane,
89
+ "notified_message_id": row["notified_message_id"],
90
+ "notified_at": row["notified_at"],
91
+ "envelope_content_hash": row["envelope_content_hash"],
92
+ "leader_pane_id_at_notify": row["leader_pane_id_at_notify"],
89
93
  }
90
94
 
91
95
 
@@ -109,11 +113,11 @@ def peek_leader_notification(
109
113
  if row is None:
110
114
  return None
111
115
  return {
112
- "notified_message_id": row[0],
113
- "notified_at": row[1],
114
- "envelope_content_hash": row[2],
115
- "leader_pane_id_at_notify": row[3],
116
- "owner_team_id": row[4],
116
+ "notified_message_id": row["notified_message_id"],
117
+ "notified_at": row["notified_at"],
118
+ "envelope_content_hash": row["envelope_content_hash"],
119
+ "leader_pane_id_at_notify": row["leader_pane_id_at_notify"],
120
+ "owner_team_id": row["owner_team_id"],
117
121
  }
118
122
 
119
123
 
@@ -134,11 +138,11 @@ def leader_notification_log_rows(store: Any, *, owner_team_id: str | None = None
134
138
  with closing(store.connect()) as conn:
135
139
  if owner_team_id is None:
136
140
  rows = conn.execute(
137
- "select * from leader_notification_log order by notified_at"
141
+ f"select {LEADER_NOTIFICATION_SELECT} from leader_notification_log order by notified_at"
138
142
  ).fetchall()
139
143
  else:
140
144
  rows = conn.execute(
141
- "select * from leader_notification_log where owner_team_id = ? "
145
+ f"select {LEADER_NOTIFICATION_SELECT} from leader_notification_log where owner_team_id = ? "
142
146
  "or owner_team_id is null order by notified_at",
143
147
  (owner_team_id,),
144
148
  ).fetchall()
@@ -4,9 +4,13 @@ import uuid
4
4
  from contextlib import closing
5
5
  from typing import Any
6
6
 
7
+ from team_agent.message_store.schema_migration import MANAGED_TABLE_LAYOUTS
7
8
  from team_agent.message_store.schema import utcnow
8
9
 
9
10
 
11
+ RESULT_WATCHER_SELECT = ", ".join(MANAGED_TABLE_LAYOUTS["result_watchers"])
12
+
13
+
10
14
  def create_result_watcher(
11
15
  self,
12
16
  task_id: str | None,
@@ -32,11 +36,13 @@ def create_result_watcher(
32
36
  def pending_result_watchers(self, owner_team_id: str | None = None) -> list[dict[str, Any]]:
33
37
  with closing(self.connect()) as conn:
34
38
  if owner_team_id is None:
35
- rows = conn.execute("select * from result_watchers where status = 'pending' order by created_at").fetchall()
39
+ rows = conn.execute(
40
+ f"select {RESULT_WATCHER_SELECT} from result_watchers where status = 'pending' order by created_at"
41
+ ).fetchall()
36
42
  else:
37
43
  rows = conn.execute(
38
- """
39
- select * from result_watchers
44
+ f"""
45
+ select {RESULT_WATCHER_SELECT} from result_watchers
40
46
  where status = 'pending' and (owner_team_id = ? or owner_team_id is null)
41
47
  order by created_at
42
48
  """,
@@ -47,17 +53,17 @@ def pending_result_watchers(self, owner_team_id: str | None = None) -> list[dict
47
53
  def retryable_result_watchers(self) -> list[dict[str, Any]]:
48
54
  with closing(self.connect()) as conn:
49
55
  rows = conn.execute(
50
- "select * from result_watchers where status in ('pending', 'notify_failed') order by created_at"
56
+ f"select {RESULT_WATCHER_SELECT} from result_watchers where status in ('pending', 'notify_failed') order by created_at"
51
57
  ).fetchall()
52
58
  return [dict(row) for row in rows]
53
59
 
54
60
  def result_watchers(self, owner_team_id: str | None = None) -> list[dict[str, Any]]:
55
61
  with closing(self.connect()) as conn:
56
62
  if owner_team_id is None:
57
- rows = conn.execute("select * from result_watchers order by created_at").fetchall()
63
+ rows = conn.execute(f"select {RESULT_WATCHER_SELECT} from result_watchers order by created_at").fetchall()
58
64
  else:
59
65
  rows = conn.execute(
60
- "select * from result_watchers where owner_team_id = ? or owner_team_id is null order by created_at",
66
+ f"select {RESULT_WATCHER_SELECT} from result_watchers where owner_team_id = ? or owner_team_id is null order by created_at",
61
67
  (owner_team_id,),
62
68
  ).fetchall()
63
69
  return [dict(row) for row in rows]
@@ -92,7 +98,7 @@ def requeue_delivery_exhausted_watchers(self) -> list[str]:
92
98
  rows = conn.execute(
93
99
  "select watcher_id from result_watchers where status = 'delivery_exhausted'"
94
100
  ).fetchall()
95
- watcher_ids = [row[0] for row in rows]
101
+ watcher_ids = [row["watcher_id"] for row in rows]
96
102
  if watcher_ids:
97
103
  # Phase D hotfix-3 (78055bc) cleared notified_message_id here; Gap 32 dedupe
98
104
  # reverses that — preserve notified_message_id so the retry path can re-confirm
@@ -153,9 +159,9 @@ def _claim_leader_notification_disabled_impl( # legacy reference for archaeolog
153
159
  "order by coalesce(completed_at, created_at) limit 1",
154
160
  (result_id, owner_team_id),
155
161
  ).fetchone()
156
- if sibling and sibling[0]:
162
+ if sibling and sibling["notified_message_id"]:
157
163
  conn.execute("COMMIT")
158
- return {"status": "already_notified_by", "canonical_message_id": sibling[0]}
164
+ return {"status": "already_notified_by", "canonical_message_id": sibling["notified_message_id"]}
159
165
  cur = conn.execute(
160
166
  "update result_watchers "
161
167
  "set notified_message_id = ?, result_id = coalesce(result_id, ?) "
@@ -172,7 +178,7 @@ def _claim_leader_notification_disabled_impl( # legacy reference for archaeolog
172
178
  conn.execute("COMMIT")
173
179
  return {
174
180
  "status": "already_notified_by",
175
- "canonical_message_id": (row[0] if row else None) or None,
181
+ "canonical_message_id": (row["notified_message_id"] if row else None) or None,
176
182
  }
177
183
  except Exception:
178
184
  try:
@@ -242,4 +248,4 @@ def leader_notified_message_id_for_result(
242
248
  "order by coalesce(completed_at, created_at) limit 1",
243
249
  (result_id, owner_team_id),
244
250
  ).fetchone()
245
- return row[0] if row else None
251
+ return row["notified_message_id"] if row else None
@@ -2,6 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  import sqlite3
4
4
  from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from team_agent.message_store import schema_migration as _schema_migration
8
+ from team_agent.message_store.schema_migration import ensure_schema_indexes, ensure_table_layout, table_layout
5
9
 
6
10
 
7
11
  MESSAGE_COLUMNS = {
@@ -68,16 +72,26 @@ RESULT_WATCHER_COLUMNS = {
68
72
  "notified_message_id",
69
73
  "error",
70
74
  }
75
+ LEADER_NOTIFICATION_LOG_COLUMNS = {
76
+ "result_id",
77
+ "leader_session_uuid",
78
+ "notified_message_id",
79
+ "notified_at",
80
+ "leader_pane_id_at_notify",
81
+ "envelope_content_hash",
82
+ "owner_team_id",
83
+ }
71
84
 
72
85
 
73
86
  def utcnow() -> str:
74
87
  return datetime.now(timezone.utc).isoformat()
75
88
 
76
89
  SCHEMA_VERSION = 3
90
+ SCHEMA_MIGRATIONS = _schema_migration.SCHEMA_MIGRATIONS
77
91
 
78
92
 
79
93
  def _table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
80
- return {row[1] for row in conn.execute(f"pragma table_info({table})").fetchall()}
94
+ return set(table_layout(conn, table))
81
95
 
82
96
 
83
97
  def _ensure_table_columns(
@@ -97,7 +111,9 @@ def _ensure_table_columns(
97
111
  conn.execute(migrations[name])
98
112
 
99
113
 
100
- def initialize_schema(conn: sqlite3.Connection) -> None:
114
+ def initialize_schema(conn: sqlite3.Connection, db_path: Path | None = None) -> None:
115
+ _schema_migration.SCHEMA_MIGRATIONS = SCHEMA_MIGRATIONS
116
+ ensure_table_layout(conn, schema_version=SCHEMA_VERSION, db_path=db_path)
101
117
  with conn:
102
118
  conn.execute(
103
119
  """
@@ -256,14 +272,8 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
256
272
  )
257
273
  """
258
274
  )
259
- conn.execute(
260
- "create index if not exists idx_leader_notification_log_uuid "
261
- "on leader_notification_log(leader_session_uuid, notified_at)"
262
- )
263
- conn.execute("create index if not exists idx_messages_owner_team_id on messages(owner_team_id)")
264
- conn.execute("create index if not exists idx_scheduled_events_owner_team_id on scheduled_events(owner_team_id)")
265
- conn.execute("create index if not exists idx_agent_health_owner_team_id on agent_health(owner_team_id)")
266
- conn.execute("create index if not exists idx_result_watchers_owner_team_id on result_watchers(owner_team_id)")
275
+ _ensure_table_columns(conn, "leader_notification_log", LEADER_NOTIFICATION_LOG_COLUMNS)
276
+ ensure_schema_indexes(conn)
267
277
  conn.execute(f"pragma user_version = {SCHEMA_VERSION}")
268
278
 
269
279
 
@@ -0,0 +1,446 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import sqlite3
5
+ import os
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+
11
+ MANAGED_TABLE_LAYOUTS: dict[str, tuple[str, ...]] = {
12
+ "messages": (
13
+ "message_id", "owner_team_id", "task_id", "sender", "recipient", "reply_to",
14
+ "requires_ack", "status", "content", "artifact_refs", "created_at", "updated_at",
15
+ "delivered_at", "acknowledged_at", "error", "delivery_attempts",
16
+ ),
17
+ "results": ("result_id", "owner_team_id", "task_id", "agent_id", "envelope", "status", "created_at"),
18
+ "scheduled_events": (
19
+ "id", "owner_team_id", "due_at", "target", "kind", "payload_json", "status",
20
+ "created_at", "fired_at", "result_json",
21
+ ),
22
+ "delivery_tokens": (
23
+ "message_id", "unique_token", "injected_at", "visible_at", "consumed_at",
24
+ "failed_at", "failure_reason",
25
+ ),
26
+ "agent_health": (
27
+ "owner_team_id", "agent_id", "status", "last_output_at", "context_usage_pct",
28
+ "current_task_id", "updated_at",
29
+ ),
30
+ "peer_allowlist": ("a", "b", "created_at"),
31
+ "result_watchers": (
32
+ "watcher_id", "owner_team_id", "task_id", "agent_id", "message_id", "leader_id",
33
+ "status", "created_at", "completed_at", "result_id", "notified_message_id", "error",
34
+ ),
35
+ "leader_notification_log": (
36
+ "result_id", "leader_session_uuid", "notified_message_id", "notified_at",
37
+ "leader_pane_id_at_notify", "envelope_content_hash", "owner_team_id",
38
+ ),
39
+ }
40
+
41
+
42
+ CREATE_TABLE_SQL: dict[str, str] = {
43
+ "messages": """
44
+ create table if not exists {table} (
45
+ message_id text primary key,
46
+ owner_team_id text,
47
+ task_id text,
48
+ sender text,
49
+ recipient text,
50
+ reply_to text,
51
+ requires_ack integer,
52
+ status text,
53
+ content text,
54
+ artifact_refs text,
55
+ created_at text,
56
+ updated_at text,
57
+ delivered_at text,
58
+ acknowledged_at text,
59
+ error text,
60
+ delivery_attempts integer not null default 0
61
+ )
62
+ """,
63
+ "results": """
64
+ create table if not exists {table} (
65
+ result_id text primary key,
66
+ owner_team_id text,
67
+ task_id text not null,
68
+ agent_id text not null,
69
+ envelope text not null,
70
+ status text not null,
71
+ created_at text not null
72
+ )
73
+ """,
74
+ "scheduled_events": """
75
+ create table if not exists {table} (
76
+ id integer primary key,
77
+ owner_team_id text,
78
+ due_at text not null,
79
+ target text not null,
80
+ kind text not null,
81
+ payload_json text not null,
82
+ status text not null,
83
+ created_at text not null,
84
+ fired_at text,
85
+ result_json text
86
+ )
87
+ """,
88
+ "delivery_tokens": """
89
+ create table if not exists {table} (
90
+ message_id text primary key,
91
+ unique_token text not null,
92
+ injected_at text not null,
93
+ visible_at text,
94
+ consumed_at text,
95
+ failed_at text,
96
+ failure_reason text
97
+ )
98
+ """,
99
+ "agent_health": """
100
+ create table if not exists {table} (
101
+ owner_team_id text,
102
+ agent_id text not null,
103
+ status text not null,
104
+ last_output_at text,
105
+ context_usage_pct integer,
106
+ current_task_id text,
107
+ updated_at text not null,
108
+ unique(owner_team_id, agent_id)
109
+ )
110
+ """,
111
+ "peer_allowlist": """
112
+ create table if not exists {table} (
113
+ a text not null,
114
+ b text not null,
115
+ created_at text not null,
116
+ primary key (a, b)
117
+ )
118
+ """,
119
+ "result_watchers": """
120
+ create table if not exists {table} (
121
+ watcher_id text primary key,
122
+ owner_team_id text,
123
+ task_id text,
124
+ agent_id text,
125
+ message_id text,
126
+ leader_id text not null,
127
+ status text not null,
128
+ created_at text not null,
129
+ completed_at text,
130
+ result_id text,
131
+ notified_message_id text,
132
+ error text
133
+ )
134
+ """,
135
+ "leader_notification_log": """
136
+ create table if not exists {table} (
137
+ result_id text not null,
138
+ leader_session_uuid text not null,
139
+ notified_message_id text not null,
140
+ notified_at text not null,
141
+ leader_pane_id_at_notify text,
142
+ envelope_content_hash text,
143
+ owner_team_id text,
144
+ primary key (result_id, leader_session_uuid)
145
+ )
146
+ """,
147
+ }
148
+
149
+
150
+ INDEX_SQL: tuple[str, ...] = (
151
+ "create index if not exists idx_leader_notification_log_uuid on leader_notification_log(leader_session_uuid, notified_at)",
152
+ "create index if not exists idx_messages_owner_team_id on messages(owner_team_id)",
153
+ "create index if not exists idx_scheduled_events_owner_team_id on scheduled_events(owner_team_id)",
154
+ "create index if not exists idx_agent_health_owner_team_id on agent_health(owner_team_id)",
155
+ "create index if not exists idx_result_watchers_owner_team_id on result_watchers(owner_team_id)",
156
+ )
157
+
158
+
159
+ SCHEMA_MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = {
160
+ 1: lambda _conn: None,
161
+ 2: lambda _conn: None,
162
+ 3: lambda _conn: None,
163
+ }
164
+
165
+
166
+ def table_layout(conn: sqlite3.Connection, table: str) -> tuple[str, ...]:
167
+ return tuple(str(row["name"]) for row in conn.execute(f"pragma table_info({table})").fetchall())
168
+
169
+
170
+ def ensure_schema_indexes(conn: sqlite3.Connection) -> None:
171
+ for statement in INDEX_SQL:
172
+ conn.execute(statement)
173
+
174
+
175
+ def schema_diagnosis(workspace: Path, *, schema_version: int) -> dict[str, Any]:
176
+ db_path = workspace / ".team" / "runtime" / "team.db"
177
+ backup_path = _backup_path(db_path, _read_user_version(db_path))
178
+ if not db_path.exists():
179
+ return {
180
+ "ok": True,
181
+ "status": "missing",
182
+ "db_path": str(db_path),
183
+ "schema_version": schema_version,
184
+ "user_version": 0,
185
+ "layout_diffs": {},
186
+ "recommended_action": "No team.db exists yet; initialize_schema will create it on first use.",
187
+ "would_backup_path": str(backup_path),
188
+ }
189
+ conn = sqlite3.connect(db_path)
190
+ conn.row_factory = sqlite3.Row
191
+ try:
192
+ user_version = _pragma_user_version(conn)
193
+ diffs = _layout_diffs(conn)
194
+ finally:
195
+ conn.close()
196
+ return {
197
+ "ok": not diffs and user_version == schema_version,
198
+ "status": "ok" if not diffs and user_version == schema_version else "schema_repair_available",
199
+ "db_path": str(db_path),
200
+ "schema_version": schema_version,
201
+ "user_version": user_version,
202
+ "layout_diffs": diffs,
203
+ "recommended_action": "run team-agent doctor --fix-schema --json" if diffs else "none",
204
+ "would_backup_path": str(backup_path),
205
+ }
206
+
207
+
208
+ def ensure_table_layout(
209
+ conn: sqlite3.Connection,
210
+ *,
211
+ schema_version: int,
212
+ db_path: Path | None = None,
213
+ ) -> list[dict[str, Any]]:
214
+ # 0.2.6 CI hotfix #3: hold the SQLite RESERVED lock across the diff
215
+ # read AND the rebuild writes. Earlier code only acquired BEGIN
216
+ # IMMEDIATE inside ``_rebuild_tables`` — so a concurrent writer
217
+ # could squeeze a row in between ``_layout_diffs`` and the rebuild,
218
+ # and the post-rebuild row-count drift check fired with
219
+ # before != after even though no schema layout was clobbered.
220
+ # Wrapping the whole sequence makes the drift check an invariant
221
+ # (no writer can interleave once we hold the lock).
222
+ conn.row_factory = sqlite3.Row
223
+ _run_version_migrations(conn, schema_version)
224
+ started_tx = False
225
+ if not conn.in_transaction:
226
+ conn.execute("BEGIN IMMEDIATE")
227
+ started_tx = True
228
+ try:
229
+ diffs = _layout_diffs(conn)
230
+ if not diffs:
231
+ if started_tx:
232
+ conn.execute("COMMIT")
233
+ started_tx = False
234
+ return []
235
+ db_path = db_path or _db_path_from_conn(conn)
236
+ if db_path is None:
237
+ raise RuntimeError("cannot rebuild team.db layout without a database path")
238
+ backup_path = _backup_path(db_path, _pragma_user_version(conn))
239
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
240
+ shutil.copy2(db_path, backup_path)
241
+ events = _rebuild_tables(conn, diffs, backup_path)
242
+ if started_tx:
243
+ conn.execute("COMMIT")
244
+ started_tx = False
245
+ except Exception:
246
+ if started_tx and conn.in_transaction:
247
+ try:
248
+ conn.execute("ROLLBACK")
249
+ except sqlite3.Error:
250
+ pass
251
+ raise
252
+ _emit_rebuild_events(db_path, events)
253
+ return events
254
+
255
+
256
+ def fix_schema_layout(workspace: Path, *, schema_version: int) -> dict[str, Any]:
257
+ db_path = workspace / ".team" / "runtime" / "team.db"
258
+ if not db_path.exists():
259
+ return schema_diagnosis(workspace, schema_version=schema_version)
260
+ lock_check = _db_lock_status(db_path)
261
+ if lock_check is not None:
262
+ from team_agent.events import EventLog
263
+ EventLog(workspace).write("schema.layout_rebuild_blocked", reason=lock_check, db_path=str(db_path))
264
+ return {"ok": False, "status": "blocked", "reason": lock_check, "event": "schema.layout_rebuild_blocked", "db_path": str(db_path)}
265
+ conn = sqlite3.connect(db_path, timeout=30.0, isolation_level=None)
266
+ conn.row_factory = sqlite3.Row
267
+ conn.execute("PRAGMA busy_timeout=30000")
268
+ try:
269
+ events = ensure_table_layout(conn, schema_version=schema_version, db_path=db_path)
270
+ conn.execute(f"pragma user_version = {schema_version}")
271
+ finally:
272
+ conn.close()
273
+ diagnosis = schema_diagnosis(workspace, schema_version=schema_version)
274
+ diagnosis.update({"fixed": True, "rebuilds": events})
275
+ return diagnosis
276
+
277
+
278
+ def _run_version_migrations(conn: sqlite3.Connection, schema_version: int) -> None:
279
+ current = _pragma_user_version(conn)
280
+ for version in range(current, schema_version + 1):
281
+ SCHEMA_MIGRATIONS.get(version, lambda _conn: None)(conn)
282
+
283
+
284
+ def _layout_diffs(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
285
+ diffs: dict[str, dict[str, Any]] = {}
286
+ if not any(_table_exists(conn, table) for table in MANAGED_TABLE_LAYOUTS):
287
+ return diffs
288
+ for table, expected in MANAGED_TABLE_LAYOUTS.items():
289
+ if not _table_exists(conn, table):
290
+ diffs[table] = {
291
+ "expected": list(expected),
292
+ "actual": [],
293
+ "missing": True,
294
+ }
295
+ continue
296
+ actual = table_layout(conn, table)
297
+ if actual != expected:
298
+ diffs[table] = {
299
+ "expected": list(expected),
300
+ "actual": list(actual),
301
+ }
302
+ return diffs
303
+
304
+
305
+ def _rebuild_tables(
306
+ conn: sqlite3.Connection,
307
+ diffs: dict[str, dict[str, Any]],
308
+ backup_path: Path,
309
+ ) -> list[dict[str, Any]]:
310
+ # Caller (``ensure_table_layout``) now holds the BEGIN IMMEDIATE for
311
+ # the full diff+rebuild window, so the drift check between ``before``
312
+ # and ``after`` is an invariant rather than a concurrency race. The
313
+ # inner BEGIN/COMMIT/ROLLBACK was removed alongside that move (a
314
+ # nested BEGIN raises in SQLite and would mask the outer lock).
315
+ events: list[dict[str, Any]] = []
316
+ for table, diff in diffs.items():
317
+ expected = MANAGED_TABLE_LAYOUTS[table]
318
+ actual = tuple(diff["actual"])
319
+ before = 0 if diff.get("missing") else _table_count(conn, table)
320
+ if diff.get("missing"):
321
+ conn.execute(CREATE_TABLE_SQL[table].format(table=table))
322
+ after = _table_count(conn, table)
323
+ if after != before:
324
+ raise RuntimeError(f"schema rebuild row count changed for {table}: {before} != {after}")
325
+ events.append({
326
+ "table": table,
327
+ "from_layout_columns": [],
328
+ "to_layout_columns": list(expected),
329
+ "backup_path": str(backup_path),
330
+ "row_count_before": before,
331
+ "row_count_after": after,
332
+ "missing": True,
333
+ })
334
+ continue
335
+ temp = f"__team_agent_rebuild_{table}"
336
+ old = f"__team_agent_old_{table}"
337
+ conn.execute(f"drop table if exists {temp}")
338
+ conn.execute(f"drop table if exists {old}")
339
+ conn.execute(CREATE_TABLE_SQL[table].format(table=temp))
340
+ common = [column for column in expected if column in actual]
341
+ column_sql = ", ".join(common)
342
+ conn.execute(f"insert into {temp}({column_sql}) select {column_sql} from {table}")
343
+ _maybe_fault_after_insert()
344
+ conn.execute(f"alter table {table} rename to {old}")
345
+ conn.execute(f"alter table {temp} rename to {table}")
346
+ conn.execute(f"drop table {old}")
347
+ after = _table_count(conn, table)
348
+ if before != after:
349
+ raise RuntimeError(f"schema rebuild row count changed for {table}: {before} != {after}")
350
+ events.append({
351
+ "table": table,
352
+ "from_layout_columns": list(actual),
353
+ "to_layout_columns": list(expected),
354
+ "backup_path": str(backup_path),
355
+ "row_count_before": before,
356
+ "row_count_after": after,
357
+ })
358
+ ensure_schema_indexes(conn)
359
+ return events
360
+
361
+
362
+ def _emit_rebuild_events(db_path: Path, events: list[dict[str, Any]]) -> None:
363
+ workspace = _workspace_from_db_path(db_path)
364
+ if workspace is None:
365
+ return
366
+ from team_agent.events import EventLog
367
+ log = EventLog(workspace)
368
+ for event in events:
369
+ log.write("schema.layout_rebuild", **event)
370
+
371
+
372
+ def _db_lock_status(db_path: Path) -> str | None:
373
+ conn = sqlite3.connect(db_path, timeout=0.0, isolation_level=None)
374
+ try:
375
+ conn.execute("BEGIN IMMEDIATE")
376
+ conn.execute("ROLLBACK")
377
+ return None
378
+ except sqlite3.OperationalError as exc:
379
+ message = str(exc).lower()
380
+ if "locked" in message or "busy" in message:
381
+ return "active_lock"
382
+ raise
383
+ finally:
384
+ conn.close()
385
+
386
+
387
+ def _table_count(conn: sqlite3.Connection, table: str) -> int:
388
+ row = conn.execute(f"select count(*) as n from {table}").fetchone()
389
+ return int(row["n"])
390
+
391
+
392
+ def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
393
+ row = conn.execute(
394
+ "select name from sqlite_master where type = 'table' and name = ?",
395
+ (table,),
396
+ ).fetchone()
397
+ return row is not None
398
+
399
+
400
+ def _pragma_user_version(conn: sqlite3.Connection) -> int:
401
+ row = conn.execute("pragma user_version").fetchone()
402
+ return int(row["user_version"])
403
+
404
+
405
+ def _read_user_version(db_path: Path) -> int:
406
+ if not db_path.exists():
407
+ return 0
408
+ conn = sqlite3.connect(db_path)
409
+ conn.row_factory = sqlite3.Row
410
+ try:
411
+ return _pragma_user_version(conn)
412
+ finally:
413
+ conn.close()
414
+
415
+
416
+ def _db_path_from_conn(conn: sqlite3.Connection) -> Path | None:
417
+ row = conn.execute("pragma database_list").fetchone()
418
+ if not row:
419
+ return None
420
+ filename = str(row["file"])
421
+ return Path(filename) if filename else None
422
+
423
+
424
+ def _workspace_from_db_path(db_path: Path) -> Path | None:
425
+ parts = db_path.parts
426
+ if len(parts) >= 3 and parts[-3:] == (".team", "runtime", "team.db"):
427
+ return db_path.parent.parent.parent
428
+ return None
429
+
430
+
431
+ def _backup_path(db_path: Path, user_version: int) -> Path:
432
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
433
+ return db_path.with_name(f"team.db.pre-migration-{stamp}-from-v{user_version}.bak")
434
+
435
+
436
+ def _maybe_fault_after_insert() -> None:
437
+ allowed_keys = {
438
+ "TEAM_AGENT_SCHEMA_MIGRATION_CRASH_AT",
439
+ "GAP46_TEST_CRASH",
440
+ "GAP46_TEST_PARTIAL_REBUILD",
441
+ }
442
+ allowed_values = {"1", "crash", "partial", "after_insert_before_rename"}
443
+ for key in allowed_keys:
444
+ value = os.environ.get(key)
445
+ if value in allowed_values:
446
+ os._exit(97)