cursorfleet 0.1.0__py3-none-any.whl

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.
Files changed (101) hide show
  1. cursorfleet/__init__.py +9 -0
  2. cursorfleet/_templates/cursor/agents/architect.md +18 -0
  3. cursorfleet/_templates/cursor/agents/implementer.md +20 -0
  4. cursorfleet/_templates/cursor/agents/patcher.md +17 -0
  5. cursorfleet/_templates/cursor/agents/qa.md +21 -0
  6. cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
  7. cursorfleet/_templates/cursor/agents/scout.md +17 -0
  8. cursorfleet/_templates/cursor/agents/tester.md +20 -0
  9. cursorfleet/_templates/cursor/agents-md/root.md +7 -0
  10. cursorfleet/_templates/cursor/agents-md/work.md +7 -0
  11. cursorfleet/_templates/cursor/config/config.toml +47 -0
  12. cursorfleet/_templates/cursor/hooks/entry.json +4 -0
  13. cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
  14. cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
  15. cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
  16. cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
  17. cursorfleet/adapters/__init__.py +0 -0
  18. cursorfleet/adapters/cursor/__init__.py +0 -0
  19. cursorfleet/adapters/cursor/blocks.py +104 -0
  20. cursorfleet/adapters/cursor/diagnostics.py +547 -0
  21. cursorfleet/adapters/cursor/fsutil.py +156 -0
  22. cursorfleet/adapters/cursor/hook_main.py +428 -0
  23. cursorfleet/adapters/cursor/hook_normalize.py +527 -0
  24. cursorfleet/adapters/cursor/hook_policy.py +76 -0
  25. cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
  26. cursorfleet/adapters/cursor/hooksjson.py +305 -0
  27. cursorfleet/adapters/cursor/installer.py +626 -0
  28. cursorfleet/adapters/cursor/kit.py +210 -0
  29. cursorfleet/adapters/cursor/kit_probe.py +54 -0
  30. cursorfleet/adapters/cursor/lock.py +145 -0
  31. cursorfleet/adapters/cursor/templates.py +60 -0
  32. cursorfleet/adapters/cursor/validation.py +365 -0
  33. cursorfleet/adapters/cursor/workspace.py +80 -0
  34. cursorfleet/cli/__init__.py +0 -0
  35. cursorfleet/cli/commands/__init__.py +1 -0
  36. cursorfleet/cli/commands/_common.py +82 -0
  37. cursorfleet/cli/commands/_resolution.py +56 -0
  38. cursorfleet/cli/commands/doctor.py +81 -0
  39. cursorfleet/cli/commands/events.py +197 -0
  40. cursorfleet/cli/commands/index.py +55 -0
  41. cursorfleet/cli/commands/init.py +71 -0
  42. cursorfleet/cli/commands/migrate.py +43 -0
  43. cursorfleet/cli/commands/replay.py +76 -0
  44. cursorfleet/cli/commands/run.py +97 -0
  45. cursorfleet/cli/commands/status.py +210 -0
  46. cursorfleet/cli/commands/tui.py +47 -0
  47. cursorfleet/cli/commands/uninstall.py +73 -0
  48. cursorfleet/cli/commands/validate.py +69 -0
  49. cursorfleet/cli/main.py +81 -0
  50. cursorfleet/config/__init__.py +44 -0
  51. cursorfleet/config/io.py +27 -0
  52. cursorfleet/config/models.py +154 -0
  53. cursorfleet/config/roster.py +245 -0
  54. cursorfleet/events/__init__.py +39 -0
  55. cursorfleet/events/forbidden.py +45 -0
  56. cursorfleet/events/ids.py +109 -0
  57. cursorfleet/events/kinds.py +132 -0
  58. cursorfleet/events/models.py +213 -0
  59. cursorfleet/events/pathcheck.py +45 -0
  60. cursorfleet/git/__init__.py +0 -0
  61. cursorfleet/git/collector.py +226 -0
  62. cursorfleet/git/runner.py +135 -0
  63. cursorfleet/git/worktrees.py +71 -0
  64. cursorfleet/policy/__init__.py +0 -0
  65. cursorfleet/py.typed +0 -0
  66. cursorfleet/safeexe.py +70 -0
  67. cursorfleet/schema_gen.py +73 -0
  68. cursorfleet/state/__init__.py +0 -0
  69. cursorfleet/state/artifact_scan.py +309 -0
  70. cursorfleet/state/context.py +38 -0
  71. cursorfleet/state/event_store.py +227 -0
  72. cursorfleet/state/indexer.py +443 -0
  73. cursorfleet/state/lock.py +74 -0
  74. cursorfleet/state/models.py +268 -0
  75. cursorfleet/state/reducer.py +588 -0
  76. cursorfleet/state/retention.py +218 -0
  77. cursorfleet/state/run_builder.py +136 -0
  78. cursorfleet/state/run_model.py +43 -0
  79. cursorfleet/state/run_store.py +95 -0
  80. cursorfleet/state/runtime.py +645 -0
  81. cursorfleet/state/spool.py +186 -0
  82. cursorfleet/state/spool_read.py +242 -0
  83. cursorfleet/state/status_doc.py +293 -0
  84. cursorfleet/tui/__init__.py +0 -0
  85. cursorfleet/tui/app.py +581 -0
  86. cursorfleet/tui/data.py +545 -0
  87. cursorfleet/tui/filters.py +82 -0
  88. cursorfleet/tui/gates.py +255 -0
  89. cursorfleet/tui/help.py +101 -0
  90. cursorfleet/tui/layout.py +26 -0
  91. cursorfleet/tui/theme.py +176 -0
  92. cursorfleet/tui/views.py +1077 -0
  93. cursorfleet/tui/watch.py +33 -0
  94. cursorfleet/workflow/__init__.py +0 -0
  95. cursorfleet/workflow/artifacts.py +148 -0
  96. cursorfleet/workflow/frontmatter.py +232 -0
  97. cursorfleet-0.1.0.dist-info/METADATA +161 -0
  98. cursorfleet-0.1.0.dist-info/RECORD +101 -0
  99. cursorfleet-0.1.0.dist-info/WHEEL +4 -0
  100. cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
  101. cursorfleet-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,218 @@
