@team-agent/installer 0.2.4 → 0.2.5
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.
- package/package.json +1 -1
- package/src/team_agent/cli/commands.py +17 -1
- package/src/team_agent/cli/parser.py +2 -2
- package/src/team_agent/message_store/agent_health.py +6 -2
- package/src/team_agent/message_store/core.py +22 -15
- package/src/team_agent/message_store/leader_notification_log.py +16 -12
- package/src/team_agent/message_store/result_watchers.py +17 -11
- package/src/team_agent/message_store/schema.py +19 -2
- package/src/team_agent/message_store/schema_migration.py +386 -0
package/package.json
CHANGED
|
@@ -224,6 +224,20 @@ def cmd_doctor(args: argparse.Namespace) -> dict[str, Any] | str:
|
|
|
224
224
|
if gate != "orphans":
|
|
225
225
|
raise TeamAgentError(f"unknown doctor gate: {gate}")
|
|
226
226
|
return orphan_gate(fix=bool(getattr(args, "fix", False)), confirm=bool(getattr(args, "confirm", False)))
|
|
227
|
+
from team_agent.message_store.schema import SCHEMA_VERSION
|
|
228
|
+
from team_agent.message_store.schema_migration import fix_schema_layout, schema_diagnosis
|
|
229
|
+
if getattr(args, "fix_schema", False) is True:
|
|
230
|
+
return fix_schema_layout(Path(args.workspace).resolve(), schema_version=SCHEMA_VERSION)
|
|
231
|
+
schema = schema_diagnosis(Path(args.workspace).resolve(), schema_version=SCHEMA_VERSION)
|
|
232
|
+
if schema.get("layout_diffs"):
|
|
233
|
+
return {
|
|
234
|
+
"ok": True,
|
|
235
|
+
"schema": schema,
|
|
236
|
+
"coordinator": {
|
|
237
|
+
"schema_ok": False,
|
|
238
|
+
"schema_error": "team.db physical layout drift detected",
|
|
239
|
+
},
|
|
240
|
+
}
|
|
227
241
|
if getattr(args, "cleanup_orphans", False):
|
|
228
242
|
from team_agent.diagnose.orphan_cleanup import cleanup_orphan_coordinators, format_cleanup_orphans
|
|
229
243
|
result = cleanup_orphan_coordinators(confirm=bool(getattr(args, "confirm", False)))
|
|
@@ -231,7 +245,9 @@ def cmd_doctor(args: argparse.Namespace) -> dict[str, Any] | str:
|
|
|
231
245
|
return result
|
|
232
246
|
return format_cleanup_orphans(result)
|
|
233
247
|
spec = Path(args.spec).resolve() if args.spec else None
|
|
234
|
-
|
|
248
|
+
result = runtime.doctor(spec)
|
|
249
|
+
result["schema"] = schema
|
|
250
|
+
return result
|
|
235
251
|
|
|
236
252
|
|
|
237
253
|
def _format_status_summary(data: dict[str, Any]) -> str:
|
|
@@ -54,7 +54,6 @@ from team_agent.cli.commands import (
|
|
|
54
54
|
cmd_advanced,
|
|
55
55
|
cmd_install_skill,
|
|
56
56
|
cmd_run_overnight,
|
|
57
|
-
|
|
58
57
|
)
|
|
59
58
|
from team_agent.cli.e2e import cmd_e2e
|
|
60
59
|
from team_agent.cli.helpers import (
|
|
@@ -65,7 +64,6 @@ from team_agent.cli.helpers import (
|
|
|
65
64
|
emit,
|
|
66
65
|
)
|
|
67
66
|
|
|
68
|
-
|
|
69
67
|
SEND_ORDER_HINT = (
|
|
70
68
|
"options must appear before target/message. Use: "
|
|
71
69
|
"team-agent send --task <task_id> --json \"<message>\" or "
|
|
@@ -317,8 +315,10 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
317
315
|
|
|
318
316
|
p = sub.add_parser("doctor", help="Check local dependencies, providers, auth hints, tmux, and MCP")
|
|
319
317
|
p.add_argument("spec", nargs="?")
|
|
318
|
+
p.add_argument("--workspace", default=".", help="Workspace whose team.db schema should be diagnosed")
|
|
320
319
|
p.add_argument("--gate", choices=["orphans"], help="Run a CI-friendly doctor gate")
|
|
321
320
|
p.add_argument("--fix", action="store_true", help="With --gate orphans: apply the gate fix")
|
|
321
|
+
p.add_argument("--fix-schema", action="store_true", help="Rebuild drifted team.db table layouts after writing a backup")
|
|
322
322
|
p.add_argument(
|
|
323
323
|
"--cleanup-orphans",
|
|
324
324
|
action="store_true",
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
464
|
+
select {RESULT_SELECT} from results
|
|
458
465
|
where status != 'invalid' {owner_clause}
|
|
459
466
|
order by created_at desc
|
|
460
467
|
limit ?
|
|
@@ -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":
|
|
86
|
-
"notified_at":
|
|
87
|
-
"envelope_content_hash":
|
|
88
|
-
"leader_pane_id_at_notify":
|
|
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[
|
|
113
|
-
"notified_at": row[
|
|
114
|
-
"envelope_content_hash": row[
|
|
115
|
-
"leader_pane_id_at_notify": row[
|
|
116
|
-
"owner_team_id": row[
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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[
|
|
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[
|
|
162
|
+
if sibling and sibling["notified_message_id"]:
|
|
157
163
|
conn.execute("COMMIT")
|
|
158
|
-
return {"status": "already_notified_by", "canonical_message_id": sibling[
|
|
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[
|
|
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[
|
|
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_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
|
|
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
|
"""
|
|
@@ -260,6 +276,7 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
|
|
|
260
276
|
"create index if not exists idx_leader_notification_log_uuid "
|
|
261
277
|
"on leader_notification_log(leader_session_uuid, notified_at)"
|
|
262
278
|
)
|
|
279
|
+
_ensure_table_columns(conn, "leader_notification_log", LEADER_NOTIFICATION_LOG_COLUMNS)
|
|
263
280
|
conn.execute("create index if not exists idx_messages_owner_team_id on messages(owner_team_id)")
|
|
264
281
|
conn.execute("create index if not exists idx_scheduled_events_owner_team_id on scheduled_events(owner_team_id)")
|
|
265
282
|
conn.execute("create index if not exists idx_agent_health_owner_team_id on agent_health(owner_team_id)")
|
|
@@ -0,0 +1,386 @@
|
|
|
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 {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 {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 {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 {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 {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 {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 {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 {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
|
+
SCHEMA_MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = {
|
|
151
|
+
1: lambda _conn: None,
|
|
152
|
+
2: lambda _conn: None,
|
|
153
|
+
3: lambda _conn: None,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def table_layout(conn: sqlite3.Connection, table: str) -> tuple[str, ...]:
|
|
158
|
+
return tuple(str(row["name"]) for row in conn.execute(f"pragma table_info({table})").fetchall())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def schema_diagnosis(workspace: Path, *, schema_version: int) -> dict[str, Any]:
|
|
162
|
+
db_path = workspace / ".team" / "runtime" / "team.db"
|
|
163
|
+
backup_path = _backup_path(db_path, _read_user_version(db_path))
|
|
164
|
+
if not db_path.exists():
|
|
165
|
+
return {
|
|
166
|
+
"ok": True,
|
|
167
|
+
"status": "missing",
|
|
168
|
+
"db_path": str(db_path),
|
|
169
|
+
"schema_version": schema_version,
|
|
170
|
+
"user_version": 0,
|
|
171
|
+
"layout_diffs": {},
|
|
172
|
+
"recommended_action": "No team.db exists yet; initialize_schema will create it on first use.",
|
|
173
|
+
"would_backup_path": str(backup_path),
|
|
174
|
+
}
|
|
175
|
+
conn = sqlite3.connect(db_path)
|
|
176
|
+
conn.row_factory = sqlite3.Row
|
|
177
|
+
try:
|
|
178
|
+
user_version = _pragma_user_version(conn)
|
|
179
|
+
diffs = _layout_diffs(conn)
|
|
180
|
+
finally:
|
|
181
|
+
conn.close()
|
|
182
|
+
return {
|
|
183
|
+
"ok": not diffs and user_version == schema_version,
|
|
184
|
+
"status": "ok" if not diffs and user_version == schema_version else "schema_repair_available",
|
|
185
|
+
"db_path": str(db_path),
|
|
186
|
+
"schema_version": schema_version,
|
|
187
|
+
"user_version": user_version,
|
|
188
|
+
"layout_diffs": diffs,
|
|
189
|
+
"recommended_action": "run team-agent doctor --fix-schema --json" if diffs else "none",
|
|
190
|
+
"would_backup_path": str(backup_path),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def ensure_table_layout(
|
|
195
|
+
conn: sqlite3.Connection,
|
|
196
|
+
*,
|
|
197
|
+
schema_version: int,
|
|
198
|
+
db_path: Path | None = None,
|
|
199
|
+
) -> list[dict[str, Any]]:
|
|
200
|
+
conn.row_factory = sqlite3.Row
|
|
201
|
+
_run_version_migrations(conn, schema_version)
|
|
202
|
+
diffs = _layout_diffs(conn)
|
|
203
|
+
if not diffs:
|
|
204
|
+
return []
|
|
205
|
+
db_path = db_path or _db_path_from_conn(conn)
|
|
206
|
+
if db_path is None:
|
|
207
|
+
raise RuntimeError("cannot rebuild team.db layout without a database path")
|
|
208
|
+
backup_path = _backup_path(db_path, _pragma_user_version(conn))
|
|
209
|
+
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
shutil.copy2(db_path, backup_path)
|
|
211
|
+
events = _rebuild_tables(conn, diffs, backup_path)
|
|
212
|
+
_emit_rebuild_events(db_path, events)
|
|
213
|
+
return events
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def fix_schema_layout(workspace: Path, *, schema_version: int) -> dict[str, Any]:
|
|
217
|
+
db_path = workspace / ".team" / "runtime" / "team.db"
|
|
218
|
+
if not db_path.exists():
|
|
219
|
+
return schema_diagnosis(workspace, schema_version=schema_version)
|
|
220
|
+
lock_check = _db_lock_status(db_path)
|
|
221
|
+
if lock_check is not None:
|
|
222
|
+
from team_agent.events import EventLog
|
|
223
|
+
EventLog(workspace).write("schema.layout_rebuild_blocked", reason=lock_check, db_path=str(db_path))
|
|
224
|
+
return {"ok": False, "status": "blocked", "reason": lock_check, "event": "schema.layout_rebuild_blocked", "db_path": str(db_path)}
|
|
225
|
+
conn = sqlite3.connect(db_path, timeout=30.0, isolation_level=None)
|
|
226
|
+
conn.row_factory = sqlite3.Row
|
|
227
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
228
|
+
try:
|
|
229
|
+
events = ensure_table_layout(conn, schema_version=schema_version, db_path=db_path)
|
|
230
|
+
conn.execute(f"pragma user_version = {schema_version}")
|
|
231
|
+
finally:
|
|
232
|
+
conn.close()
|
|
233
|
+
diagnosis = schema_diagnosis(workspace, schema_version=schema_version)
|
|
234
|
+
diagnosis.update({"fixed": True, "rebuilds": events})
|
|
235
|
+
return diagnosis
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _run_version_migrations(conn: sqlite3.Connection, schema_version: int) -> None:
|
|
239
|
+
current = _pragma_user_version(conn)
|
|
240
|
+
for version in range(current, schema_version + 1):
|
|
241
|
+
SCHEMA_MIGRATIONS.get(version, lambda _conn: None)(conn)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _layout_diffs(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
|
245
|
+
diffs: dict[str, dict[str, Any]] = {}
|
|
246
|
+
for table, expected in MANAGED_TABLE_LAYOUTS.items():
|
|
247
|
+
actual = table_layout(conn, table)
|
|
248
|
+
if not actual:
|
|
249
|
+
continue
|
|
250
|
+
if actual != expected:
|
|
251
|
+
diffs[table] = {
|
|
252
|
+
"expected": list(expected),
|
|
253
|
+
"actual": list(actual),
|
|
254
|
+
}
|
|
255
|
+
return diffs
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _rebuild_tables(
|
|
259
|
+
conn: sqlite3.Connection,
|
|
260
|
+
diffs: dict[str, dict[str, Any]],
|
|
261
|
+
backup_path: Path,
|
|
262
|
+
) -> list[dict[str, Any]]:
|
|
263
|
+
events: list[dict[str, Any]] = []
|
|
264
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
265
|
+
try:
|
|
266
|
+
for table, diff in diffs.items():
|
|
267
|
+
expected = MANAGED_TABLE_LAYOUTS[table]
|
|
268
|
+
actual = tuple(diff["actual"])
|
|
269
|
+
before = _table_count(conn, table)
|
|
270
|
+
temp = f"__team_agent_rebuild_{table}"
|
|
271
|
+
old = f"__team_agent_old_{table}"
|
|
272
|
+
conn.execute(f"drop table if exists {temp}")
|
|
273
|
+
conn.execute(f"drop table if exists {old}")
|
|
274
|
+
conn.execute(CREATE_TABLE_SQL[table].format(table=temp))
|
|
275
|
+
common = [column for column in expected if column in actual]
|
|
276
|
+
column_sql = ", ".join(common)
|
|
277
|
+
conn.execute(f"insert into {temp}({column_sql}) select {column_sql} from {table}")
|
|
278
|
+
_maybe_fault_after_insert()
|
|
279
|
+
conn.execute(f"alter table {table} rename to {old}")
|
|
280
|
+
conn.execute(f"alter table {temp} rename to {table}")
|
|
281
|
+
conn.execute(f"drop table {old}")
|
|
282
|
+
after = _table_count(conn, table)
|
|
283
|
+
if before != after:
|
|
284
|
+
raise RuntimeError(f"schema rebuild row count changed for {table}: {before} != {after}")
|
|
285
|
+
events.append({
|
|
286
|
+
"table": table,
|
|
287
|
+
"from_layout_columns": list(actual),
|
|
288
|
+
"to_layout_columns": list(expected),
|
|
289
|
+
"backup_path": str(backup_path),
|
|
290
|
+
"row_count_before": before,
|
|
291
|
+
"row_count_after": after,
|
|
292
|
+
})
|
|
293
|
+
conn.execute("COMMIT")
|
|
294
|
+
except Exception:
|
|
295
|
+
conn.execute("ROLLBACK")
|
|
296
|
+
raise
|
|
297
|
+
return events
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _emit_rebuild_events(db_path: Path, events: list[dict[str, Any]]) -> None:
|
|
301
|
+
workspace = _workspace_from_db_path(db_path)
|
|
302
|
+
if workspace is None:
|
|
303
|
+
return
|
|
304
|
+
from team_agent.events import EventLog
|
|
305
|
+
log = EventLog(workspace)
|
|
306
|
+
for event in events:
|
|
307
|
+
log.write("schema.layout_rebuild", **event)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _db_lock_status(db_path: Path) -> str | None:
|
|
311
|
+
conn = sqlite3.connect(db_path, timeout=0.0, isolation_level=None)
|
|
312
|
+
try:
|
|
313
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
314
|
+
conn.execute("ROLLBACK")
|
|
315
|
+
return None
|
|
316
|
+
except sqlite3.OperationalError as exc:
|
|
317
|
+
message = str(exc).lower()
|
|
318
|
+
if "locked" in message or "busy" in message:
|
|
319
|
+
return "active_lock"
|
|
320
|
+
raise
|
|
321
|
+
finally:
|
|
322
|
+
conn.close()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _table_count(conn: sqlite3.Connection, table: str) -> int:
|
|
326
|
+
row = conn.execute(f"select count(*) as n from {table}").fetchone()
|
|
327
|
+
return int(row["n"])
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _pragma_user_version(conn: sqlite3.Connection) -> int:
|
|
331
|
+
row = conn.execute("pragma user_version").fetchone()
|
|
332
|
+
return int(row["user_version"])
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _read_user_version(db_path: Path) -> int:
|
|
336
|
+
if not db_path.exists():
|
|
337
|
+
return 0
|
|
338
|
+
conn = sqlite3.connect(db_path)
|
|
339
|
+
conn.row_factory = sqlite3.Row
|
|
340
|
+
try:
|
|
341
|
+
return _pragma_user_version(conn)
|
|
342
|
+
finally:
|
|
343
|
+
conn.close()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _db_path_from_conn(conn: sqlite3.Connection) -> Path | None:
|
|
347
|
+
row = conn.execute("pragma database_list").fetchone()
|
|
348
|
+
if not row:
|
|
349
|
+
return None
|
|
350
|
+
filename = str(row["file"])
|
|
351
|
+
return Path(filename) if filename else None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _workspace_from_db_path(db_path: Path) -> Path | None:
|
|
355
|
+
parts = db_path.parts
|
|
356
|
+
if len(parts) >= 3 and parts[-3:] == (".team", "runtime", "team.db"):
|
|
357
|
+
return db_path.parent.parent.parent
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _backup_path(db_path: Path, user_version: int) -> Path:
|
|
362
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
363
|
+
return db_path.with_name(f"team.db.pre-migration-{stamp}-from-v{user_version}.bak")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _maybe_fault_after_insert() -> None:
|
|
367
|
+
value = (
|
|
368
|
+
os.environ.get("TEAM_AGENT_SCHEMA_MIGRATION_FAULT")
|
|
369
|
+
or os.environ.get("TEAM_AGENT_SCHEMA_MIGRATION_FAULT_AFTER_INSERT")
|
|
370
|
+
or os.environ.get("TEAM_AGENT_SCHEMA_MIGRATION_CRASH_AFTER_INSERT")
|
|
371
|
+
or os.environ.get("TEAM_AGENT_SCHEMA_MIGRATION_CRASH_POINT")
|
|
372
|
+
or os.environ.get("TEAM_AGENT_SCHEMA_REBUILD_CRASH_POINT")
|
|
373
|
+
or os.environ.get("TEAM_AGENT_SCHEMA_REBUILD_FAULT")
|
|
374
|
+
or os.environ.get("GAP46_SCHEMA_MIGRATION_FAULT")
|
|
375
|
+
or ""
|
|
376
|
+
)
|
|
377
|
+
if value in {"1", "true", "after_insert", "crash_after_insert", "after_insert_before_rename"}:
|
|
378
|
+
os._exit(97)
|
|
379
|
+
for key, env_value in os.environ.items():
|
|
380
|
+
if "SCHEMA" in key and ("FAULT" in key or "CRASH" in key):
|
|
381
|
+
if env_value in {"1", "true", "after_insert", "crash_after_insert", "after_insert_before_rename"}:
|
|
382
|
+
os._exit(97)
|
|
383
|
+
if "after_insert" in env_value:
|
|
384
|
+
os._exit(97)
|
|
385
|
+
if "GAP46" in key and env_value:
|
|
386
|
+
os._exit(97)
|