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.
- cursorfleet/__init__.py +9 -0
- cursorfleet/_templates/cursor/agents/architect.md +18 -0
- cursorfleet/_templates/cursor/agents/implementer.md +20 -0
- cursorfleet/_templates/cursor/agents/patcher.md +17 -0
- cursorfleet/_templates/cursor/agents/qa.md +21 -0
- cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
- cursorfleet/_templates/cursor/agents/scout.md +17 -0
- cursorfleet/_templates/cursor/agents/tester.md +20 -0
- cursorfleet/_templates/cursor/agents-md/root.md +7 -0
- cursorfleet/_templates/cursor/agents-md/work.md +7 -0
- cursorfleet/_templates/cursor/config/config.toml +47 -0
- cursorfleet/_templates/cursor/hooks/entry.json +4 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
- cursorfleet/adapters/__init__.py +0 -0
- cursorfleet/adapters/cursor/__init__.py +0 -0
- cursorfleet/adapters/cursor/blocks.py +104 -0
- cursorfleet/adapters/cursor/diagnostics.py +547 -0
- cursorfleet/adapters/cursor/fsutil.py +156 -0
- cursorfleet/adapters/cursor/hook_main.py +428 -0
- cursorfleet/adapters/cursor/hook_normalize.py +527 -0
- cursorfleet/adapters/cursor/hook_policy.py +76 -0
- cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
- cursorfleet/adapters/cursor/hooksjson.py +305 -0
- cursorfleet/adapters/cursor/installer.py +626 -0
- cursorfleet/adapters/cursor/kit.py +210 -0
- cursorfleet/adapters/cursor/kit_probe.py +54 -0
- cursorfleet/adapters/cursor/lock.py +145 -0
- cursorfleet/adapters/cursor/templates.py +60 -0
- cursorfleet/adapters/cursor/validation.py +365 -0
- cursorfleet/adapters/cursor/workspace.py +80 -0
- cursorfleet/cli/__init__.py +0 -0
- cursorfleet/cli/commands/__init__.py +1 -0
- cursorfleet/cli/commands/_common.py +82 -0
- cursorfleet/cli/commands/_resolution.py +56 -0
- cursorfleet/cli/commands/doctor.py +81 -0
- cursorfleet/cli/commands/events.py +197 -0
- cursorfleet/cli/commands/index.py +55 -0
- cursorfleet/cli/commands/init.py +71 -0
- cursorfleet/cli/commands/migrate.py +43 -0
- cursorfleet/cli/commands/replay.py +76 -0
- cursorfleet/cli/commands/run.py +97 -0
- cursorfleet/cli/commands/status.py +210 -0
- cursorfleet/cli/commands/tui.py +47 -0
- cursorfleet/cli/commands/uninstall.py +73 -0
- cursorfleet/cli/commands/validate.py +69 -0
- cursorfleet/cli/main.py +81 -0
- cursorfleet/config/__init__.py +44 -0
- cursorfleet/config/io.py +27 -0
- cursorfleet/config/models.py +154 -0
- cursorfleet/config/roster.py +245 -0
- cursorfleet/events/__init__.py +39 -0
- cursorfleet/events/forbidden.py +45 -0
- cursorfleet/events/ids.py +109 -0
- cursorfleet/events/kinds.py +132 -0
- cursorfleet/events/models.py +213 -0
- cursorfleet/events/pathcheck.py +45 -0
- cursorfleet/git/__init__.py +0 -0
- cursorfleet/git/collector.py +226 -0
- cursorfleet/git/runner.py +135 -0
- cursorfleet/git/worktrees.py +71 -0
- cursorfleet/policy/__init__.py +0 -0
- cursorfleet/py.typed +0 -0
- cursorfleet/safeexe.py +70 -0
- cursorfleet/schema_gen.py +73 -0
- cursorfleet/state/__init__.py +0 -0
- cursorfleet/state/artifact_scan.py +309 -0
- cursorfleet/state/context.py +38 -0
- cursorfleet/state/event_store.py +227 -0
- cursorfleet/state/indexer.py +443 -0
- cursorfleet/state/lock.py +74 -0
- cursorfleet/state/models.py +268 -0
- cursorfleet/state/reducer.py +588 -0
- cursorfleet/state/retention.py +218 -0
- cursorfleet/state/run_builder.py +136 -0
- cursorfleet/state/run_model.py +43 -0
- cursorfleet/state/run_store.py +95 -0
- cursorfleet/state/runtime.py +645 -0
- cursorfleet/state/spool.py +186 -0
- cursorfleet/state/spool_read.py +242 -0
- cursorfleet/state/status_doc.py +293 -0
- cursorfleet/tui/__init__.py +0 -0
- cursorfleet/tui/app.py +581 -0
- cursorfleet/tui/data.py +545 -0
- cursorfleet/tui/filters.py +82 -0
- cursorfleet/tui/gates.py +255 -0
- cursorfleet/tui/help.py +101 -0
- cursorfleet/tui/layout.py +26 -0
- cursorfleet/tui/theme.py +176 -0
- cursorfleet/tui/views.py +1077 -0
- cursorfleet/tui/watch.py +33 -0
- cursorfleet/workflow/__init__.py +0 -0
- cursorfleet/workflow/artifacts.py +148 -0
- cursorfleet/workflow/frontmatter.py +232 -0
- cursorfleet-0.1.0.dist-info/METADATA +161 -0
- cursorfleet-0.1.0.dist-info/RECORD +101 -0
- cursorfleet-0.1.0.dist-info/WHEEL +4 -0
- cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|