assay-kit 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.
assay/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("assay-kit")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0+dev"
assay/cli/__init__.py ADDED
File without changes
assay/cli/main.py ADDED
@@ -0,0 +1,406 @@
1
+ """Assay CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Optional, cast
8
+
9
+ import typer
10
+
11
+ from assay import __version__
12
+ from assay.config import AssayConfig, ConfigError, load_config
13
+ from assay.formatter.formatter import format_packet
14
+ from assay.formatter.writer import write_packet
15
+ from assay.keys.store import KeyStoreError, create_key, list_keys, revoke_key
16
+ from assay.runner import artifacts as _artifacts
17
+ from assay.runner import runner as _runner
18
+ from assay.schedule.cron import InvalidCronError, validate_cron
19
+ from assay.schedule.store import ScheduleStoreError, add_schedule, list_schedules, remove_schedule
20
+
21
+ app = typer.Typer(
22
+ help="Assay — independent verification layer for software projects.",
23
+ invoke_without_command=True,
24
+ )
25
+ schedule_app = typer.Typer(help="Manage scheduled test runs.")
26
+ key_app = typer.Typer(help="Manage API keys for the ingest endpoint.")
27
+
28
+ app.add_typer(schedule_app, name="schedule")
29
+ app.add_typer(key_app, name="key")
30
+
31
+ _NOT_IMPLEMENTED = "not implemented"
32
+
33
+
34
+ @app.callback()
35
+ def main(
36
+ ctx: typer.Context,
37
+ version: Optional[bool] = typer.Option( # noqa: UP007
38
+ None,
39
+ "--version",
40
+ help="Print version and exit.",
41
+ ),
42
+ config: Optional[str] = typer.Option( # noqa: UP007
43
+ None,
44
+ "--config",
45
+ help="Override config file path.",
46
+ envvar="ASSAY_CONFIG",
47
+ ),
48
+ verbose: bool = typer.Option(False, "--verbose", help="Enable verbose output."),
49
+ ) -> None:
50
+ try:
51
+ ctx.obj = load_config(config)
52
+ except ConfigError as exc:
53
+ typer.echo(f"config error: {exc}", err=True)
54
+ raise typer.Exit(2) from exc
55
+ if version:
56
+ typer.echo(f"assay {__version__}")
57
+ raise typer.Exit(0)
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # run
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ @app.command()
66
+ def run(
67
+ ctx: typer.Context,
68
+ target: Optional[str] = typer.Option(None, "--target", help="URL or target to test."), # noqa: UP007
69
+ suite: str = typer.Option("default", "--suite", help="Test suite name."),
70
+ output: Optional[str] = typer.Option(None, "--output", help="Output directory."), # noqa: UP007
71
+ task_id: Optional[str] = typer.Option(None, "--task-id", help="Grain task ID to tag this run."), # noqa: UP007
72
+ submit: bool = typer.Option(False, "--submit", help="Submit packet to Grain output path after run."),
73
+ ) -> None:
74
+ """Execute a test run using the Playwright + Docker runner."""
75
+ from assay.grain.detect import detect_task_id
76
+
77
+ if target is None:
78
+ typer.echo("error: --target is required", err=True)
79
+ raise typer.Exit(2)
80
+
81
+ config: AssayConfig = ctx.obj
82
+ output_dir = output or config.output.directory
83
+ image = config.runner.docker_image
84
+
85
+ # Resolve task_id: explicit flag > Grain auto-detect
86
+ effective_task_id = task_id or detect_task_id(config.grain.project_root or None)
87
+ if effective_task_id:
88
+ typer.echo(f"task_id: {effective_task_id}")
89
+
90
+ runner_result = _runner.run(target, suite=suite, output_dir=output_dir, image=image)
91
+
92
+ try:
93
+ bundle = _artifacts.collect_artifacts(runner_result.output_dir, runner_result)
94
+ except _artifacts.ArtifactError as exc:
95
+ typer.echo(f"error reading artifacts: {exc}", err=True)
96
+ raise typer.Exit(1) from exc
97
+
98
+ typer.echo(f"outcome: {bundle.outcome}")
99
+ if bundle.error:
100
+ typer.echo(f"error: {bundle.error}", err=True)
101
+
102
+ try:
103
+ packet = format_packet(bundle, task_id=effective_task_id)
104
+ # Copy screenshot to a stable verification_id-based name in the output dir
105
+ verification_id = str(packet["verification_id"])
106
+ if bundle.screenshot_path:
107
+ src = Path(bundle.screenshot_path)
108
+ if src.exists():
109
+ dest = Path(output_dir) / f"{verification_id}.png"
110
+ shutil.copy2(src, dest)
111
+ packet["artifact_refs"] = [str(dest)]
112
+ packet_path = write_packet(packet, output_dir)
113
+ typer.echo(f"packet: {packet_path}")
114
+ except Exception as exc:
115
+ typer.echo(f"error writing packet: {exc}", err=True)
116
+ raise typer.Exit(1) from exc
117
+
118
+ if submit:
119
+ _do_submit(str(packet_path), config)
120
+
121
+ if bundle.outcome == "pass":
122
+ raise typer.Exit(0)
123
+ elif bundle.outcome == "fail":
124
+ raise typer.Exit(3)
125
+ else:
126
+ raise typer.Exit(1)
127
+
128
+
129
+ def _do_submit(packet_path: str, config: AssayConfig) -> None:
130
+ """Copy a packet to the configured Grain output path."""
131
+ import json as _json
132
+
133
+ grain_output = config.grain.output_path
134
+ if not grain_output:
135
+ typer.echo("error: [grain] output_path not configured", err=True)
136
+ raise typer.Exit(1)
137
+
138
+ try:
139
+ data = _json.loads(Path(packet_path).read_text())
140
+ except Exception as exc:
141
+ typer.echo(f"error reading packet: {exc}", err=True)
142
+ raise typer.Exit(1) from exc
143
+
144
+ import jsonschema # type: ignore[import-untyped]
145
+
146
+ from assay.schemas import ASSAY_PAYLOAD
147
+ try:
148
+ jsonschema.validate(instance=data, schema=ASSAY_PAYLOAD)
149
+ except jsonschema.ValidationError as exc:
150
+ typer.echo(f"error: packet schema invalid: {exc.message}", err=True)
151
+ raise typer.Exit(1) from exc
152
+
153
+ dest_dir = Path(grain_output)
154
+ dest_dir.mkdir(parents=True, exist_ok=True)
155
+ dest = dest_dir / Path(packet_path).name
156
+ shutil.copy2(packet_path, dest)
157
+ typer.echo(f"submitted: {dest}")
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # submit
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ @app.command()
166
+ def submit(
167
+ ctx: typer.Context,
168
+ packet: str = typer.Option(..., "--packet", help="Path to the packet JSON file to submit."),
169
+ ) -> None:
170
+ """Validate and submit a packet to the configured Grain output path."""
171
+ config: AssayConfig = ctx.obj
172
+ _do_submit(packet, config)
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # serve
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ @app.command()
181
+ def serve(
182
+ ctx: typer.Context,
183
+ host: str = typer.Option("127.0.0.1", "--host", help="Host to bind."),
184
+ port: int = typer.Option(8000, "--port", help="Port to bind."),
185
+ ) -> None:
186
+ """Start the FastAPI ingest server."""
187
+ import uvicorn
188
+
189
+ from assay.ingest.app import app as ingest_app
190
+
191
+ config: AssayConfig = ctx.obj
192
+ ingest_app.state.key_store = config.keys.store
193
+ ingest_app.state.output_dir = config.output.directory
194
+
195
+ uvicorn.run(ingest_app, host=host, port=port)
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # report
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ @app.command()
204
+ def report(
205
+ output: str = typer.Option("./assay-output", "--output", help="Directory to read packets from."),
206
+ format: str = typer.Option("text", "--format", help="Output format: text or json."),
207
+ filter: Optional[str] = typer.Option(None, "--filter", help="Filter packets, e.g. outcome=fail."), # noqa: UP007
208
+ ) -> None:
209
+ """Display task packet summaries from the output directory."""
210
+ import json as _json
211
+ from pathlib import Path as _Path
212
+
213
+ out = _Path(output)
214
+ if not out.is_dir():
215
+ typer.echo(f"error: output directory not found: {output}", err=True)
216
+ raise typer.Exit(1)
217
+
218
+ packets = []
219
+ for path in sorted(out.glob("assay-*.json")):
220
+ try:
221
+ data: dict[str, object] = _json.loads(path.read_text())
222
+ except Exception:
223
+ continue
224
+ packets.append(data)
225
+
226
+ # Apply --filter key=value
227
+ if filter:
228
+ if "=" not in filter:
229
+ typer.echo("error: --filter must be in key=value form", err=True)
230
+ raise typer.Exit(2)
231
+ fkey, fval = filter.split("=", 1)
232
+ packets = [p for p in packets if str(p.get(fkey, "")) == fval]
233
+
234
+ if format == "json":
235
+ typer.echo(_json.dumps(packets, indent=2))
236
+ raise typer.Exit(0)
237
+
238
+ if not packets:
239
+ typer.echo("no packets found")
240
+ raise typer.Exit(0)
241
+
242
+ # Text table
243
+ col = "{:<36} {:<13} {:<8} {:<10} {:<10} {}"
244
+ typer.echo(col.format("verification_id", "outcome", "severity", "screenshot", "verified_at", "summary"))
245
+ typer.echo("-" * 120)
246
+ for p in packets:
247
+ vid = str(p.get("verification_id", ""))[:36]
248
+ outcome = str(p.get("outcome", ""))
249
+ severity = str(p.get("severity", ""))
250
+ refs = cast(list[object], p.get("artifact_refs", []))
251
+ has_screenshot = "yes" if any(str(r).endswith(".png") for r in refs) else "no"
252
+ verified_at = str(p.get("verified_at", ""))[:10]
253
+ summary = str(p.get("summary", ""))
254
+ typer.echo(col.format(vid, outcome, severity, has_screenshot, verified_at, summary))
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # schedule subcommands
259
+ # ---------------------------------------------------------------------------
260
+
261
+
262
+ @schedule_app.command("add")
263
+ def schedule_add(
264
+ ctx: typer.Context,
265
+ cron: str = typer.Option(..., "--cron", help="Cron expression (5-field)."),
266
+ suite: str = typer.Option("default", "--suite", help="Test suite name."),
267
+ target: Optional[str] = typer.Option(None, "--target", help="URL override (uses config default if omitted)."), # noqa: UP007
268
+ ) -> None:
269
+ """Register a new scheduled run."""
270
+ try:
271
+ validate_cron(cron)
272
+ except InvalidCronError as exc:
273
+ typer.echo(f"error: {exc}", err=True)
274
+ raise typer.Exit(2) from exc
275
+
276
+ config: AssayConfig = ctx.obj
277
+ try:
278
+ sid = add_schedule(config.schedule.store, cron, suite=suite, target=target)
279
+ except ScheduleStoreError as exc:
280
+ typer.echo(f"error: {exc}", err=True)
281
+ raise typer.Exit(1) from exc
282
+ typer.echo(f"schedule added: {sid}")
283
+
284
+
285
+ @schedule_app.command("list")
286
+ def schedule_list(ctx: typer.Context) -> None:
287
+ """List all active schedules."""
288
+ config: AssayConfig = ctx.obj
289
+ try:
290
+ schedules = list_schedules(config.schedule.store)
291
+ except ScheduleStoreError as exc:
292
+ typer.echo(f"error: {exc}", err=True)
293
+ raise typer.Exit(1) from exc
294
+ if not schedules:
295
+ typer.echo("no schedules")
296
+ return
297
+ for s in schedules:
298
+ target = s["target"] or "(config default)"
299
+ last = s["last_run"] or "never"
300
+ typer.echo(f"{s['id']} {s['cron']} suite={s['suite']} target={target} last_run={last}")
301
+
302
+
303
+ @schedule_app.command("run")
304
+ def schedule_run(ctx: typer.Context) -> None:
305
+ """Start the scheduler loop (foreground; Ctrl+C to stop)."""
306
+ from assay.schedule.loop import run_scheduler
307
+
308
+ config: AssayConfig = ctx.obj
309
+ run_scheduler(config)
310
+
311
+
312
+ @schedule_app.command("remove")
313
+ def schedule_remove(
314
+ ctx: typer.Context,
315
+ schedule_id: str = typer.Argument(..., help="Schedule ID to remove."),
316
+ ) -> None:
317
+ """Remove a schedule by ID."""
318
+ config: AssayConfig = ctx.obj
319
+ try:
320
+ remove_schedule(config.schedule.store, schedule_id)
321
+ except ScheduleStoreError as exc:
322
+ typer.echo(f"error: {exc}", err=True)
323
+ raise typer.Exit(1) from exc
324
+ typer.echo(f"removed: {schedule_id}")
325
+
326
+
327
+ @schedule_app.command("start")
328
+ def schedule_start(ctx: typer.Context) -> None:
329
+ """Start the scheduler as a background daemon."""
330
+ from assay.schedule.daemon import start
331
+
332
+ start(config_path=None)
333
+
334
+
335
+ @schedule_app.command("stop")
336
+ def schedule_stop() -> None:
337
+ """Stop the running background scheduler daemon."""
338
+ from assay.schedule.daemon import stop
339
+
340
+ stop()
341
+
342
+
343
+ @schedule_app.command("status")
344
+ def schedule_status() -> None:
345
+ """Show whether the background scheduler daemon is running."""
346
+ from assay.schedule.daemon import status
347
+
348
+ info = status()
349
+ if info["running"]:
350
+ typer.echo(f"running (pid {info['pid']})")
351
+ else:
352
+ typer.echo("stopped")
353
+ typer.echo(f"log: {info['log_file']}")
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # key subcommands
358
+ # ---------------------------------------------------------------------------
359
+
360
+
361
+ @key_app.command("create")
362
+ def key_create(
363
+ ctx: typer.Context,
364
+ name: Optional[str] = typer.Option(None, "--name", help="Label for the key."), # noqa: UP007
365
+ ) -> None:
366
+ """Generate a new API key."""
367
+ config: AssayConfig = ctx.obj
368
+ try:
369
+ raw = create_key(config.keys.store, label=name)
370
+ except KeyStoreError as exc:
371
+ typer.echo(f"error: {exc}", err=True)
372
+ raise typer.Exit(1) from exc
373
+ typer.echo(f"key: {raw}")
374
+ typer.echo("Save this key — it will not be shown again.")
375
+
376
+
377
+ @key_app.command("list")
378
+ def key_list(ctx: typer.Context) -> None:
379
+ """List all API keys (IDs and labels only)."""
380
+ config: AssayConfig = ctx.obj
381
+ try:
382
+ keys = list_keys(config.keys.store)
383
+ except KeyStoreError as exc:
384
+ typer.echo(f"error: {exc}", err=True)
385
+ raise typer.Exit(1) from exc
386
+ if not keys:
387
+ typer.echo("no keys")
388
+ return
389
+ for k in keys:
390
+ status = "revoked" if k["revoked"] else "active"
391
+ typer.echo(f"{k['id']} {k['label']} {status} {k['created_at']}")
392
+
393
+
394
+ @key_app.command("revoke")
395
+ def key_revoke(
396
+ ctx: typer.Context,
397
+ key_id: str = typer.Argument(..., help="Key ID to revoke."),
398
+ ) -> None:
399
+ """Revoke an API key by ID."""
400
+ config: AssayConfig = ctx.obj
401
+ try:
402
+ revoke_key(config.keys.store, key_id)
403
+ except KeyStoreError as exc:
404
+ typer.echo(f"error: {exc}", err=True)
405
+ raise typer.Exit(1) from exc
406
+ typer.echo(f"revoked: {key_id}")
assay/config.py ADDED
@@ -0,0 +1,149 @@
1
+ """Assay configuration loader.
2
+
3
+ Reads assay.toml from (in priority order):
4
+ 1. explicit path passed to load_config()
5
+ 2. ./assay.toml (project-local)
6
+ 3. ~/.assay/config.toml (global user)
7
+ 4. built-in defaults (all fields optional)
8
+
9
+ Exit code 2 is expected when ConfigError propagates to the CLI layer.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import tomllib
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ _KNOWN_SECTIONS = {"project", "runner", "output", "serve", "keys", "schedule", "grain"}
19
+
20
+
21
+ class ConfigError(Exception):
22
+ """Raised on config parse or validation failure."""
23
+
24
+
25
+ @dataclass
26
+ class ProjectConfig:
27
+ name: str = "assay"
28
+
29
+
30
+ @dataclass
31
+ class RunnerConfig:
32
+ docker_image: str = "assay-playwright:latest"
33
+ timeout_seconds: int = 300
34
+
35
+
36
+ @dataclass
37
+ class OutputConfig:
38
+ directory: str = "./assay-output"
39
+
40
+
41
+ @dataclass
42
+ class ServeConfig:
43
+ host: str = "127.0.0.1"
44
+ port: int = 8000
45
+
46
+
47
+ @dataclass
48
+ class KeysConfig:
49
+ store: str = "~/.assay/keys.json"
50
+
51
+
52
+ @dataclass
53
+ class ScheduleConfig:
54
+ store: str = "~/.assay/schedules.json"
55
+
56
+
57
+ @dataclass
58
+ class GrainConfig:
59
+ project_root: str = ""
60
+ output_path: str = ""
61
+
62
+
63
+ @dataclass
64
+ class AssayConfig:
65
+ project: ProjectConfig = field(default_factory=ProjectConfig)
66
+ runner: RunnerConfig = field(default_factory=RunnerConfig)
67
+ output: OutputConfig = field(default_factory=OutputConfig)
68
+ serve: ServeConfig = field(default_factory=ServeConfig)
69
+ keys: KeysConfig = field(default_factory=KeysConfig)
70
+ schedule: ScheduleConfig = field(default_factory=ScheduleConfig)
71
+ grain: GrainConfig = field(default_factory=GrainConfig)
72
+
73
+
74
+ def _resolve_path(override: str | None) -> Path | None:
75
+ if override is not None:
76
+ p = Path(override)
77
+ if not p.exists():
78
+ raise ConfigError(f"config file not found: {override}")
79
+ return p
80
+ local = Path("assay.toml")
81
+ if local.exists():
82
+ return local
83
+ global_ = Path.home() / ".assay" / "config.toml"
84
+ if global_.exists():
85
+ return global_
86
+ return None
87
+
88
+
89
+ def _parse(raw: dict[str, object]) -> AssayConfig:
90
+ unknown = set(raw.keys()) - _KNOWN_SECTIONS
91
+ if unknown:
92
+ raise ConfigError(f"unknown config section(s): {', '.join(sorted(unknown))}")
93
+
94
+ def _section(key: str) -> dict[str, object]:
95
+ val = raw.get(key, {})
96
+ if not isinstance(val, dict):
97
+ raise ConfigError(f"config section [{key}] must be a table")
98
+ return val
99
+
100
+ proj = _section("project")
101
+ runner = _section("runner")
102
+ output = _section("output")
103
+ serve = _section("serve")
104
+ keys = _section("keys")
105
+ schedule = _section("schedule")
106
+ grain = _section("grain")
107
+
108
+ raw_timeout = runner.get("timeout_seconds", 300)
109
+ raw_port = serve.get("port", 8000)
110
+ if not isinstance(raw_timeout, int):
111
+ raise ConfigError(f"runner.timeout_seconds must be an integer, got {raw_timeout!r}")
112
+ if not isinstance(raw_port, int):
113
+ raise ConfigError(f"serve.port must be an integer, got {raw_port!r}")
114
+
115
+ return AssayConfig(
116
+ project=ProjectConfig(name=str(proj.get("name", "assay"))),
117
+ runner=RunnerConfig(
118
+ docker_image=str(runner.get("docker_image", "assay-playwright:latest")),
119
+ timeout_seconds=raw_timeout,
120
+ ),
121
+ output=OutputConfig(directory=str(output.get("directory", "./assay-output"))),
122
+ serve=ServeConfig(
123
+ host=str(serve.get("host", "127.0.0.1")),
124
+ port=raw_port,
125
+ ),
126
+ keys=KeysConfig(store=str(keys.get("store", "~/.assay/keys.json"))),
127
+ schedule=ScheduleConfig(store=str(schedule.get("store", "~/.assay/schedules.json"))),
128
+ grain=GrainConfig(
129
+ project_root=str(grain.get("project_root", "")),
130
+ output_path=str(grain.get("output_path", "")),
131
+ ),
132
+ )
133
+
134
+
135
+ def load_config(path: str | None = None) -> AssayConfig:
136
+ """Load and return AssayConfig from the resolved config file path.
137
+
138
+ Returns all-defaults AssayConfig if no config file is found.
139
+ Raises ConfigError on file-not-found (when explicit path given), parse error,
140
+ or unknown section.
141
+ """
142
+ resolved = _resolve_path(path)
143
+ if resolved is None:
144
+ return AssayConfig()
145
+ try:
146
+ raw = tomllib.loads(resolved.read_text())
147
+ except tomllib.TOMLDecodeError as exc:
148
+ raise ConfigError(f"invalid TOML in {resolved}: {exc}") from exc
149
+ return _parse(raw)
@@ -0,0 +1 @@
1
+ """Assay formatter package — converts runner artifacts to Grain Sentinel payloads."""
@@ -0,0 +1,93 @@
1
+ """Formatter — converts ArtifactBundle or SDK IngestPayload to a Grain Sentinel payload dict."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from typing import TYPE_CHECKING, Optional
8
+
9
+ from assay.runner.artifacts import ArtifactBundle
10
+
11
+ if TYPE_CHECKING:
12
+ from assay.ingest.app import IngestPayload
13
+
14
+ _ISSUE_TYPE_MAP = {
15
+ "pass": "test_failure",
16
+ "fail": "test_failure",
17
+ "inconclusive": "test_failure",
18
+ }
19
+
20
+ _SEVERITY_MAP = {
21
+ "pass": "info",
22
+ "fail": "error",
23
+ "inconclusive": "warning",
24
+ }
25
+
26
+
27
+ def _summary(bundle: ArtifactBundle) -> str:
28
+ """Build a human-readable one-line summary."""
29
+ if bundle.outcome == "pass":
30
+ return f"pass: {bundle.url}"
31
+ if bundle.error:
32
+ return f"{bundle.outcome}: {bundle.error}"
33
+ return f"{bundle.outcome}: {bundle.url or 'unknown url'}"
34
+
35
+
36
+ def _verified_at(bundle: ArtifactBundle) -> str:
37
+ """Return bundle timestamp or current UTC time in ISO 8601."""
38
+ if bundle.timestamp:
39
+ return bundle.timestamp
40
+ return datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
41
+
42
+
43
+ def format_packet(
44
+ bundle: ArtifactBundle,
45
+ task_id: Optional[str] = None, # noqa: UP007
46
+ ) -> dict[str, object]:
47
+ """Convert an ArtifactBundle into a Grain Sentinel payload dict.
48
+
49
+ Args:
50
+ bundle: Populated ArtifactBundle from collect_artifacts().
51
+ task_id: Grain TASK-#### ID being verified; None for standalone runs.
52
+
53
+ Returns:
54
+ Dict conforming to data_contracts.md §1 Grain Sentinel payload schema.
55
+ """
56
+ artifact_refs: list[str] = [bundle.screenshot_path] if bundle.screenshot_path else []
57
+
58
+ return {
59
+ "verification_id": str(uuid.uuid4()),
60
+ "task_id": task_id,
61
+ "issue_type": _ISSUE_TYPE_MAP.get(bundle.outcome, "test_failure"),
62
+ "severity": _SEVERITY_MAP.get(bundle.outcome, "warning"),
63
+ "outcome": bundle.outcome,
64
+ "summary": _summary(bundle),
65
+ "artifact_refs": artifact_refs,
66
+ "followup_candidates": [],
67
+ "verified_at": _verified_at(bundle),
68
+ }
69
+
70
+
71
+ def format_sdk_packet(payload: "IngestPayload") -> dict[str, object]:
72
+ """Convert a browser SDK IngestPayload into a Grain Sentinel payload dict.
73
+
74
+ Args:
75
+ payload: Validated IngestPayload from the POST /ingest handler.
76
+
77
+ Returns:
78
+ Dict conforming to data_contracts.md §1 schema.
79
+ """
80
+ comment = payload.user_comment or ""
81
+ summary = f"SDK capture: {payload.url}" + (f" — {comment}" if comment else "")
82
+
83
+ return {
84
+ "verification_id": str(uuid.uuid4()),
85
+ "task_id": None,
86
+ "issue_type": "screenshot_evidence",
87
+ "severity": "info",
88
+ "outcome": "inconclusive",
89
+ "summary": summary,
90
+ "artifact_refs": [],
91
+ "followup_candidates": [],
92
+ "verified_at": payload.captured_at,
93
+ }
@@ -0,0 +1,34 @@
1
+ """Packet file writer — serialises a Grain Sentinel payload dict to disk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+
9
+ def write_packet(packet: dict[str, object], output_dir: str) -> Path:
10
+ """Write a Grain Sentinel payload dict to a JSON file in output_dir.
11
+
12
+ The filename is derived from the packet's verified_at and verification_id
13
+ fields so it is both human-sortable and globally unique.
14
+
15
+ Args:
16
+ packet: Grain Sentinel payload dict from format_packet().
17
+ output_dir: Directory to write into. Created if it does not exist.
18
+
19
+ Returns:
20
+ Path of the written file.
21
+ """
22
+ out = Path(output_dir)
23
+ out.mkdir(parents=True, exist_ok=True)
24
+
25
+ verified_at = str(packet.get("verified_at", ""))
26
+ verification_id = str(packet.get("verification_id", ""))
27
+
28
+ # Make verified_at filename-safe: drop colons and dots
29
+ ts_safe = verified_at.replace(":", "").replace(".", "").replace("-", "")
30
+
31
+ filename = f"assay-{ts_safe}-{verification_id}.json"
32
+ path = out / filename
33
+ path.write_text(json.dumps(packet))
34
+ return path
File without changes