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
cursorfleet/tui/data.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""Data layer for the TUI: everything the screens show, gathered without Textual.
|
|
2
|
+
|
|
3
|
+
Sources (all read-only, no network):
|
|
4
|
+
|
|
5
|
+
- the SQLite projection, kept fresh by tailing the spool with the single-writer indexer
|
|
6
|
+
(:class:`~cursorfleet.state.indexer.Indexer`); events for the timeline come from the
|
|
7
|
+
projection's ``events`` table;
|
|
8
|
+
- the read-only git collector (never fetches, never mutates);
|
|
9
|
+
- the artifact scanner over ``.cursorfleet/work`` (SELF-REPORTED declarations).
|
|
10
|
+
|
|
11
|
+
:meth:`DataSource.load` is blocking and meant for a worker thread. It never raises: every
|
|
12
|
+
failure becomes a line in :attr:`FleetData.problems` so a corrupt database, a damaged spool
|
|
13
|
+
or a missing repository degrades the display instead of crashing it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from cursorfleet.adapters.cursor.kit_probe import HooksKitState, probe_hooks_kit
|
|
25
|
+
from cursorfleet.config.io import loads_config
|
|
26
|
+
from cursorfleet.events.kinds import EventKind
|
|
27
|
+
from cursorfleet.events.models import Event
|
|
28
|
+
from cursorfleet.git.collector import (
|
|
29
|
+
DEFAULT_STALE_AFTER_HOURS,
|
|
30
|
+
GitSnapshot,
|
|
31
|
+
WorktreeSnapshot,
|
|
32
|
+
collect,
|
|
33
|
+
)
|
|
34
|
+
from cursorfleet.git.runner import GitError
|
|
35
|
+
from cursorfleet.state.artifact_scan import (
|
|
36
|
+
DEFAULT_WORK_DIR,
|
|
37
|
+
ArtifactRecord,
|
|
38
|
+
ArtifactScan,
|
|
39
|
+
ArtifactScanner,
|
|
40
|
+
)
|
|
41
|
+
from cursorfleet.state.context import RepoContext, resolve_repo
|
|
42
|
+
from cursorfleet.state.event_store import (
|
|
43
|
+
EventFilter,
|
|
44
|
+
EventPage,
|
|
45
|
+
matches_filter,
|
|
46
|
+
query_events,
|
|
47
|
+
)
|
|
48
|
+
from cursorfleet.state.indexer import Indexer, replay_session_state
|
|
49
|
+
from cursorfleet.state.models import AgentAcc, AgentView, FleetView, Lane, SessionAcc, SessionView
|
|
50
|
+
from cursorfleet.state.reducer import DEFAULT_STALE_AFTER_S, MAIN, reduce_events, snapshot
|
|
51
|
+
from cursorfleet.state.run_builder import build_active_run, pick_primary_task
|
|
52
|
+
from cursorfleet.state.run_model import RunSnapshot
|
|
53
|
+
from cursorfleet.state.run_store import RunRecord, list_runs
|
|
54
|
+
from cursorfleet.state.spool_read import Corruption, list_spool_files
|
|
55
|
+
from cursorfleet.state.status_doc import worktree_activity
|
|
56
|
+
from cursorfleet.tui import gates as gate_logic
|
|
57
|
+
|
|
58
|
+
MAX_WORKTREE_ROOTS = 25
|
|
59
|
+
RECENT_EVENTS = 40
|
|
60
|
+
GATE_EVIDENCE_EVENTS = 400
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Card:
|
|
65
|
+
"""One item in the workflow overview."""
|
|
66
|
+
|
|
67
|
+
key: str
|
|
68
|
+
kind: str # "agent" | "declared" | "worktree"
|
|
69
|
+
lane: str
|
|
70
|
+
title: str
|
|
71
|
+
detail: str
|
|
72
|
+
basis: str # observed_activity | lifecycle_only | self_reported | derived | none
|
|
73
|
+
last_ts: datetime | None
|
|
74
|
+
blockers: int = 0
|
|
75
|
+
declared_blockers: int = 0
|
|
76
|
+
stale: bool = False
|
|
77
|
+
session_id: str | None = None
|
|
78
|
+
agent_key: str | None = None
|
|
79
|
+
worktree_path: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class AgentBundle:
|
|
84
|
+
"""Everything known about one agent card (a hook-observed agent or a declared role)."""
|
|
85
|
+
|
|
86
|
+
key: str
|
|
87
|
+
session: SessionAcc | None
|
|
88
|
+
acc: AgentAcc
|
|
89
|
+
view: AgentView | None # None for declared-only roles (no live telemetry)
|
|
90
|
+
session_view: SessionView | None
|
|
91
|
+
records: list[ArtifactRecord] = field(default_factory=list) # self-reported, by role
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def role(self) -> str | None:
|
|
95
|
+
return self.acc.role
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class GateBoard:
|
|
100
|
+
"""Gate statuses per worktree id (``None`` = evidence tied to no known worktree)."""
|
|
101
|
+
|
|
102
|
+
by_worktree: dict[str | None, list[gate_logic.GateStatus]] = field(default_factory=dict)
|
|
103
|
+
evidence: list[gate_logic.GateEvidence] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class FleetData:
|
|
108
|
+
now: datetime
|
|
109
|
+
repo_root: str | None = None
|
|
110
|
+
runtime_dir: str | None = None
|
|
111
|
+
db_path: str | None = None
|
|
112
|
+
sessions: dict[str, SessionAcc] = field(default_factory=dict)
|
|
113
|
+
fleet: FleetView | None = None
|
|
114
|
+
git: GitSnapshot | None = None
|
|
115
|
+
git_enabled: bool = True
|
|
116
|
+
artifacts: ArtifactScan = field(default_factory=ArtifactScan)
|
|
117
|
+
cards: list[Card] = field(default_factory=list)
|
|
118
|
+
bundles: dict[str, AgentBundle] = field(default_factory=dict)
|
|
119
|
+
gates: GateBoard = field(default_factory=GateBoard)
|
|
120
|
+
corruption: Corruption = field(default_factory=Corruption)
|
|
121
|
+
spool_files: int = 0
|
|
122
|
+
telemetry: str = "none" # "hooks" | "none"
|
|
123
|
+
hooks_kit: HooksKitState = HooksKitState.MISSING
|
|
124
|
+
problems: list[str] = field(default_factory=list)
|
|
125
|
+
recent: EventPage = field(default_factory=EventPage)
|
|
126
|
+
timeline: EventPage = field(default_factory=EventPage)
|
|
127
|
+
timeline_errors: tuple[str, ...] = ()
|
|
128
|
+
primary_run: RunSnapshot | None = None
|
|
129
|
+
run_snapshots: list[RunSnapshot] = field(default_factory=list)
|
|
130
|
+
run_records: list[RunRecord] = field(default_factory=list)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def is_empty(self) -> bool:
|
|
134
|
+
return not self.cards and not self.artifacts.records
|
|
135
|
+
|
|
136
|
+
def worktree_by_id(self, worktree_id: str | None) -> WorktreeSnapshot | None:
|
|
137
|
+
if self.git is None or worktree_id is None:
|
|
138
|
+
return None
|
|
139
|
+
return next((w for w in self.git.worktrees if w.worktree_id == worktree_id), None)
|
|
140
|
+
|
|
141
|
+
def blocked_count(self) -> int:
|
|
142
|
+
return sum(1 for c in self.cards if c.blockers > 0)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _work_dir(top_level: str | None) -> str:
|
|
146
|
+
"""The configured work dir (``.cursorfleet/config.toml``), or the default."""
|
|
147
|
+
if top_level is None:
|
|
148
|
+
return DEFAULT_WORK_DIR
|
|
149
|
+
config = Path(top_level, ".cursorfleet", "config.toml")
|
|
150
|
+
try:
|
|
151
|
+
if config.is_file() and config.stat().st_size < 256 * 1024:
|
|
152
|
+
return loads_config(config.read_text(encoding="utf-8")).work.dir
|
|
153
|
+
except (OSError, ValueError):
|
|
154
|
+
pass
|
|
155
|
+
return DEFAULT_WORK_DIR
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _owners(snap: WorktreeSnapshot, fleet: FleetView) -> list[str]:
|
|
159
|
+
out: list[str] = []
|
|
160
|
+
for session in fleet.sessions:
|
|
161
|
+
for agent in session.agents:
|
|
162
|
+
if snap.worktree_id and agent.worktree_id == snap.worktree_id:
|
|
163
|
+
out.append(f"{session.session_id}/{agent.key}")
|
|
164
|
+
return sorted(out)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class DataSource:
|
|
168
|
+
"""Stateful loader (caches the projection, git snapshot and artifact scan)."""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
repo: str | os.PathLike[str],
|
|
173
|
+
*,
|
|
174
|
+
with_git: bool = True,
|
|
175
|
+
clock: Callable[[], datetime] | None = None,
|
|
176
|
+
stale_after_s: int = DEFAULT_STALE_AFTER_S,
|
|
177
|
+
git_every: int = 5,
|
|
178
|
+
) -> None:
|
|
179
|
+
self._repo = os.fspath(repo)
|
|
180
|
+
self._with_git = with_git
|
|
181
|
+
self._clock = clock or (lambda: datetime.now(UTC))
|
|
182
|
+
self._stale_after_s = stale_after_s
|
|
183
|
+
self._git_every = max(1, git_every)
|
|
184
|
+
self._ctx: RepoContext | None = None
|
|
185
|
+
self._ctx_error: str | None = None
|
|
186
|
+
self._indexer: Indexer | None = None
|
|
187
|
+
self._sessions: dict[str, SessionAcc] = {}
|
|
188
|
+
self._corruption = Corruption()
|
|
189
|
+
self._git: GitSnapshot | None = None
|
|
190
|
+
self._ticks = 0
|
|
191
|
+
self._loaded = False
|
|
192
|
+
self._scanner: ArtifactScanner | None = None
|
|
193
|
+
self._evidence: list[gate_logic.GateEvidence] = []
|
|
194
|
+
self._evidence_dirty = True
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------ public
|
|
197
|
+
|
|
198
|
+
def now(self) -> datetime:
|
|
199
|
+
return self._clock().astimezone(UTC)
|
|
200
|
+
|
|
201
|
+
def load(
|
|
202
|
+
self,
|
|
203
|
+
flt: EventFilter | None = None,
|
|
204
|
+
*,
|
|
205
|
+
limit: int = 200,
|
|
206
|
+
force_git: bool = False,
|
|
207
|
+
) -> FleetData:
|
|
208
|
+
"""Blocking snapshot for the screens. Never raises."""
|
|
209
|
+
now = self.now()
|
|
210
|
+
data = FleetData(now=now, git_enabled=self._with_git)
|
|
211
|
+
try:
|
|
212
|
+
self._load(data, flt, limit, force_git)
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
data.problems.append(f"data load failed ({type(exc).__name__}); showing what is known")
|
|
215
|
+
return data
|
|
216
|
+
|
|
217
|
+
def recent_tools(self, session_id: str, agent_key: str, limit: int = 6) -> list[Event]:
|
|
218
|
+
"""Newest tool events for one agent, for the detail pane."""
|
|
219
|
+
if self._ctx is None:
|
|
220
|
+
return []
|
|
221
|
+
page = query_events(
|
|
222
|
+
self._ctx.paths.db, EventFilter(session=session_id, kind="tool.started"), limit=200
|
|
223
|
+
)
|
|
224
|
+
wanted = None if agent_key == MAIN else agent_key
|
|
225
|
+
return [e for e in page.events if e.agent_id == wanted][:limit]
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------ internals
|
|
228
|
+
|
|
229
|
+
def _resolve(self) -> RepoContext | None:
|
|
230
|
+
if self._ctx is not None:
|
|
231
|
+
return self._ctx
|
|
232
|
+
try:
|
|
233
|
+
self._ctx = resolve_repo(self._repo)
|
|
234
|
+
except GitError as exc:
|
|
235
|
+
self._ctx_error = type(exc).__name__
|
|
236
|
+
return None
|
|
237
|
+
self._indexer = Indexer(self._ctx.paths)
|
|
238
|
+
return self._ctx
|
|
239
|
+
|
|
240
|
+
def _load(self, data: FleetData, flt: EventFilter | None, limit: int, force_git: bool) -> None:
|
|
241
|
+
ctx = self._resolve()
|
|
242
|
+
if ctx is None:
|
|
243
|
+
data.problems.append("not a git repository (or git is unavailable)")
|
|
244
|
+
data.fleet = snapshot({}, now=data.now, stale_after_s=self._stale_after_s)
|
|
245
|
+
return
|
|
246
|
+
data.repo_root = ctx.top_level or ctx.common_dir
|
|
247
|
+
data.runtime_dir = ctx.paths.root
|
|
248
|
+
data.db_path = ctx.paths.db
|
|
249
|
+
data.hooks_kit = probe_hooks_kit(data.repo_root).state
|
|
250
|
+
self._refresh_projection(data)
|
|
251
|
+
data.sessions = self._sessions
|
|
252
|
+
data.corruption = self._corruption
|
|
253
|
+
data.telemetry = "hooks" if self._sessions else "none"
|
|
254
|
+
fleet = snapshot(self._sessions, now=data.now, stale_after_s=self._stale_after_s)
|
|
255
|
+
data.fleet = fleet
|
|
256
|
+
self._refresh_git(data, force_git)
|
|
257
|
+
self._scan_artifacts(data, ctx)
|
|
258
|
+
data.recent = query_events(ctx.paths.db, limit=RECENT_EVENTS)
|
|
259
|
+
data.timeline = query_events(ctx.paths.db, flt, limit=limit)
|
|
260
|
+
self._merge_artifact_events(data, flt, limit)
|
|
261
|
+
self._refresh_evidence(data)
|
|
262
|
+
_build_cards(data)
|
|
263
|
+
self._build_gates(data)
|
|
264
|
+
self._build_runs(data, ctx)
|
|
265
|
+
self._add_problems(data)
|
|
266
|
+
|
|
267
|
+
def _build_runs(self, data: FleetData, ctx: RepoContext) -> None:
|
|
268
|
+
if data.fleet is None:
|
|
269
|
+
return
|
|
270
|
+
session_views = {s.session_id: s for s in data.fleet.sessions}
|
|
271
|
+
records = data.artifacts.records
|
|
272
|
+
all_records = list_runs(ctx.paths.root)
|
|
273
|
+
data.run_records = all_records
|
|
274
|
+
active_meta = sorted(
|
|
275
|
+
(r for r in all_records if not r.archived),
|
|
276
|
+
key=lambda r: r.created_at,
|
|
277
|
+
reverse=True,
|
|
278
|
+
)
|
|
279
|
+
snapshots: list[RunSnapshot] = []
|
|
280
|
+
for meta in active_meta:
|
|
281
|
+
snapshots.append(
|
|
282
|
+
build_active_run(
|
|
283
|
+
task_slug=meta.task_slug,
|
|
284
|
+
run_id=meta.run_id,
|
|
285
|
+
sessions=data.sessions,
|
|
286
|
+
session_views=session_views,
|
|
287
|
+
records=records,
|
|
288
|
+
linked_session_ids=frozenset(meta.session_ids),
|
|
289
|
+
now=data.now,
|
|
290
|
+
stale_after_s=self._stale_after_s,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
data.run_snapshots = snapshots
|
|
294
|
+
if snapshots:
|
|
295
|
+
data.primary_run = snapshots[0]
|
|
296
|
+
return
|
|
297
|
+
task = pick_primary_task(records, now=data.now)
|
|
298
|
+
if task is None and not session_views:
|
|
299
|
+
data.primary_run = None
|
|
300
|
+
return
|
|
301
|
+
slug = task or "unscoped"
|
|
302
|
+
data.primary_run = build_active_run(
|
|
303
|
+
task_slug=slug,
|
|
304
|
+
run_id=f"inferred:{slug}",
|
|
305
|
+
sessions=data.sessions,
|
|
306
|
+
session_views=session_views,
|
|
307
|
+
records=records,
|
|
308
|
+
linked_session_ids=frozenset(),
|
|
309
|
+
now=data.now,
|
|
310
|
+
stale_after_s=self._stale_after_s,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def _refresh_projection(self, data: FleetData) -> None:
|
|
314
|
+
assert self._indexer is not None # noqa: S101 - set together with the context
|
|
315
|
+
assert self._ctx is not None # noqa: S101
|
|
316
|
+
try:
|
|
317
|
+
data.spool_files = len(list_spool_files(self._ctx.paths))
|
|
318
|
+
except OSError:
|
|
319
|
+
data.spool_files = 0
|
|
320
|
+
if data.spool_files == 0 and not os.path.exists(self._ctx.paths.db):
|
|
321
|
+
return
|
|
322
|
+
try:
|
|
323
|
+
stats = self._indexer.try_sync()
|
|
324
|
+
except Exception as exc:
|
|
325
|
+
data.problems.append(
|
|
326
|
+
f"indexing failed ({type(exc).__name__}); showing last known state"
|
|
327
|
+
)
|
|
328
|
+
stats = None
|
|
329
|
+
changed = (
|
|
330
|
+
not self._loaded
|
|
331
|
+
or stats is None
|
|
332
|
+
or stats.events_new > 0
|
|
333
|
+
or stats.sessions_pruned > 0
|
|
334
|
+
or stats.db_recovered
|
|
335
|
+
)
|
|
336
|
+
if stats is not None and stats.db_recovered:
|
|
337
|
+
data.problems.append("the projection was damaged: it was moved aside and rebuilt")
|
|
338
|
+
if not changed:
|
|
339
|
+
return
|
|
340
|
+
sessions = self._indexer.load_sessions()
|
|
341
|
+
corruption = self._indexer.corruption()
|
|
342
|
+
if not sessions and data.spool_files:
|
|
343
|
+
try:
|
|
344
|
+
sessions, corruption, _n = replay_session_state(self._ctx.paths)
|
|
345
|
+
except Exception as exc:
|
|
346
|
+
data.problems.append(f"spool replay failed ({type(exc).__name__})")
|
|
347
|
+
self._sessions = sessions
|
|
348
|
+
self._corruption = corruption
|
|
349
|
+
self._loaded = True
|
|
350
|
+
self._evidence_dirty = True
|
|
351
|
+
|
|
352
|
+
def _refresh_git(self, data: FleetData, force: bool) -> None:
|
|
353
|
+
if not self._with_git:
|
|
354
|
+
return
|
|
355
|
+
due = force or self._git is None or self._ticks % self._git_every == 0
|
|
356
|
+
self._ticks += 1
|
|
357
|
+
if due and data.repo_root is not None:
|
|
358
|
+
self._git = collect(
|
|
359
|
+
data.repo_root,
|
|
360
|
+
now=data.now,
|
|
361
|
+
stale_after_hours=DEFAULT_STALE_AFTER_HOURS,
|
|
362
|
+
activity=worktree_activity(self._sessions),
|
|
363
|
+
)
|
|
364
|
+
self._evidence_dirty = True
|
|
365
|
+
data.git = self._git
|
|
366
|
+
|
|
367
|
+
def _scan_artifacts(self, data: FleetData, ctx: RepoContext) -> None:
|
|
368
|
+
if self._scanner is None:
|
|
369
|
+
self._scanner = ArtifactScanner(_work_dir(ctx.top_level))
|
|
370
|
+
roots: list[str] = [ctx.top_level] if ctx.top_level else []
|
|
371
|
+
if data.git is not None:
|
|
372
|
+
for wt in data.git.worktrees:
|
|
373
|
+
if wt.real_path and wt.exists and not wt.bare:
|
|
374
|
+
roots.append(wt.real_path)
|
|
375
|
+
data.artifacts = self._scanner.scan(roots[:MAX_WORKTREE_ROOTS])
|
|
376
|
+
|
|
377
|
+
@staticmethod
|
|
378
|
+
def _merge_artifact_events(data: FleetData, flt: EventFilter | None, limit: int) -> None:
|
|
379
|
+
"""Self-reported artifact events are not in the database; merge them by time."""
|
|
380
|
+
extra = [e for e in data.artifacts.events if matches_filter(e, flt or EventFilter())]
|
|
381
|
+
if not extra:
|
|
382
|
+
return
|
|
383
|
+
merged = sorted(
|
|
384
|
+
[*data.timeline.events, *extra], key=lambda e: (e.ts, e.event_id), reverse=True
|
|
385
|
+
)
|
|
386
|
+
data.timeline.events = merged[:limit]
|
|
387
|
+
data.timeline.total += len(extra)
|
|
388
|
+
|
|
389
|
+
def _refresh_evidence(self, data: FleetData) -> None:
|
|
390
|
+
if not self._evidence_dirty or self._ctx is None:
|
|
391
|
+
return
|
|
392
|
+
events: list[Event] = []
|
|
393
|
+
for kind in (
|
|
394
|
+
EventKind.VERIFICATION_OBSERVED,
|
|
395
|
+
EventKind.TEST_COMPLETED,
|
|
396
|
+
EventKind.GATE_CHANGED,
|
|
397
|
+
):
|
|
398
|
+
page = query_events(
|
|
399
|
+
self._ctx.paths.db, EventFilter(kind=kind.value), limit=GATE_EVIDENCE_EVENTS
|
|
400
|
+
)
|
|
401
|
+
events.extend(page.events)
|
|
402
|
+
self._evidence = gate_logic.evidence_from_events(events)
|
|
403
|
+
self._evidence_dirty = False
|
|
404
|
+
|
|
405
|
+
def _build_gates(self, data: FleetData) -> None:
|
|
406
|
+
heads: dict[str | None, str | None] = {}
|
|
407
|
+
if data.git is not None:
|
|
408
|
+
for wt in data.git.worktrees:
|
|
409
|
+
if wt.worktree_id is not None:
|
|
410
|
+
heads[wt.worktree_id] = wt.head
|
|
411
|
+
# Evidence for worktrees git does not list (deleted, or git disabled) is judged with
|
|
412
|
+
# an unknown HEAD, which keeps it "unknown" rather than pretending it is current.
|
|
413
|
+
for item in self._evidence:
|
|
414
|
+
heads.setdefault(item.worktree_id, None)
|
|
415
|
+
board = GateBoard(evidence=list(self._evidence))
|
|
416
|
+
board.by_worktree = gate_logic.evaluate_all(self._evidence, heads)
|
|
417
|
+
data.gates = board
|
|
418
|
+
|
|
419
|
+
def _add_problems(self, data: FleetData) -> None:
|
|
420
|
+
if data.corruption.total:
|
|
421
|
+
data.problems.append(
|
|
422
|
+
f"{data.corruption.total} corrupt or skipped spool line(s) were ignored"
|
|
423
|
+
)
|
|
424
|
+
if data.artifacts.issues:
|
|
425
|
+
data.problems.append(
|
|
426
|
+
f"{len(data.artifacts.issues)} invalid artifact file(s): see the evidence view"
|
|
427
|
+
)
|
|
428
|
+
if data.git_enabled and data.git is not None and not data.git.available:
|
|
429
|
+
data.problems.append(f"git data unavailable: {data.git.error}")
|
|
430
|
+
if data.timeline.error and (data.spool_files or data.sessions):
|
|
431
|
+
data.problems.append(data.timeline.error)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ---------------------------------------------------------------- cards
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _declared_for_role(records: list[ArtifactRecord], role: str) -> list[ArtifactRecord]:
|
|
438
|
+
"""Task-scoped declared records for a hook-observed role (avoid cross-task bleed)."""
|
|
439
|
+
matched = [r for r in records if r.author_role == role]
|
|
440
|
+
tasks = {r.task for r in matched}
|
|
441
|
+
if len(tasks) == 1:
|
|
442
|
+
return matched
|
|
443
|
+
return []
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _hook_cards(data: FleetData, records_by_role: dict[str, list[ArtifactRecord]]) -> None:
|
|
447
|
+
fleet = data.fleet
|
|
448
|
+
assert fleet is not None # noqa: S101
|
|
449
|
+
for sview in fleet.sessions:
|
|
450
|
+
acc_session = data.sessions[sview.session_id]
|
|
451
|
+
for aview in sview.agents:
|
|
452
|
+
acc = acc_session.agents[aview.key]
|
|
453
|
+
all_records = data.artifacts.records
|
|
454
|
+
records = _declared_for_role(all_records, acc.role) if acc.role else []
|
|
455
|
+
declared_blockers = sum(1 for r in records if r.kind == "blocker.raised")
|
|
456
|
+
observed_blockers = acc.blockers or (1 if aview.lane == Lane.BLOCKED.value else 0)
|
|
457
|
+
key = f"agent:{sview.session_id}:{aview.key}"
|
|
458
|
+
title = acc.role or ("main" if aview.key == MAIN else aview.key)
|
|
459
|
+
card = Card(
|
|
460
|
+
key=key,
|
|
461
|
+
kind="agent",
|
|
462
|
+
lane=aview.lane,
|
|
463
|
+
title=title,
|
|
464
|
+
detail=f"session {sview.session_id}",
|
|
465
|
+
basis=aview.lane_basis,
|
|
466
|
+
last_ts=aview.last_ts,
|
|
467
|
+
blockers=observed_blockers + declared_blockers,
|
|
468
|
+
declared_blockers=declared_blockers,
|
|
469
|
+
stale=aview.stale,
|
|
470
|
+
session_id=sview.session_id,
|
|
471
|
+
agent_key=aview.key,
|
|
472
|
+
)
|
|
473
|
+
data.cards.append(card)
|
|
474
|
+
data.bundles[key] = AgentBundle(key, acc_session, acc, aview, sview, records)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _declared_cards(data: FleetData, matched_roles: set[str]) -> None:
|
|
478
|
+
"""Roles that only exist as artifact authors (no hook telemetry matches their role)."""
|
|
479
|
+
sessions = reduce_events(data.artifacts.events)
|
|
480
|
+
for session_id, session in sessions.items():
|
|
481
|
+
task = session_id.split(":", 1)[1]
|
|
482
|
+
for agent_key, acc in sorted(session.agents.items()):
|
|
483
|
+
role = acc.role or agent_key
|
|
484
|
+
if role in matched_roles:
|
|
485
|
+
continue
|
|
486
|
+
records = [
|
|
487
|
+
r for r in data.artifacts.records if r.task == task and r.author_role == role
|
|
488
|
+
]
|
|
489
|
+
key = f"declared:{task}:{role}"
|
|
490
|
+
blockers = sum(1 for r in records if r.kind == "blocker.raised")
|
|
491
|
+
data.cards.append(
|
|
492
|
+
Card(
|
|
493
|
+
key=key,
|
|
494
|
+
kind="declared",
|
|
495
|
+
lane=acc.lane.value,
|
|
496
|
+
title=role,
|
|
497
|
+
detail=f"task {task}",
|
|
498
|
+
basis="self_reported",
|
|
499
|
+
last_ts=acc.last_ts,
|
|
500
|
+
blockers=blockers,
|
|
501
|
+
declared_blockers=blockers,
|
|
502
|
+
agent_key=agent_key,
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
data.bundles[key] = AgentBundle(key, session, acc, None, None, records)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _worktree_cards(data: FleetData) -> None:
|
|
509
|
+
if data.git is None:
|
|
510
|
+
return
|
|
511
|
+
assert data.fleet is not None # noqa: S101
|
|
512
|
+
for wt in data.git.worktrees:
|
|
513
|
+
if _owners(wt, data.fleet):
|
|
514
|
+
continue
|
|
515
|
+
name = Path(wt.path).name or wt.path
|
|
516
|
+
branch = wt.branch or ("detached" if wt.detached else "?")
|
|
517
|
+
if data.hooks_kit == HooksKitState.INSTALLED:
|
|
518
|
+
tail = "no telemetry yet (open Cursor here)"
|
|
519
|
+
else:
|
|
520
|
+
tail = "no hook telemetry"
|
|
521
|
+
data.cards.append(
|
|
522
|
+
Card(
|
|
523
|
+
key=f"wt:{wt.path}",
|
|
524
|
+
kind="worktree",
|
|
525
|
+
lane=Lane.UNKNOWN.value,
|
|
526
|
+
title=name,
|
|
527
|
+
detail=f"branch {branch}, {tail}",
|
|
528
|
+
basis="none",
|
|
529
|
+
last_ts=wt.last_commit_ts,
|
|
530
|
+
worktree_path=wt.path,
|
|
531
|
+
)
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _build_cards(data: FleetData) -> None:
|
|
536
|
+
records_by_role: dict[str, list[ArtifactRecord]] = {}
|
|
537
|
+
for record in data.artifacts.records:
|
|
538
|
+
records_by_role.setdefault(record.author_role, []).append(record)
|
|
539
|
+
data.cards = []
|
|
540
|
+
data.bundles = {}
|
|
541
|
+
if data.fleet is not None:
|
|
542
|
+
_hook_cards(data, records_by_role)
|
|
543
|
+
matched = {b.acc.role for b in data.bundles.values() if b.acc.role}
|
|
544
|
+
_declared_cards(data, matched)
|
|
545
|
+
_worktree_cards(data)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Parsing of the ``/`` filter box.
|
|
2
|
+
|
|
3
|
+
Timeline syntax: whitespace-separated ``key:value`` terms plus free words, for example
|
|
4
|
+
``agent:reviewer kind:tool risk:high file:src/api since:15m shell``. Keys: ``agent``,
|
|
5
|
+
``session``, ``kind``, ``risk``, ``source`` (``observed``/``self_reported``/``derived``),
|
|
6
|
+
``attribution``, ``file``, ``since``, ``until`` (relative ``90s``/``15m``/``2h``/``1d`` or an
|
|
7
|
+
ISO 8601 time; naive times are UTC). Free words must all match. Other views treat the whole
|
|
8
|
+
text as a case-insensitive substring filter.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import UTC, datetime, timedelta
|
|
16
|
+
|
|
17
|
+
from cursorfleet.state.event_store import EventFilter
|
|
18
|
+
|
|
19
|
+
_RELATIVE = re.compile(r"^(\d{1,6})([smhd])$")
|
|
20
|
+
_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
21
|
+
_KEYS = frozenset(
|
|
22
|
+
{"agent", "session", "kind", "risk", "source", "attribution", "file", "since", "until"}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ParsedFilter:
|
|
28
|
+
event: EventFilter = field(default_factory=EventFilter)
|
|
29
|
+
errors: tuple[str, ...] = ()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_time(text: str, now: datetime) -> datetime | None:
|
|
33
|
+
match = _RELATIVE.match(text.strip().lower())
|
|
34
|
+
if match:
|
|
35
|
+
return now - timedelta(seconds=int(match.group(1)) * _UNITS[match.group(2)])
|
|
36
|
+
try:
|
|
37
|
+
parsed = datetime.fromisoformat(text.strip().replace("Z", "+00:00"))
|
|
38
|
+
except ValueError:
|
|
39
|
+
return None
|
|
40
|
+
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_timeline_filter(text: str, now: datetime) -> ParsedFilter:
|
|
44
|
+
values: dict[str, str] = {}
|
|
45
|
+
words: list[str] = []
|
|
46
|
+
errors: list[str] = []
|
|
47
|
+
for token in text.split():
|
|
48
|
+
key, sep, value = token.partition(":")
|
|
49
|
+
if sep and key.lower() in _KEYS and value:
|
|
50
|
+
values[key.lower()] = value
|
|
51
|
+
else:
|
|
52
|
+
words.append(token)
|
|
53
|
+
since = until = None
|
|
54
|
+
if "since" in values:
|
|
55
|
+
since = parse_time(values.pop("since"), now)
|
|
56
|
+
if since is None:
|
|
57
|
+
errors.append("since: expects 15m, 2h, 1d or an ISO time")
|
|
58
|
+
if "until" in values:
|
|
59
|
+
until = parse_time(values.pop("until"), now)
|
|
60
|
+
if until is None:
|
|
61
|
+
errors.append("until: expects 15m, 2h, 1d or an ISO time")
|
|
62
|
+
return ParsedFilter(
|
|
63
|
+
EventFilter(
|
|
64
|
+
agent=values.get("agent"),
|
|
65
|
+
session=values.get("session"),
|
|
66
|
+
kind=values.get("kind"),
|
|
67
|
+
risk=values.get("risk"),
|
|
68
|
+
source=values.get("source"),
|
|
69
|
+
attribution=values.get("attribution"),
|
|
70
|
+
file=values.get("file"),
|
|
71
|
+
text=" ".join(words) or None,
|
|
72
|
+
since=since,
|
|
73
|
+
until=until,
|
|
74
|
+
),
|
|
75
|
+
tuple(errors),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def matches_text(haystack: str, text: str) -> bool:
|
|
80
|
+
"""Case-insensitive: every whitespace-separated word of ``text`` occurs in ``haystack``."""
|
|
81
|
+
lowered = haystack.lower()
|
|
82
|
+
return all(word in lowered for word in text.lower().split())
|