1
+ """Spool retention and purge (docs/privacy.md, "Retention" and "Purge command contract").
2
+
3
+ Defaults: 14 days or 50 MB per session, whichever comes first. Everything here touches only
4
+ ``<git-common-dir>/cursorfleet/``; committed ``.cursorfleet/`` and ``.cursor/`` are never
5
+ reachable from these functions. Deletion is ordinary file removal, not secure wiping.
6
+ Planning is separate from applying so ``--dry-run`` shares the exact same code path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import shutil
14
+ from contextlib import suppress
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timedelta
17
+
18
+ from cursorfleet.events.ids import fs_name_for
19
+ from cursorfleet.state.runtime import RuntimePaths
20
+ from cursorfleet.state.spool import CAPPED_MARKER, SPOOL_SUFFIX
21
+
22
+ DEFAULT_MAX_AGE_DAYS = 14
23
+ DEFAULT_MAX_SESSION_MB = 50
24
+
25
+ _DURATION = re.compile(r"^(\d{1,6})([smhdw])$")
26
+ _UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
27
+
28
+
29
+ def parse_duration(text: str) -> timedelta:
30
+ """Parse ``30m``, ``12h``, ``7d``, ``2w`` (and ``s``). Raises ``ValueError`` otherwise."""
31
+ match = _DURATION.match(text.strip().lower())
32
+ if match is None:
33
+ msg = f"invalid duration {text!r}; use e.g. 90m, 12h, 7d, 2w"
34
+ raise ValueError(msg)
35
+ return timedelta(seconds=int(match.group(1)) * _UNITS[match.group(2)])
36
+
37
+
38
+ @dataclass
39
+ class PurgePlan:
40
+ """What would be removed. Paths are absolute and under the runtime root."""
41
+
42
+ files: list[str] = field(default_factory=list)
43
+ file_bytes: int = 0
44
+ session_dirs: list[str] = field(default_factory=list) # removed entirely (incl. markers)
45
+ partial_sessions: int = 0 # sessions that survive but lose some segments
46
+ clear_markers: list[str] = field(default_factory=list)
47
+ remove_projection: bool = False
48
+ rotate_key: bool = False
49
+ remove_quarantine: bool = False
50
+
51
+ @property
52
+ def sessions(self) -> int:
53
+ return len(self.session_dirs) + self.partial_sessions
54
+
55
+ @property
56
+ def empty(self) -> bool:
57
+ return not (
58
+ self.files
59
+ or self.session_dirs
60
+ or self.clear_markers
61
+ or self.remove_projection
62
+ or self.rotate_key
63
+ or self.remove_quarantine
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class PurgeReport:
69
+ sessions: int = 0
70
+ files: int = 0
71
+ bytes: int = 0
72
+ projection_removed: bool = False
73
+ key_rotated: bool = False
74
+ needs_reindex: bool = False
75
+ errors: list[str] = field(default_factory=list)
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class _SessionDir:
80
+ path: str
81
+ files: tuple[tuple[str, int, float], ...] # (path, size, mtime), oldest first
82
+
83
+ @property
84
+ def size(self) -> int:
85
+ return sum(size for _p, size, _m in self.files)
86
+
87
+ @property
88
+ def newest(self) -> float:
89
+ return max((m for _p, _s, m in self.files), default=0.0)
90
+
91
+
92
+ def _scan_sessions(paths: RuntimePaths) -> list[_SessionDir]:
93
+ found: list[_SessionDir] = []
94
+ try:
95
+ with os.scandir(paths.spool) as entries:
96
+ directories = sorted(e.path for e in entries if e.is_dir(follow_symlinks=False))
97
+ except OSError:
98
+ return found
99
+ for directory in directories:
100
+ files: list[tuple[str, int, float]] = []
101
+ try:
102
+ with os.scandir(directory) as entries:
103
+ for entry in entries:
104
+ if entry.name.endswith(SPOOL_SUFFIX) and entry.is_file(follow_symlinks=False):
105
+ stat = entry.stat(follow_symlinks=False)
106
+ files.append((entry.path, stat.st_size, stat.st_mtime))
107
+ except OSError:
108
+ continue
109
+ files.sort(key=lambda f: (f[2], f[0]))
110
+ found.append(_SessionDir(directory, tuple(files)))
111
+ return found
112
+
113
+
114
+ def _whole_session(plan: PurgePlan, session: _SessionDir) -> None:
115
+ plan.session_dirs.append(session.path)
116
+ plan.files.extend(p for p, _s, _m in session.files)
117
+ plan.file_bytes += session.size
118
+
119
+
120
+ def plan_purge( # noqa: PLR0913
121
+ paths: RuntimePaths,
122
+ *,
123
+ now: datetime,
124
+ older_than: timedelta | None = None,
125
+ session: str | None = None,
126
+ everything: bool = False,
127
+ max_age_days: int = DEFAULT_MAX_AGE_DAYS,
128
+ max_session_mb: int = DEFAULT_MAX_SESSION_MB,
129
+ ) -> PurgePlan:
130
+ """Plan a purge. With no selector the configured retention is applied."""
131
+ plan = PurgePlan()
132
+ sessions = _scan_sessions(paths)
133
+ if everything:
134
+ for item in sessions:
135
+ _whole_session(plan, item)
136
+ plan.remove_projection = os.path.isdir(paths.index_dir)
137
+ plan.rotate_key = os.path.exists(paths.hmac_key)
138
+ plan.remove_quarantine = os.path.isdir(paths.quarantine)
139
+ return plan
140
+ if session is not None:
141
+ target = os.path.join(paths.spool, fs_name_for(session))
142
+ for item in sessions:
143
+ if item.path == target:
144
+ _whole_session(plan, item)
145
+ return plan
146
+ now_ts = now.timestamp()
147
+ cap = max_session_mb * 1024 * 1024
148
+ age_limit = older_than if older_than is not None else timedelta(days=max_age_days)
149
+ for item in sessions:
150
+ if item.files and now_ts - item.newest > age_limit.total_seconds():
151
+ _whole_session(plan, item)
152
+ continue
153
+ if older_than is None and item.size > cap:
154
+ _plan_over_cap(plan, item, cap)
155
+ elif older_than is None and os.path.lexists(os.path.join(item.path, CAPPED_MARKER)):
156
+ plan.clear_markers.append(os.path.join(item.path, CAPPED_MARKER))
157
+ return plan
158
+
159
+
160
+ def _plan_over_cap(plan: PurgePlan, item: _SessionDir, cap: int) -> None:
161
+ remaining = item.size
162
+ removed_any = False
163
+ for path, size, _mtime in item.files: # oldest first; active files are newest anyway
164
+ if remaining <= cap:
165
+ break
166
+ plan.files.append(path)
167
+ plan.file_bytes += size
168
+ remaining -= size
169
+ removed_any = True
170
+ if removed_any:
171
+ plan.partial_sessions += 1
172
+ marker = os.path.join(item.path, CAPPED_MARKER)
173
+ if remaining < cap and os.path.lexists(marker):
174
+ plan.clear_markers.append(marker)
175
+
176
+
177
+ def _remove_file(path: str, report: PurgeReport) -> bool:
178
+ try:
179
+ os.remove(path)
180
+ except FileNotFoundError:
181
+ return True
182
+ except OSError as exc:
183
+ report.errors.append(f"{os.path.basename(path)}: {exc.strerror or exc}")
184
+ return False
185
+ return True
186
+
187
+
188
+ def apply_plan(paths: RuntimePaths, plan: PurgePlan) -> PurgeReport:
189
+ """Execute ``plan``. Best effort: failures are collected, never raised."""
190
+ report = PurgeReport()
191
+ root = os.path.realpath(paths.root)
192
+ for path in plan.files:
193
+ if not os.path.realpath(path).startswith(root + os.sep):
194
+ report.errors.append("refused path outside the runtime directory")
195
+ continue
196
+ try:
197
+ size = os.lstat(path).st_size
198
+ except OSError:
199
+ size = 0
200
+ if _remove_file(path, report):
201
+ report.files += 1
202
+ report.bytes += size
203
+ for marker in plan.clear_markers:
204
+ _remove_file(marker, report)
205
+ for directory in plan.session_dirs:
206
+ _remove_file(os.path.join(directory, CAPPED_MARKER), report)
207
+ with suppress(OSError): # not empty (e.g. a hook recreated a file): leave it
208
+ os.rmdir(directory)
209
+ report.sessions = plan.sessions
210
+ report.needs_reindex = bool(plan.files) or plan.remove_projection
211
+ if plan.remove_projection:
212
+ shutil.rmtree(paths.index_dir, ignore_errors=True)
213
+ report.projection_removed = True
214
+ if plan.rotate_key:
215
+ report.key_rotated = _remove_file(paths.hmac_key, report)
216
+ if plan.remove_quarantine:
217
+ shutil.rmtree(paths.quarantine, ignore_errors=True)
218
+ return report
@@ -0,0 +1,136 @@
1
+ """Build task/run snapshots from sessions, artifacts, and optional run metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from cursorfleet.state.artifact_scan import ArtifactRecord
8
+ from cursorfleet.state.models import Lane, SessionAcc, SessionView, utc
9
+ from cursorfleet.state.run_model import (
10
+ Confidence,
11
+ ParticipantGroup,
12
+ ParticipantInstance,
13
+ RunSnapshot,
14
+ )
15
+
16
+ DEFAULT_STALE_AFTER_S = 900
17
+
18
+
19
+ def _is_stale(view: SessionView, now: datetime, stale_after_s: int) -> bool:
20
+ if view.lane in (Lane.DONE.value, Lane.UNKNOWN.value):
21
+ return False
22
+ if view.last_ts is None:
23
+ return True
24
+ return (utc(now) - utc(view.last_ts)).total_seconds() > stale_after_s
25
+
26
+
27
+ def build_active_run( # noqa: PLR0913, PLR0912
28
+ *,
29
+ task_slug: str,
30
+ run_id: str,
31
+ sessions: dict[str, SessionAcc],
32
+ session_views: dict[str, SessionView],
33
+ records: list[ArtifactRecord],
34
+ linked_session_ids: frozenset[str] | None = None,
35
+ now: datetime,
36
+ stale_after_s: int = DEFAULT_STALE_AFTER_S,
37
+ ) -> RunSnapshot:
38
+ """In-memory Active Run for one task slug (B1 prototype)."""
39
+ task_records = [r for r in records if r.task == task_slug]
40
+ roles_from_declared = {r.author_role for r in task_records}
41
+
42
+ groups: dict[str, ParticipantGroup] = {}
43
+ unassociated: list[str] = []
44
+ suggested: list[str] = []
45
+
46
+ for sid, sview in session_views.items():
47
+ if _is_stale(sview, now, stale_after_s) and sid not in (linked_session_ids or ()):
48
+ continue
49
+ matched = False
50
+ for aview in sview.agents:
51
+ acc = sessions[sid].agents[aview.key]
52
+ role = acc.role or aview.key
53
+ if role in roles_from_declared or sid in (linked_session_ids or ()):
54
+ matched = True
55
+ inst = ParticipantInstance(
56
+ session_id=sid,
57
+ agent_key=aview.key,
58
+ role=acc.role,
59
+ lane=aview.lane,
60
+ lane_basis=aview.lane_basis,
61
+ last_ts=aview.last_ts,
62
+ stale=aview.stale,
63
+ )
64
+ grp = groups.get(role)
65
+ if grp is None:
66
+ groups[role] = ParticipantGroup(
67
+ role=role,
68
+ summary_lane=aview.lane,
69
+ confidence="explicit" if sid in (linked_session_ids or ()) else "inferred",
70
+ instances=[inst],
71
+ )
72
+ else:
73
+ grp.instances.append(inst)
74
+ if aview.lane == Lane.BLOCKED.value or grp.summary_lane != aview.lane:
75
+ grp.conflict = True
76
+ if not matched and not _is_stale(sview, now, stale_after_s):
77
+ if roles_from_declared:
78
+ suggested.append(sid)
79
+ else:
80
+ unassociated.append(sid)
81
+
82
+ for role in roles_from_declared:
83
+ if role not in groups:
84
+ blockers = sum(
85
+ 1 for r in task_records if r.author_role == role and r.kind == "blocker.raised"
86
+ )
87
+ groups[role] = ParticipantGroup(
88
+ role=role,
89
+ summary_lane=Lane.UNKNOWN.value,
90
+ confidence="declared",
91
+ declared_blockers=blockers,
92
+ )
93
+
94
+ if linked_session_ids:
95
+ confidence: Confidence = "explicit"
96
+ elif task_records:
97
+ confidence = "declared"
98
+ else:
99
+ confidence = "inferred"
100
+ primary_wt = None
101
+ branch = None
102
+ commit = None
103
+ linked = linked_session_ids or frozenset()
104
+ if linked:
105
+ git_pairs: list[tuple[str, SessionView]] = [
106
+ (sid, session_views[sid]) for sid in linked if sid in session_views
107
+ ]
108
+ else:
109
+ git_pairs = list(session_views.items())
110
+ for _sid, sview in git_pairs:
111
+ if sview.worktree_ids and primary_wt is None:
112
+ primary_wt = sview.worktree_ids[0]
113
+ if sview.branch and branch is None:
114
+ branch = sview.branch
115
+ if sview.commit and commit is None:
116
+ commit = sview.commit
117
+
118
+ return RunSnapshot(
119
+ run_id=run_id,
120
+ task_slug=task_slug,
121
+ confidence=confidence,
122
+ primary_worktree_id=primary_wt,
123
+ branch=branch,
124
+ commit=commit,
125
+ groups=sorted(groups.values(), key=lambda g: g.role),
126
+ unassociated_session_ids=sorted(unassociated),
127
+ suggested_session_ids=sorted(suggested),
128
+ )
129
+
130
+
131
+ def pick_primary_task(records: list[ArtifactRecord], *, now: datetime) -> str | None:
132
+ """Most recently declared task slug, if any."""
133
+ if not records:
134
+ return None
135
+ latest = max(records, key=lambda r: (r.created, r.path))
136
+ return latest.task
@@ -0,0 +1,43 @@
1
+ """Task/run view models (M2.5). Pure data; no I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from typing import Literal
8
+
9
+ Confidence = Literal["explicit", "declared", "inferred"]
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ParticipantInstance:
14
+ session_id: str
15
+ agent_key: str
16
+ role: str | None
17
+ lane: str
18
+ lane_basis: str
19
+ last_ts: datetime | None
20
+ stale: bool = False
21
+
22
+
23
+ @dataclass
24
+ class ParticipantGroup:
25
+ role: str
26
+ summary_lane: str
27
+ confidence: Confidence
28
+ instances: list[ParticipantInstance] = field(default_factory=list)
29
+ declared_blockers: int = 0
30
+ conflict: bool = False
31
+
32
+
33
+ @dataclass
34
+ class RunSnapshot:
35
+ run_id: str
36
+ task_slug: str
37
+ confidence: Confidence
38
+ primary_worktree_id: str | None
39
+ branch: str | None
40
+ commit: str | None
41
+ groups: list[ParticipantGroup] = field(default_factory=list)
42
+ unassociated_session_ids: list[str] = field(default_factory=list)
43
+ suggested_session_ids: list[str] = field(default_factory=list)
@@ -0,0 +1,95 @@
1
+ """Run metadata under ``<git-common-dir>/cursorfleet/runs/`` (B2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import asdict, dataclass, field
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ RUNS_SCHEMA = "cursorfleet.run/0.1"
13
+
14
+
15
+ @dataclass
16
+ class RunRecord:
17
+ run_id: str
18
+ task_slug: str
19
+ created_at: str
20
+ archived: bool = False
21
+ session_ids: list[str] = field(default_factory=list)
22
+ schema: str = RUNS_SCHEMA
23
+
24
+ def to_json(self) -> dict[str, Any]:
25
+ data = asdict(self)
26
+ data["schema"] = RUNS_SCHEMA
27
+ return data
28
+
29
+ @classmethod
30
+ def from_json(cls, data: dict[str, Any]) -> RunRecord:
31
+ return cls(
32
+ run_id=str(data["run_id"]),
33
+ task_slug=str(data["task_slug"]),
34
+ created_at=str(data.get("created_at", datetime.now(tz=UTC).isoformat())),
35
+ archived=bool(data.get("archived", False)),
36
+ session_ids=[str(s) for s in data.get("session_ids", [])],
37
+ )
38
+
39
+
40
+ def runs_dir(runtime_root: str) -> Path:
41
+ return Path(runtime_root) / "runs"
42
+
43
+
44
+ def _run_path(root: str, run_id: str) -> Path:
45
+ safe = run_id.replace("/", "_").replace("\\", "_")
46
+ return runs_dir(root) / f"{safe}.json"
47
+
48
+
49
+ def list_runs(runtime_root: str) -> list[RunRecord]:
50
+ directory = runs_dir(runtime_root)
51
+ if not directory.is_dir():
52
+ return []
53
+ out: list[RunRecord] = []
54
+ for path in sorted(directory.glob("*.json")):
55
+ if path.name == "index.json":
56
+ continue
57
+ try:
58
+ data = json.loads(path.read_text(encoding="utf-8"))
59
+ if isinstance(data, dict):
60
+ out.append(RunRecord.from_json(data))
61
+ except (OSError, json.JSONDecodeError, KeyError, TypeError):
62
+ continue
63
+ return out
64
+
65
+
66
+ def load_run(runtime_root: str, run_id: str) -> RunRecord | None:
67
+ path = _run_path(runtime_root, run_id)
68
+ if not path.is_file():
69
+ return None
70
+ try:
71
+ data = json.loads(path.read_text(encoding="utf-8"))
72
+ except (OSError, json.JSONDecodeError):
73
+ return None
74
+ if not isinstance(data, dict):
75
+ return None
76
+ return RunRecord.from_json(data)
77
+
78
+
79
+ def save_run(runtime_root: str, record: RunRecord) -> None:
80
+ directory = runs_dir(runtime_root)
81
+ directory.mkdir(parents=True, exist_ok=True)
82
+ path = _run_path(runtime_root, record.run_id)
83
+ tmp = path.with_suffix(".json.tmp")
84
+ tmp.write_text(json.dumps(record.to_json(), indent=2, sort_keys=True) + "\n", encoding="utf-8")
85
+ os.replace(tmp, path)
86
+
87
+
88
+ def attach_session(runtime_root: str, run_id: str, session_id: str) -> bool:
89
+ record = load_run(runtime_root, run_id)
90
+ if record is None or record.archived:
91
+ return False
92
+ if session_id not in record.session_ids:
93
+ record.session_ids.append(session_id)
94
+ save_run(runtime_root, record)
95
+ return True