syncmill 0.2.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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/room_raw.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Explicit raw content for ``room export`` (#189).
|
|
2
|
+
|
|
3
|
+
``room export`` is body-free by default. This module adds stored prompts, agent
|
|
4
|
+
output, gate check output and patches only for targets the user names, and
|
|
5
|
+
only in two steps: ``--preview`` shows what would be included and prints a
|
|
6
|
+
token; ``--confirm TOKEN`` collects everything again and writes it only when
|
|
7
|
+
the recomputed token is the same. The token is a digest of every item's
|
|
8
|
+
descriptor (kind, target, observed room and round, the full artifact digest
|
|
9
|
+
and size, how much is included, the integrity class and the source note), so
|
|
10
|
+
a confirmation is bound to exactly what was previewed.
|
|
11
|
+
|
|
12
|
+
Integrity differs by kind and every item says which applies:
|
|
13
|
+
|
|
14
|
+
- ``sqlite-digest``: patch and output, streamed once and checked against the
|
|
15
|
+
digest the SQLite candidate row holds.
|
|
16
|
+
- ``manifest-record``: gate check output, stored in the candidate manifest,
|
|
17
|
+
which has no independent digest; it is read through the comparison's own
|
|
18
|
+
path (:func:`room_compare.read_candidate_gate`) and accepted only when that
|
|
19
|
+
path accepts the record.
|
|
20
|
+
- ``room-database``: a round's prompt, read from its SQLite row.
|
|
21
|
+
|
|
22
|
+
Raw bytes are never redacted (they would no longer match their digests); a
|
|
23
|
+
warning says they may contain secrets. ``room_compare`` stays body-free: it
|
|
24
|
+
only places the lines this module renders before its footer. A flat top-level
|
|
25
|
+
module imported only by the CLI.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
from collections.abc import Iterable
|
|
34
|
+
from dataclasses import asdict, dataclass, field
|
|
35
|
+
from hashlib import sha256
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from syncmill.artifacts import printable_name, printable_text
|
|
39
|
+
from syncmill.room_compare import (
|
|
40
|
+
_GATE_TEXT,
|
|
41
|
+
RoomComparison,
|
|
42
|
+
RoundGroup,
|
|
43
|
+
_md,
|
|
44
|
+
_md_table,
|
|
45
|
+
read_candidate_gate,
|
|
46
|
+
)
|
|
47
|
+
from syncmill.rooms import RoomError, RoomStore
|
|
48
|
+
|
|
49
|
+
KINDS = ("patch", "output", "gate-output", "prompt")
|
|
50
|
+
|
|
51
|
+
#: Bytes included per item; digests always cover the whole artifact.
|
|
52
|
+
DEFAULT_MAX_BYTES = 256 * 1024
|
|
53
|
+
|
|
54
|
+
#: Lines of each item the preview shows.
|
|
55
|
+
PREVIEW_LINES = 8
|
|
56
|
+
|
|
57
|
+
TOKEN_VERSION = 1
|
|
58
|
+
|
|
59
|
+
WARNING = (
|
|
60
|
+
"Raw content is included verbatim: prompts, agent output, gate output and "
|
|
61
|
+
"patches. It may contain secrets such as tokens, credentials, file paths "
|
|
62
|
+
"or environment values. Review it before sharing."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
DISPLAY_NOTE = (
|
|
66
|
+
"Each block is a display rendering: control characters and invalid UTF-8 "
|
|
67
|
+
"are shown as escapes such as \\x1b, which look the same as literal text "
|
|
68
|
+
"such as \\x41. The sha256 is of the stored bytes; use --format json for "
|
|
69
|
+
"byte-exact content."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_SOURCE_NOTES = {
|
|
73
|
+
"patch": "stored candidate patch",
|
|
74
|
+
"output": "stored provider output",
|
|
75
|
+
"gate-output": (
|
|
76
|
+
"stored gate check output text (the shipped CommandGate keeps only the "
|
|
77
|
+
"last 4000 characters of a check's output); the sha256 identifies this "
|
|
78
|
+
"stored text, not the original process output"
|
|
79
|
+
),
|
|
80
|
+
"prompt": (
|
|
81
|
+
"the round's user-authored prompt as stored in the room database; each "
|
|
82
|
+
"provider also received checkpoint and memory context that is not part "
|
|
83
|
+
"of this text and may differ per member"
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_INTEGRITY_TEXT = {
|
|
88
|
+
"sqlite-digest": "sha256 matches the SQLite candidate row",
|
|
89
|
+
"manifest-record": "manifest record; no independent digest",
|
|
90
|
+
"room-database": "room database row",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, order=True)
|
|
95
|
+
class RawSpec:
|
|
96
|
+
"""One ``--include KIND:TARGET``, canonicalized."""
|
|
97
|
+
|
|
98
|
+
kind: str
|
|
99
|
+
target: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_include(value: str) -> RawSpec:
|
|
103
|
+
"""Parse ``KIND:TARGET``. A candidate id must be given in full (an export
|
|
104
|
+
binds content, so no prefix matching); a round number is canonicalized so
|
|
105
|
+
``prompt:01`` and ``prompt:1`` are the same include."""
|
|
106
|
+
kind, sep, target = value.partition(":")
|
|
107
|
+
if not sep or kind not in KINDS:
|
|
108
|
+
raise ValueError(f"expected KIND:TARGET with KIND one of {', '.join(KINDS)}")
|
|
109
|
+
if kind == "prompt":
|
|
110
|
+
if not re.fullmatch(r"[0-9]+", target) or int(target) < 1:
|
|
111
|
+
raise ValueError("prompt takes a round number (1 or more)")
|
|
112
|
+
return RawSpec(kind, str(int(target)))
|
|
113
|
+
if not target:
|
|
114
|
+
raise ValueError(f"{kind} takes a full candidate id")
|
|
115
|
+
return RawSpec(kind, target)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class RawItem:
|
|
120
|
+
"""One included block. Every field but ``head`` is its descriptor, and the
|
|
121
|
+
whole descriptor is bound into the token."""
|
|
122
|
+
|
|
123
|
+
kind: str
|
|
124
|
+
target: str
|
|
125
|
+
#: 1-based gate check index (gate-output only).
|
|
126
|
+
check: int | None
|
|
127
|
+
#: The check's stored command (gate-output only).
|
|
128
|
+
command: str | None
|
|
129
|
+
room_id: str
|
|
130
|
+
round_id: str
|
|
131
|
+
sha256: str
|
|
132
|
+
size: int
|
|
133
|
+
included_bytes: int
|
|
134
|
+
truncated: bool
|
|
135
|
+
integrity: str
|
|
136
|
+
source_note: str
|
|
137
|
+
head: bytes = field(repr=False)
|
|
138
|
+
|
|
139
|
+
def descriptor(self) -> dict[str, Any]:
|
|
140
|
+
data = asdict(self)
|
|
141
|
+
del data["head"]
|
|
142
|
+
return data
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _item(
|
|
146
|
+
spec: RawSpec,
|
|
147
|
+
*,
|
|
148
|
+
room_id: str,
|
|
149
|
+
round_id: str,
|
|
150
|
+
data: bytes,
|
|
151
|
+
size: int,
|
|
152
|
+
digest: str,
|
|
153
|
+
integrity: str,
|
|
154
|
+
source_note: str,
|
|
155
|
+
max_bytes: int,
|
|
156
|
+
check: int | None = None,
|
|
157
|
+
command: str | None = None,
|
|
158
|
+
) -> RawItem:
|
|
159
|
+
head = data[:max_bytes]
|
|
160
|
+
return RawItem(
|
|
161
|
+
kind=spec.kind,
|
|
162
|
+
target=spec.target,
|
|
163
|
+
check=check,
|
|
164
|
+
command=command,
|
|
165
|
+
room_id=room_id,
|
|
166
|
+
round_id=round_id,
|
|
167
|
+
sha256=digest,
|
|
168
|
+
size=size,
|
|
169
|
+
included_bytes=len(head),
|
|
170
|
+
truncated=size > len(head),
|
|
171
|
+
integrity=integrity,
|
|
172
|
+
source_note=source_note,
|
|
173
|
+
head=head,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def collect_raw(
|
|
178
|
+
store: RoomStore,
|
|
179
|
+
comparison: RoomComparison,
|
|
180
|
+
specs: Iterable[RawSpec],
|
|
181
|
+
max_bytes: int,
|
|
182
|
+
) -> list[RawItem]:
|
|
183
|
+
"""Read every requested item from the store, verified, in canonical order.
|
|
184
|
+
|
|
185
|
+
Each target must lie inside the comparison's rounds; the reads are bound
|
|
186
|
+
to the room, round and digests the comparison resolved, so a row that
|
|
187
|
+
changed since is refused rather than exported under the old scope."""
|
|
188
|
+
candidates = {
|
|
189
|
+
row.candidate_id: (group, row) for group in comparison.groups for row in group.rows
|
|
190
|
+
}
|
|
191
|
+
rounds = {group.number: group for group in comparison.groups}
|
|
192
|
+
items: list[RawItem] = []
|
|
193
|
+
for spec in sorted(set(specs)):
|
|
194
|
+
if spec.kind == "prompt":
|
|
195
|
+
group = rounds.get(int(spec.target))
|
|
196
|
+
if group is None:
|
|
197
|
+
raise RoomError(
|
|
198
|
+
f"round {spec.target} is not in this export "
|
|
199
|
+
"(choose it with --round or use --all-rounds)"
|
|
200
|
+
)
|
|
201
|
+
items.append(_prompt_item(store, comparison.room_id, group, spec, max_bytes))
|
|
202
|
+
continue
|
|
203
|
+
found = candidates.get(spec.target)
|
|
204
|
+
if found is None:
|
|
205
|
+
raise RoomError(f"candidate {spec.target} is not in this export's rounds")
|
|
206
|
+
group, row = found
|
|
207
|
+
expected = {
|
|
208
|
+
"room_id": comparison.room_id,
|
|
209
|
+
"round_id": group.round_id,
|
|
210
|
+
"patch_sha256": row.patch_sha256,
|
|
211
|
+
"output_sha256": row.output_sha256,
|
|
212
|
+
}
|
|
213
|
+
if spec.kind == "gate-output":
|
|
214
|
+
items.extend(_gate_items(store, spec, expected, max_bytes))
|
|
215
|
+
continue
|
|
216
|
+
read = store.load_candidate_raw_head(
|
|
217
|
+
spec.target, spec.kind, max_bytes, expected=expected
|
|
218
|
+
)
|
|
219
|
+
note = _SOURCE_NOTES[spec.kind]
|
|
220
|
+
if spec.kind == "output" and read["manifest"].get("output_truncated") is True:
|
|
221
|
+
note += ", truncated at save"
|
|
222
|
+
items.append(
|
|
223
|
+
_item(
|
|
224
|
+
spec,
|
|
225
|
+
room_id=str(read["row"]["room_id"]),
|
|
226
|
+
round_id=str(read["row"]["round_id"]),
|
|
227
|
+
data=read["head"],
|
|
228
|
+
size=read["size"],
|
|
229
|
+
digest=read["sha256"],
|
|
230
|
+
integrity="sqlite-digest",
|
|
231
|
+
source_note=note,
|
|
232
|
+
max_bytes=max_bytes,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
return items
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _prompt_item(
|
|
239
|
+
store: RoomStore, room_id: str, group: RoundGroup, spec: RawSpec, max_bytes: int
|
|
240
|
+
) -> RawItem:
|
|
241
|
+
row = store.get_round(group.round_id)
|
|
242
|
+
if (
|
|
243
|
+
row.get("room_id") != room_id
|
|
244
|
+
or row.get("number") != group.number
|
|
245
|
+
or row.get("id") != group.round_id
|
|
246
|
+
):
|
|
247
|
+
raise RoomError(f"round {group.number} changed since the export resolved it")
|
|
248
|
+
prompt = row.get("prompt")
|
|
249
|
+
try:
|
|
250
|
+
if not isinstance(prompt, str):
|
|
251
|
+
raise TypeError("prompt is not text")
|
|
252
|
+
data = prompt.encode("utf-8")
|
|
253
|
+
except (TypeError, UnicodeEncodeError) as exc:
|
|
254
|
+
raise RoomError(f"round {group.number} prompt is not valid Unicode text") from exc
|
|
255
|
+
return _item(
|
|
256
|
+
spec,
|
|
257
|
+
room_id=room_id,
|
|
258
|
+
round_id=group.round_id,
|
|
259
|
+
data=data,
|
|
260
|
+
size=len(data),
|
|
261
|
+
digest=sha256(data).hexdigest(),
|
|
262
|
+
integrity="room-database",
|
|
263
|
+
source_note=_SOURCE_NOTES["prompt"],
|
|
264
|
+
max_bytes=max_bytes,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _gate_items(
|
|
269
|
+
store: RoomStore, spec: RawSpec, expected: dict[str, Any], max_bytes: int
|
|
270
|
+
) -> list[RawItem]:
|
|
271
|
+
row = store.get_candidate(spec.target)
|
|
272
|
+
store._check_expected_row(spec.target, row, expected)
|
|
273
|
+
# The comparison's own path (artifact verification, then the gate policy),
|
|
274
|
+
# not a copy of it: raw export never shows checks the comparison discards.
|
|
275
|
+
read = read_candidate_gate(
|
|
276
|
+
store, spec.target, state=str(row["state"]), eligible=bool(row["eligible"])
|
|
277
|
+
)
|
|
278
|
+
verdict, manifest = read.gate, read.manifest
|
|
279
|
+
if verdict.verdict not in ("passed", "failed") or manifest is None:
|
|
280
|
+
reason = read.unreadable or verdict.reason or _GATE_TEXT.get(verdict.verdict, verdict.verdict)
|
|
281
|
+
raise RoomError(f"candidate {spec.target} has no usable gate record: {reason}")
|
|
282
|
+
# The manifest was validated against the row as it was at read time; it
|
|
283
|
+
# must still be the row the comparison resolved this target from.
|
|
284
|
+
for key in ("room_id", "round_id", "patch_sha256", "output_sha256"):
|
|
285
|
+
if manifest.get(key) != expected[key]:
|
|
286
|
+
raise RoomError(
|
|
287
|
+
f"candidate {key} changed since the export resolved it: {spec.target}"
|
|
288
|
+
)
|
|
289
|
+
if not manifest["gate"]["checks"]:
|
|
290
|
+
# An include must add at least one item: zero items would leave the
|
|
291
|
+
# token unchanged by the include and export nothing for it.
|
|
292
|
+
raise RoomError(f"candidate {spec.target} has a gate report with no checks")
|
|
293
|
+
items: list[RawItem] = []
|
|
294
|
+
for index, check in enumerate(manifest["gate"]["checks"], 1):
|
|
295
|
+
try:
|
|
296
|
+
command = check["command"]
|
|
297
|
+
command.encode("utf-8")
|
|
298
|
+
data = check["output"].encode("utf-8")
|
|
299
|
+
except UnicodeEncodeError as exc:
|
|
300
|
+
raise RoomError(
|
|
301
|
+
f"candidate {spec.target} gate check {index} is not valid Unicode text"
|
|
302
|
+
) from exc
|
|
303
|
+
items.append(
|
|
304
|
+
_item(
|
|
305
|
+
spec,
|
|
306
|
+
room_id=expected["room_id"],
|
|
307
|
+
round_id=expected["round_id"],
|
|
308
|
+
data=data,
|
|
309
|
+
size=len(data),
|
|
310
|
+
digest=sha256(data).hexdigest(),
|
|
311
|
+
integrity="manifest-record",
|
|
312
|
+
source_note=_SOURCE_NOTES["gate-output"],
|
|
313
|
+
max_bytes=max_bytes,
|
|
314
|
+
check=index,
|
|
315
|
+
command=command,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
return items
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def scope_of(comparison: RoomComparison, *, all_rounds: bool) -> dict[str, Any]:
|
|
322
|
+
"""The resolved export scope bound into the token: the default (latest)
|
|
323
|
+
round is named by its number, so a new round changes it."""
|
|
324
|
+
if all_rounds:
|
|
325
|
+
return {"all_rounds": True}
|
|
326
|
+
return {"round": comparison.groups[0].number if comparison.groups else None}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def preview_token(
|
|
330
|
+
room_id: str, scope: dict[str, Any], max_bytes: int, items: list[RawItem]
|
|
331
|
+
) -> str:
|
|
332
|
+
document = {
|
|
333
|
+
"v": TOKEN_VERSION,
|
|
334
|
+
"room": room_id,
|
|
335
|
+
"scope": scope,
|
|
336
|
+
"cap": max_bytes,
|
|
337
|
+
"items": [item.descriptor() for item in items],
|
|
338
|
+
}
|
|
339
|
+
canonical = json.dumps(
|
|
340
|
+
document, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
|
341
|
+
)
|
|
342
|
+
return sha256(canonical.encode("ascii")).hexdigest()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ------------------------------------------------------------- rendering
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _render_line(line: str) -> str:
|
|
349
|
+
# One display line: a gate command is a one-line value, so an LF in it is
|
|
350
|
+
# escaped the way printable_name writes it rather than starting a new line.
|
|
351
|
+
return printable_text(line).replace("\n", "\\x0a")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def display_lines(data: bytes) -> tuple[list[str], bool]:
|
|
355
|
+
"""``(lines, ends_with_newline)`` of the display rendering. LF splits lines
|
|
356
|
+
and one final LF is a terminator; TAB stays literal; every other
|
|
357
|
+
non-printable character and every invalid UTF-8 byte becomes an escape."""
|
|
358
|
+
parts = data.decode("utf-8", errors="surrogateescape").split("\n")
|
|
359
|
+
ends_with_newline = parts[-1] == ""
|
|
360
|
+
if ends_with_newline:
|
|
361
|
+
parts.pop()
|
|
362
|
+
return [_render_line(part) for part in parts], ends_with_newline
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _p(value: object) -> str:
|
|
366
|
+
return printable_name(str(value))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def render_preview(items: list[RawItem], token: str, room_id: str) -> str:
|
|
370
|
+
lines = [f"Raw content preview for room {_p(room_id)}", ""]
|
|
371
|
+
for number, item in enumerate(items, 1):
|
|
372
|
+
title = f"[{number}] {item.kind} {_p(item.target)}"
|
|
373
|
+
if item.check is not None:
|
|
374
|
+
title += f" check {item.check}"
|
|
375
|
+
lines.append(title)
|
|
376
|
+
if item.command is not None:
|
|
377
|
+
lines.append(f" command: {_p(item.command)}")
|
|
378
|
+
lines.append(f" integrity: {_INTEGRITY_TEXT[item.integrity]}")
|
|
379
|
+
lines.append(f" size: {item.size:,} bytes, sha256 {item.sha256}")
|
|
380
|
+
lines.append(
|
|
381
|
+
f" included: {item.included_bytes:,} bytes"
|
|
382
|
+
+ (" (truncated)" if item.truncated else "")
|
|
383
|
+
)
|
|
384
|
+
lines.append(f" source: {item.source_note}")
|
|
385
|
+
body, _ = display_lines(item.head)
|
|
386
|
+
shown = body[:PREVIEW_LINES]
|
|
387
|
+
lines.append(f" first {len(shown)} line(s):")
|
|
388
|
+
lines.extend(f" | {line}" for line in shown)
|
|
389
|
+
if len(body) > len(shown) or item.truncated:
|
|
390
|
+
lines.append(" | ...")
|
|
391
|
+
lines.append("")
|
|
392
|
+
total = sum(item.included_bytes for item in items)
|
|
393
|
+
lines.extend(
|
|
394
|
+
[
|
|
395
|
+
f"{len(items)} item(s), {total:,} bytes to include.",
|
|
396
|
+
"",
|
|
397
|
+
f"WARNING: {WARNING}",
|
|
398
|
+
"",
|
|
399
|
+
f"Token: {token}",
|
|
400
|
+
f"To write: rerun the same command with --preview replaced by --confirm {token}",
|
|
401
|
+
"(add --output FILE to write a file).",
|
|
402
|
+
]
|
|
403
|
+
)
|
|
404
|
+
return "\n".join(lines) + "\n"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _md_raw_fence(lines: list[str]) -> list[str]:
|
|
408
|
+
"""Already-rendered lines as one fenced block whose fence outlasts every
|
|
409
|
+
backtick run in them (the #186 rule). Fence and body are separate list
|
|
410
|
+
entries so the review renderer's blank-line squeeze sees the fence."""
|
|
411
|
+
longest = 0
|
|
412
|
+
for line in lines:
|
|
413
|
+
run = 0
|
|
414
|
+
for ch in line:
|
|
415
|
+
run = run + 1 if ch == "`" else 0
|
|
416
|
+
longest = max(longest, run)
|
|
417
|
+
fence = "`" * max(3, longest + 1)
|
|
418
|
+
return ["", fence, *lines, fence, ""]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def render_markdown_section(items: list[RawItem], token: str) -> list[str]:
|
|
422
|
+
lines = [
|
|
423
|
+
"",
|
|
424
|
+
"## Raw content (explicitly included)",
|
|
425
|
+
"",
|
|
426
|
+
f"WARNING: {_md(WARNING)}",
|
|
427
|
+
"",
|
|
428
|
+
_md(DISPLAY_NOTE),
|
|
429
|
+
"",
|
|
430
|
+
_md("The comparison above was built when this export was confirmed."),
|
|
431
|
+
"",
|
|
432
|
+
f"Confirmed with token: {_md(token)}",
|
|
433
|
+
]
|
|
434
|
+
for number, item in enumerate(items, 1):
|
|
435
|
+
title = f"Raw {number}: {item.kind} {item.target}"
|
|
436
|
+
if item.check is not None:
|
|
437
|
+
title += f" check {item.check}"
|
|
438
|
+
lines.extend(["", f"### {_md(title)}"])
|
|
439
|
+
lines.extend(
|
|
440
|
+
_md_table(
|
|
441
|
+
["Field", "Value"],
|
|
442
|
+
[
|
|
443
|
+
["Kind", _md(item.kind)],
|
|
444
|
+
["Target", _md(item.target)],
|
|
445
|
+
*([["Check", _md(item.check)]] if item.check is not None else []),
|
|
446
|
+
["Round id", _md(item.round_id)],
|
|
447
|
+
["Integrity", _md(_INTEGRITY_TEXT[item.integrity])],
|
|
448
|
+
["Size", _md(f"{item.size:,} bytes")],
|
|
449
|
+
["Sha256", _md(item.sha256)],
|
|
450
|
+
["Included", _md(f"{item.included_bytes:,} bytes")],
|
|
451
|
+
["Truncated", "yes" if item.truncated else "no"],
|
|
452
|
+
],
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
if item.command is not None:
|
|
456
|
+
lines.append("Command:")
|
|
457
|
+
lines.extend(_md_raw_fence([_render_line(item.command)]))
|
|
458
|
+
lines.append("Source:")
|
|
459
|
+
lines.extend(_md_raw_fence([item.source_note]))
|
|
460
|
+
body, ends_with_newline = display_lines(item.head)
|
|
461
|
+
lines.append("Content:")
|
|
462
|
+
lines.extend(_md_raw_fence(body))
|
|
463
|
+
if item.truncated:
|
|
464
|
+
lines.append(
|
|
465
|
+
_md(f"[truncated: {item.included_bytes:,} of {item.size:,} bytes shown]")
|
|
466
|
+
)
|
|
467
|
+
elif body and not ends_with_newline:
|
|
468
|
+
lines.append(_md("[no newline at end of content]"))
|
|
469
|
+
return lines
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def json_section(items: list[RawItem], token: str) -> dict[str, Any]:
|
|
473
|
+
return {
|
|
474
|
+
"token": token,
|
|
475
|
+
"warning": WARNING,
|
|
476
|
+
"items": [
|
|
477
|
+
{
|
|
478
|
+
**item.descriptor(),
|
|
479
|
+
"encoding": "base64",
|
|
480
|
+
"data": base64.b64encode(item.head).decode("ascii"),
|
|
481
|
+
}
|
|
482
|
+
for item in items
|
|
483
|
+
],
|
|
484
|
+
}
|