Open History to see the last commit
1#!/usr/bin/env python3
2"""python3 scripts/seed-issue-21397.py --home NEW_DIR [matching|absent|unrelated] [--now [ISO]] [--source DB].
3
4Copies a migrated statev2.sqlite (default: this worktree's .t3, with dev stopped;
5run dev once first to migrate it) into NEW_DIR/userdata, then replaces every
6thread/project projection in that copy with a synthetic stopped usage-limit run.
7The source is only read as bytes, never opened by SQLite, so it stays unchanged.
8NEW_DIR must not exist and must not be under the live ~/.t3. Then run the printed
9`vp run dev --home-dir NEW_DIR`.
10
11Fixture times are frozen around the evidence clock, 2026-10-22T19:00:00Z (usage
12limit hit; reset and matching snooze at 2026-10-23T08:50:00Z). By default they
13are seeded unchanged, reproducing the PR captures. After that reset, pass --now
14(current time) or --now ISO to shift every timestamp so the failure happens then.
15"""
16import argparse
17import contextlib
18import datetime
19import json
20import pathlib
21import re
22import shlex
23import shutil
24import sqlite3
25
26EVIDENCE_CLOCK = datetime.datetime(2026, 10, 22, 19, tzinfo=datetime.timezone.utc)
27ISO = re.compile(r'^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$')
28
29
30def parse_iso(value):
31 parsed = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
32 return parsed if parsed.tzinfo else parsed.replace(tzinfo=datetime.timezone.utc)
33
34
35def shift(value, delta):
36 if isinstance(value, dict):
37 return {k: shift(v, delta) for k, v in value.items()}
38 if isinstance(value, str) and ISO.match(value):
39 moved = parse_iso(value) + delta
40 return moved.strftime('%Y-%m-%dT%H:%M:%S.') + f'{moved.microsecond // 1000:03d}Z'
41 return value
42
43
44root = pathlib.Path(__file__).resolve().parent.parent
45parser = argparse.ArgumentParser()
46parser.add_argument('mode', nargs='?', default='matching', choices=('matching', 'absent', 'unrelated'))
47parser.add_argument('--home', required=True, type=pathlib.Path, help='new T3 home to create; must not exist')
48parser.add_argument('--source', type=pathlib.Path, default=root / '.t3/userdata/statev2.sqlite')
49parser.add_argument('--now', nargs='?', const='now', help='shift fixture times so the limit is hit at this ISO time')
50args = parser.parse_args()
51mode = args.mode
52now = None if args.now is None else datetime.datetime.now(datetime.timezone.utc) if args.now == 'now' else parse_iso(args.now)
53delta = datetime.timedelta() if now is None else now - EVIDENCE_CLOCK
54
55source = args.source.absolute()
56if source.is_symlink() or not source.is_file():
57 raise SystemExit(f'Expected a migrated statev2.sqlite regular file at {source}')
58# Resolve the parent, not the home itself, so a symlink at the home path is refused below.
59home = args.home.absolute().parent.resolve() / args.home.name
60live = (pathlib.Path.home() / '.t3').resolve()
61if not args.home.name or home == live or live in home.parents:
62 raise SystemExit(f'Refusing fixture home {home}: pick a new directory outside {live}')
63home.parent.mkdir(parents=True, exist_ok=True)
64try:
65 home.mkdir() # Atomic: fails on any existing file, directory, or symlink.
66except FileExistsError:
67 raise SystemExit(f'Refusing existing fixture home {home}: pick a new directory')
68
69try:
70 userdata = home / 'userdata'
71 userdata.mkdir()
72 path = userdata / 'statev2.sqlite'
73 shutil.copyfile(source, path)
74 wal = source.with_name(source.name + '-wal')
75 if wal.is_file() and not wal.is_symlink():
76 shutil.copyfile(wal, path.with_name(path.name + '-wal'))
77 fixture = json.loads((root / 'scripts/fixtures/issue-21397.json').read_text())
78 # closing() outlives the transaction so cleanup below never deletes an open database.
79 with contextlib.closing(sqlite3.connect(path)) as db, db:
80 # Drop copied conversations, including V1 tables the server could re-import.
81 tables = [r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")]
82 for table in tables:
83 if table.startswith('orchestration_v2_projection_') and table != 'orchestration_v2_projection_metadata':
84 db.execute(f'DELETE FROM "{table}"')
85 elif table.startswith('projection_thread') or table == 'projection_projects':
86 db.execute(f'DELETE FROM "{table}"')
87 db.execute("UPDATE orchestration_v2_effect_outbox SET status='cancelled' WHERE status IN ('pending','running')")
88 for table, rows in fixture.items():
89 for row in rows:
90 row = shift(dict(row), delta)
91 if table == 'projection_projects':
92 row['workspace_root'] = str(root)
93 if table == 'orchestration_v2_projection_threads':
94 payload = json.loads(row['payload_json'])
95 if mode == 'absent':
96 payload['snoozedUntil'] = None
97 elif mode == 'unrelated':
98 payload['snoozedUntil'] = '2026-10-24T08:50:00.000Z'
99 row['payload_json'] = json.dumps(shift(payload, delta))
100 elif delta and 'payload_json' in row:
101 row['payload_json'] = json.dumps(shift(json.loads(row['payload_json']), delta))
102 columns = ','.join(f'"{k}"' for k in row)
103 placeholders = ','.join('?' for _ in row)
104 db.execute(f'INSERT INTO "{table}" ({columns}) VALUES ({placeholders})', list(row.values()))
105 settings = dict(snoozeLimitedThreads=False, autoResumeLimitedThreads=False)
106 (userdata / 'settings.json').write_text(json.dumps(settings, indent=2) + '\n')
107except BaseException:
108 shutil.rmtree(home) # Created above by this run, so it holds nothing else.
109 raise
110hit = (now or EVIDENCE_CLOCK).astimezone(datetime.timezone.utc)
111print(f'Seeded synthetic {mode} fixture in {path} (limit hit at {hit:%Y-%m-%dT%H:%M:%SZ})')
112print(f'Run: vp run dev --home-dir {shlex.quote(str(home))}')