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,186 @@
|
|
|
1
|
+
"""Spool line codec and the hot-path appender. Stdlib only, no pathlib/dataclasses/typing.
|
|
2
|
+
|
|
3
|
+
Line format (ADR 0002): ``<crc32 hex8> <space> <event json> "\\n"``, at most 8 KiB,
|
|
4
|
+
written with a single ``O_APPEND`` ``write``. One file per writer:
|
|
5
|
+
``spool/<session>/<writer>.jsonl``. When a writer file reaches ``ROTATE_BYTES`` it is
|
|
6
|
+
renamed to ``<writer>.<ms>-<pid>.jsonl`` and a fresh file is started.
|
|
7
|
+
|
|
8
|
+
Retention on the hot path (docs/privacy.md): when a session directory reaches its byte
|
|
9
|
+
cap the appender writes a ``.capped`` marker and stops appending for that session
|
|
10
|
+
instead of rotating forever; the indexer / ``events purge`` prune and clear the marker.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import zlib
|
|
17
|
+
|
|
18
|
+
from cursorfleet.events.ids import fs_name_for
|
|
19
|
+
from cursorfleet.state.runtime import (
|
|
20
|
+
FILE_MODE,
|
|
21
|
+
RuntimePaths,
|
|
22
|
+
ensure_runtime_root,
|
|
23
|
+
mkdir_private,
|
|
24
|
+
untrusted_reason,
|
|
25
|
+
write_private_file,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
MAX_LINE_BYTES = 8192
|
|
29
|
+
ROTATE_BYTES = 4 * 1024 * 1024
|
|
30
|
+
DEFAULT_MAX_SESSION_BYTES = 50 * 1024 * 1024
|
|
31
|
+
CAPPED_MARKER = ".capped"
|
|
32
|
+
SPOOL_SUFFIX = ".jsonl"
|
|
33
|
+
|
|
34
|
+
_O_FLAGS = (
|
|
35
|
+
os.O_WRONLY
|
|
36
|
+
| os.O_APPEND
|
|
37
|
+
| os.O_CREAT
|
|
38
|
+
| getattr(os, "O_BINARY", 0)
|
|
39
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
40
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
41
|
+
| getattr(os, "O_NONBLOCK", 0) # a FIFO planted at the path fails (ENXIO) instead of hanging
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def encode_line(event_json: str) -> bytes:
|
|
46
|
+
"""Return the on-disk bytes for one event. Raises ``ValueError`` if over the line cap."""
|
|
47
|
+
body = event_json.encode("utf-8")
|
|
48
|
+
line = b"%08x %s\n" % (zlib.crc32(body) & 0xFFFFFFFF, body)
|
|
49
|
+
if len(line) > MAX_LINE_BYTES:
|
|
50
|
+
msg = "spool line exceeds the 8 KiB cap"
|
|
51
|
+
raise ValueError(msg)
|
|
52
|
+
return line
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def decode_line(line: bytes) -> str | None:
|
|
56
|
+
"""Return the event JSON text of one complete line (without ``\\n``), or ``None`` if bad.
|
|
57
|
+
|
|
58
|
+
"Bad" = wrong shape, non-hex CRC, CRC mismatch or non-UTF-8 payload.
|
|
59
|
+
"""
|
|
60
|
+
if len(line) < 10 or line[8:9] != b" ":
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
expected = int(line[:8], 16)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return None
|
|
66
|
+
body = line[9:]
|
|
67
|
+
if (zlib.crc32(body) & 0xFFFFFFFF) != expected:
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
return body.decode("utf-8")
|
|
71
|
+
except UnicodeDecodeError:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def session_dir(paths: RuntimePaths, session_id: str) -> str:
|
|
76
|
+
return os.path.join(paths.spool, fs_name_for(session_id))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def writer_file(paths: RuntimePaths, session_id: str, writer: str) -> str:
|
|
80
|
+
return os.path.join(session_dir(paths, session_id), fs_name_for(writer) + SPOOL_SUFFIX)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def session_bytes(directory: str) -> int:
|
|
84
|
+
"""Total size of the ``.jsonl`` files in a session directory (0 if missing)."""
|
|
85
|
+
total = 0
|
|
86
|
+
try:
|
|
87
|
+
with os.scandir(directory) as entries:
|
|
88
|
+
for entry in entries:
|
|
89
|
+
if entry.name.endswith(SPOOL_SUFFIX):
|
|
90
|
+
try:
|
|
91
|
+
total += entry.stat(follow_symlinks=False).st_size
|
|
92
|
+
except OSError:
|
|
93
|
+
continue
|
|
94
|
+
except OSError:
|
|
95
|
+
return 0
|
|
96
|
+
return total
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _open_append(path: str) -> int:
|
|
100
|
+
return os.open(path, _O_FLAGS, FILE_MODE)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _cap_reached(directory: str, max_session_bytes: int) -> bool:
|
|
104
|
+
if session_bytes(directory) < max_session_bytes:
|
|
105
|
+
return False
|
|
106
|
+
try:
|
|
107
|
+
write_private_file(os.path.join(directory, CAPPED_MARKER), b"", exclusive=True)
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def append_event( # noqa: PLR0911, PLR0912
|
|
114
|
+
paths: RuntimePaths,
|
|
115
|
+
session_id: str,
|
|
116
|
+
writer: str,
|
|
117
|
+
event_json: str,
|
|
118
|
+
*,
|
|
119
|
+
now_ms: int,
|
|
120
|
+
rotate_bytes: int = ROTATE_BYTES,
|
|
121
|
+
max_session_bytes: int = DEFAULT_MAX_SESSION_BYTES,
|
|
122
|
+
) -> bool:
|
|
123
|
+
"""Append one event line. Returns False (never raises for I/O) if nothing was written.
|
|
124
|
+
|
|
125
|
+
Concurrency: the write is a single ``os.write`` on an ``O_APPEND`` descriptor, so
|
|
126
|
+
parallel hook processes sharing a writer file do not interleave within a line on
|
|
127
|
+
POSIX local filesystems (PROVISIONAL, ADR 0001 Q5). Readers tolerate tears anyway.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
data = encode_line(event_json)
|
|
131
|
+
except ValueError:
|
|
132
|
+
return False
|
|
133
|
+
directory = session_dir(paths, session_id)
|
|
134
|
+
path = os.path.join(directory, fs_name_for(writer) + SPOOL_SUFFIX)
|
|
135
|
+
try:
|
|
136
|
+
if untrusted_reason(paths.root) is not None:
|
|
137
|
+
return False # never write into a directory another user could have prepared
|
|
138
|
+
if os.path.lexists(os.path.join(directory, CAPPED_MARKER)):
|
|
139
|
+
return False
|
|
140
|
+
try:
|
|
141
|
+
fd = _open_append(path)
|
|
142
|
+
except FileNotFoundError:
|
|
143
|
+
ensure_runtime_root(paths)
|
|
144
|
+
mkdir_private(directory)
|
|
145
|
+
fd = _open_append(path)
|
|
146
|
+
try:
|
|
147
|
+
size = os.fstat(fd).st_size
|
|
148
|
+
if size == 0 and os.name == "posix":
|
|
149
|
+
fchmod = getattr(os, "fchmod", None)
|
|
150
|
+
if fchmod is not None:
|
|
151
|
+
fchmod(fd, FILE_MODE) # explicit, not umask-dependent
|
|
152
|
+
if size == 0 or size >= rotate_bytes:
|
|
153
|
+
if size >= rotate_bytes:
|
|
154
|
+
os.close(fd)
|
|
155
|
+
fd = -1
|
|
156
|
+
_rotate(path, now_ms)
|
|
157
|
+
fd = _open_append(path)
|
|
158
|
+
if os.name == "posix":
|
|
159
|
+
fchmod = getattr(os, "fchmod", None)
|
|
160
|
+
if fchmod is not None:
|
|
161
|
+
fchmod(fd, FILE_MODE)
|
|
162
|
+
if _cap_reached(directory, max_session_bytes):
|
|
163
|
+
return False
|
|
164
|
+
written = os.write(fd, data)
|
|
165
|
+
if written != len(data): # torn by ENOSPC/EINTR: terminate the fragment
|
|
166
|
+
os.write(fd, b"\n")
|
|
167
|
+
return False
|
|
168
|
+
return True
|
|
169
|
+
finally:
|
|
170
|
+
if fd >= 0:
|
|
171
|
+
os.close(fd)
|
|
172
|
+
except OSError:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _rotate(path: str, now_ms: int) -> None:
|
|
177
|
+
base = path[: -len(SPOOL_SUFFIX)]
|
|
178
|
+
target = f"{base}.{now_ms}-{os.getpid()}"
|
|
179
|
+
suffix = 0
|
|
180
|
+
while os.path.lexists(target + SPOOL_SUFFIX) and suffix < 1000: # never clobber a rotation
|
|
181
|
+
suffix += 1
|
|
182
|
+
target = f"{base}.{now_ms}-{os.getpid()}-{suffix}"
|
|
183
|
+
try:
|
|
184
|
+
os.rename(path, target + SPOOL_SUFFIX)
|
|
185
|
+
except OSError:
|
|
186
|
+
pass # another process rotated it first; reopening creates/uses the new file
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Corruption-tolerant spool reader. Reports counts; never raises on bad data.
|
|
2
|
+
|
|
3
|
+
Tolerated and counted: missing/garbled CRC prefix, CRC mismatch, non-UTF-8 or non-JSON
|
|
4
|
+
payloads, over-long lines, events failing the Pydantic model, unknown ``schema_version``,
|
|
5
|
+
and a torn tail (final line without a newline). A torn fragment followed directly by a
|
|
6
|
+
good record (a killed writer, then a normal append) is resynchronised so the good
|
|
7
|
+
record is not lost.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import zlib
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from pydantic import ValidationError
|
|
19
|
+
|
|
20
|
+
from cursorfleet.events.ids import fs_name_for
|
|
21
|
+
from cursorfleet.events.kinds import SCHEMA_VERSION
|
|
22
|
+
from cursorfleet.events.models import Event
|
|
23
|
+
from cursorfleet.state.runtime import RuntimePaths, untrusted_reason
|
|
24
|
+
from cursorfleet.state.spool import MAX_LINE_BYTES, SPOOL_SUFFIX, decode_line
|
|
25
|
+
|
|
26
|
+
MAX_READ_BYTES = 32 * 1024 * 1024
|
|
27
|
+
_RESYNC = re.compile(rb'[0-9a-f]{8} \{"schema_version"')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Corruption:
|
|
32
|
+
"""Counters for everything the reader skipped."""
|
|
33
|
+
|
|
34
|
+
bad_format: int = 0
|
|
35
|
+
bad_crc: int = 0
|
|
36
|
+
bad_json: int = 0
|
|
37
|
+
invalid_event: int = 0
|
|
38
|
+
unknown_version: int = 0
|
|
39
|
+
oversize: int = 0
|
|
40
|
+
misplaced: int = 0 # event whose session id does not match its spool directory
|
|
41
|
+
torn_tail: int = 0
|
|
42
|
+
resynced: int = 0 # good records recovered after a torn/garbled prefix (informational)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def total(self) -> int:
|
|
46
|
+
return (
|
|
47
|
+
self.bad_format
|
|
48
|
+
+ self.bad_crc
|
|
49
|
+
+ self.bad_json
|
|
50
|
+
+ self.invalid_event
|
|
51
|
+
+ self.unknown_version
|
|
52
|
+
+ self.oversize
|
|
53
|
+
+ self.misplaced
|
|
54
|
+
+ self.torn_tail
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def add(self, other: Corruption) -> None:
|
|
58
|
+
for name in self.__dataclass_fields__:
|
|
59
|
+
setattr(self, name, getattr(self, name) + getattr(other, name))
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, int]:
|
|
62
|
+
data = {name: getattr(self, name) for name in self.__dataclass_fields__}
|
|
63
|
+
data["total"] = self.total
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, data: dict[str, int]) -> Corruption:
|
|
68
|
+
return cls(**{n: int(data.get(n, 0)) for n in cls.__dataclass_fields__})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class FileRead:
|
|
73
|
+
events: list[Event] = field(default_factory=list)
|
|
74
|
+
new_offset: int = 0
|
|
75
|
+
size: int = 0
|
|
76
|
+
fingerprint: str | None = None
|
|
77
|
+
torn_tail_bytes: int = 0
|
|
78
|
+
corruption: Corruption = field(default_factory=Corruption)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fingerprint_of(first_line: bytes) -> str:
|
|
82
|
+
return f"{zlib.crc32(first_line) & 0xFFFFFFFF:08x}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_event(text: str, counts: Corruption) -> Event | None:
|
|
86
|
+
try:
|
|
87
|
+
obj = json.loads(text)
|
|
88
|
+
except (ValueError, RecursionError):
|
|
89
|
+
counts.bad_json += 1
|
|
90
|
+
return None
|
|
91
|
+
if not isinstance(obj, dict):
|
|
92
|
+
counts.bad_json += 1
|
|
93
|
+
return None
|
|
94
|
+
if obj.get("schema_version") != SCHEMA_VERSION:
|
|
95
|
+
counts.unknown_version += 1
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
return Event.model_validate(obj)
|
|
99
|
+
except ValidationError:
|
|
100
|
+
counts.invalid_event += 1
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _decode_with_resync(line: bytes, counts: Corruption) -> str | None:
|
|
105
|
+
text = decode_line(line)
|
|
106
|
+
if text is not None:
|
|
107
|
+
return text
|
|
108
|
+
for match in _RESYNC.finditer(line, 1):
|
|
109
|
+
text = decode_line(line[match.start() :])
|
|
110
|
+
if text is not None:
|
|
111
|
+
counts.bad_crc += 1 # the torn/garbled prefix
|
|
112
|
+
counts.resynced += 1
|
|
113
|
+
return text
|
|
114
|
+
if len(line) < 10 or line[8:9] != b" ":
|
|
115
|
+
counts.bad_format += 1
|
|
116
|
+
else:
|
|
117
|
+
counts.bad_crc += 1
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def peek_fingerprint(path: str | os.PathLike[str]) -> str | None:
|
|
122
|
+
"""Fingerprint of the first complete line (stable across rotation renames), or None."""
|
|
123
|
+
try:
|
|
124
|
+
with open(path, "rb") as handle:
|
|
125
|
+
head = handle.read(MAX_LINE_BYTES)
|
|
126
|
+
except OSError:
|
|
127
|
+
return None
|
|
128
|
+
newline = head.find(b"\n")
|
|
129
|
+
return fingerprint_of(head[:newline]) if newline >= 0 else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def read_file(path: str | os.PathLike[str], offset: int = 0, *, final: bool = False) -> FileRead:
|
|
133
|
+
"""Read complete lines from ``offset``. Never raises on content or missing files.
|
|
134
|
+
|
|
135
|
+
``new_offset`` stops before an unterminated tail, so an incremental reader retries it
|
|
136
|
+
once the writer finishes. With ``final=True`` (one-shot replay/export) the tail is
|
|
137
|
+
also counted as ``torn_tail``.
|
|
138
|
+
"""
|
|
139
|
+
result = FileRead(new_offset=offset)
|
|
140
|
+
try:
|
|
141
|
+
with open(path, "rb") as handle:
|
|
142
|
+
result.size = os.fstat(handle.fileno()).st_size
|
|
143
|
+
head = handle.read(MAX_LINE_BYTES)
|
|
144
|
+
newline = head.find(b"\n")
|
|
145
|
+
if newline >= 0:
|
|
146
|
+
result.fingerprint = fingerprint_of(head[:newline])
|
|
147
|
+
if offset > result.size:
|
|
148
|
+
offset = 0 # truncated or replaced file: start over (dedupe handles repeats)
|
|
149
|
+
result.new_offset = 0
|
|
150
|
+
handle.seek(offset)
|
|
151
|
+
data = handle.read(MAX_READ_BYTES)
|
|
152
|
+
except OSError:
|
|
153
|
+
return result
|
|
154
|
+
consumed = 0
|
|
155
|
+
while True:
|
|
156
|
+
end = data.find(b"\n", consumed)
|
|
157
|
+
if end < 0:
|
|
158
|
+
break
|
|
159
|
+
line = data[consumed:end]
|
|
160
|
+
consumed = end + 1
|
|
161
|
+
if not line.strip():
|
|
162
|
+
continue
|
|
163
|
+
if len(line) > MAX_LINE_BYTES:
|
|
164
|
+
result.corruption.oversize += 1
|
|
165
|
+
continue
|
|
166
|
+
text = _decode_with_resync(line, result.corruption)
|
|
167
|
+
if text is None:
|
|
168
|
+
continue
|
|
169
|
+
event = _parse_event(text, result.corruption)
|
|
170
|
+
if event is not None:
|
|
171
|
+
result.events.append(event)
|
|
172
|
+
tail = data[consumed:]
|
|
173
|
+
if consumed == 0 and len(data) >= MAX_READ_BYTES:
|
|
174
|
+
# one unterminated run longer than the whole read window: skip it so we make progress
|
|
175
|
+
result.corruption.oversize += 1
|
|
176
|
+
consumed = len(data)
|
|
177
|
+
tail = b""
|
|
178
|
+
result.new_offset = offset + consumed
|
|
179
|
+
result.torn_tail_bytes = len(tail)
|
|
180
|
+
if tail.strip() and final:
|
|
181
|
+
result.corruption.torn_tail += 1
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def list_spool_files(paths: RuntimePaths, session_id: str | None = None) -> list[str]:
|
|
186
|
+
"""All ``*.jsonl`` files under ``spool/`` (or one session), sorted for determinism."""
|
|
187
|
+
root = paths.spool
|
|
188
|
+
found: list[str] = []
|
|
189
|
+
if untrusted_reason(paths.root) is not None:
|
|
190
|
+
return found # spool files in a directory others could write are not trusted input
|
|
191
|
+
try:
|
|
192
|
+
if session_id is not None:
|
|
193
|
+
directories = [os.path.join(root, fs_name_for(session_id))]
|
|
194
|
+
else:
|
|
195
|
+
with os.scandir(root) as entries:
|
|
196
|
+
directories = sorted(e.path for e in entries if e.is_dir(follow_symlinks=False))
|
|
197
|
+
except OSError:
|
|
198
|
+
return []
|
|
199
|
+
for directory in directories:
|
|
200
|
+
try:
|
|
201
|
+
with os.scandir(directory) as entries:
|
|
202
|
+
found.extend(
|
|
203
|
+
sorted(
|
|
204
|
+
e.path
|
|
205
|
+
for e in entries
|
|
206
|
+
if e.name.endswith(SPOOL_SUFFIX) and e.is_file(follow_symlinks=False)
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
except OSError:
|
|
210
|
+
continue
|
|
211
|
+
return found
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def accept_placement(path: str, event: Event, counts: Corruption) -> bool:
|
|
215
|
+
"""An event is only trusted inside the spool directory named for its own session."""
|
|
216
|
+
if os.path.basename(os.path.dirname(path)) == fs_name_for(event.session_id):
|
|
217
|
+
return True
|
|
218
|
+
counts.misplaced += 1
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def read_all(
|
|
223
|
+
paths: RuntimePaths, session_id: str | None = None
|
|
224
|
+
) -> tuple[list[Event], Corruption, int]:
|
|
225
|
+
"""Read every spool file once. Returns (events, corruption, files_read).
|
|
226
|
+
|
|
227
|
+
With ``session_id`` only that session's directory is read and events are filtered to
|
|
228
|
+
the id, so a forged file cannot smuggle another session in.
|
|
229
|
+
"""
|
|
230
|
+
events: list[Event] = []
|
|
231
|
+
corruption = Corruption()
|
|
232
|
+
files = list_spool_files(paths, session_id)
|
|
233
|
+
for file in files:
|
|
234
|
+
result = read_file(file, 0, final=True)
|
|
235
|
+
corruption.add(result.corruption)
|
|
236
|
+
events.extend(
|
|
237
|
+
e
|
|
238
|
+
for e in result.events
|
|
239
|
+
if (session_id is None or e.session_id == session_id)
|
|
240
|
+
and accept_placement(file, e, corruption)
|
|
241
|
+
)
|
|
242
|
+
return events, corruption, len(files)
|