pyrobostudio-sidecar 0.1.0__tar.gz
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.
- pyrobostudio_sidecar-0.1.0/PKG-INFO +32 -0
- pyrobostudio_sidecar-0.1.0/README.md +12 -0
- pyrobostudio_sidecar-0.1.0/pyproject.toml +33 -0
- pyrobostudio_sidecar-0.1.0/pyproject.toml.orig +32 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/__init__.py +5 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/fixtures_env.py +50 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/lerobot.py +115 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/make_fixture.py +255 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/mission.py +85 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/networkintel.py +112 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/recorded.py +104 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/repos.py +199 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/ros2bag.py +219 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/server.py +182 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/spikes/test_foxglove_handshake.py +78 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/syncfleet.py +91 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/terrain_fixture.py +36 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_adapters.py +97 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_env_adapters.py +117 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_lerobot.py +127 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_mission.py +75 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_repos_api.py +80 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_ros2bag.py +114 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_security.py +97 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_server.py +117 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/vision_tracks.py +63 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/worldsim.py +61 -0
- pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/ws_transport.py +77 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyrobostudio-sidecar
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PyRoboStudio data sidecar: recorded-session serving, capabilities, mission/time APIs
|
|
5
|
+
Author: Georgi Mullassery
|
|
6
|
+
Author-email: Georgi Mullassery <mullassery@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Dist: fastapi>=0.162.0
|
|
9
|
+
Requires-Dist: httpx>=0.28.1
|
|
10
|
+
Requires-Dist: mcap>=1.6.0
|
|
11
|
+
Requires-Dist: mcap-ros2-support>=0.6.0
|
|
12
|
+
Requires-Dist: foxglove-sdk>=0.30
|
|
13
|
+
Requires-Dist: websockets>=17.0
|
|
14
|
+
Requires-Dist: uvicorn[standard]>=0.54.0
|
|
15
|
+
Requires-Dist: h3>=4.5.0
|
|
16
|
+
Requires-Dist: rosbags>=0.11.9
|
|
17
|
+
Requires-Dist: pyarrow>=26.0.1
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# pyrobostudio-sidecar
|
|
22
|
+
|
|
23
|
+
Python data sidecar for [PyRoboStudio](https://github.com/Mullassery/PyRoboStudio):
|
|
24
|
+
a read-only FastAPI service that serves recorded robotics sessions, adapter views
|
|
25
|
+
of PyRobo* repositories, mission manifests, and a Foxglove WebSocket transport.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
uvx pyrobostudio-sidecar # serves 127.0.0.1:8787
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Read-only by design: the sidecar never publishes to robots or writes to source
|
|
32
|
+
repositories. See the main repository for architecture, security model, and docs.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# pyrobostudio-sidecar
|
|
2
|
+
|
|
3
|
+
Python data sidecar for [PyRoboStudio](https://github.com/Mullassery/PyRoboStudio):
|
|
4
|
+
a read-only FastAPI service that serves recorded robotics sessions, adapter views
|
|
5
|
+
of PyRobo* repositories, mission manifests, and a Foxglove WebSocket transport.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
uvx pyrobostudio-sidecar # serves 127.0.0.1:8787
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Read-only by design: the sidecar never publishes to robots or writes to source
|
|
12
|
+
repositories. See the main repository for architecture, security model, and docs.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyrobostudio-sidecar"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "PyRoboStudio data sidecar: recorded-session serving, capabilities, mission/time APIs"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"fastapi>=0.162.0",
|
|
10
|
+
"httpx>=0.28.1",
|
|
11
|
+
"mcap>=1.6.0",
|
|
12
|
+
"mcap-ros2-support>=0.6.0",
|
|
13
|
+
"foxglove-sdk>=0.30",
|
|
14
|
+
"websockets>=17.0",
|
|
15
|
+
"uvicorn[standard]>=0.54.0",
|
|
16
|
+
"h3>=4.5.0",
|
|
17
|
+
"rosbags>=0.11.9",
|
|
18
|
+
"pyarrow>=26.0.1",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[[project.authors]]
|
|
22
|
+
name = "Georgi Mullassery"
|
|
23
|
+
email = "mullassery@gmail.com"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
pyrobostudio-sidecar = "pyrobostudio_sidecar:main"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = ["pytest>=9.2.0"]
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["uv_build>=0.13.0,<0.14.0"]
|
|
33
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyrobostudio-sidecar"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "PyRoboStudio data sidecar: recorded-session serving, capabilities, mission/time APIs"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Georgi Mullassery", email = "mullassery@gmail.com" }
|
|
7
|
+
]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"fastapi>=0.162.0",
|
|
13
|
+
"httpx>=0.28.1",
|
|
14
|
+
"mcap>=1.6.0",
|
|
15
|
+
"mcap-ros2-support>=0.6.0",
|
|
16
|
+
"foxglove-sdk>=0.30",
|
|
17
|
+
"websockets>=17.0",
|
|
18
|
+
"uvicorn[standard]>=0.54.0",
|
|
19
|
+
"h3>=4.5.0",
|
|
20
|
+
"rosbags>=0.11.9",
|
|
21
|
+
"pyarrow>=26.0.1",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
pyrobostudio-sidecar = "pyrobostudio_sidecar:main"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["pytest>=9.2.0"]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["uv_build>=0.13.0,<0.14.0"]
|
|
32
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Environment-test fixtures for networkintel + syncfleet adapters (verbatim upstream DDL subsets)."""
|
|
2
|
+
import sqlite3
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
NI_DDL = [
|
|
6
|
+
"CREATE TABLE scan_sessions (id INTEGER PRIMARY KEY, scan_time DATETIME NOT NULL, duration_seconds REAL, target VARCHAR(255), device_count INTEGER DEFAULT 0, finding_count INTEGER DEFAULT 0, status VARCHAR(50) DEFAULT 'completed', error_message TEXT)",
|
|
7
|
+
"CREATE TABLE devices (id INTEGER PRIMARY KEY, scan_id INTEGER REFERENCES scan_sessions(id), ip VARCHAR(45) NOT NULL, hostname VARCHAR(255), os VARCHAR(255), device_type VARCHAR(50) DEFAULT 'linux', is_online BOOLEAN DEFAULT 1, last_seen DATETIME NOT NULL, first_seen DATETIME NOT NULL)",
|
|
8
|
+
"CREATE TABLE services (id INTEGER PRIMARY KEY, device_id INTEGER REFERENCES devices(id), port INTEGER NOT NULL, name VARCHAR(100), version VARCHAR(255), protocol VARCHAR(10) DEFAULT 'tcp', state VARCHAR(50) DEFAULT 'open', first_seen DATETIME, last_seen DATETIME)",
|
|
9
|
+
"CREATE TABLE findings (id INTEGER PRIMARY KEY, scan_id INTEGER REFERENCES scan_sessions(id), device_ip VARCHAR(45), finding_type VARCHAR(50), severity VARCHAR(20), title VARCHAR(500), description TEXT, evidence TEXT, recommendation TEXT, business_impact TEXT, cve_id VARCHAR(50), cvss_score REAL, exploit_available BOOLEAN DEFAULT 0, found_at DATETIME, resolved_at DATETIME)",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
SF_DDL = [
|
|
13
|
+
"CREATE TABLE operation_journal (id INTEGER PRIMARY KEY, dataset_id INTEGER REFERENCES datasets(id), entity_id VARCHAR(255), origin_node_id VARCHAR(255), origin_seq INTEGER NOT NULL, op_type INTEGER, schema_version INTEGER, payload BLOB, causal_metadata BLOB, created_at DATETIME, checksum BLOB)",
|
|
14
|
+
"CREATE TABLE replication_checkpoints (id INTEGER PRIMARY KEY, peer_id VARCHAR(255), dataset_id INTEGER, last_acked_seq INTEGER, last_applied_seq INTEGER, updated_at DATETIME)",
|
|
15
|
+
"CREATE TABLE conflicts (id INTEGER PRIMARY KEY, operation_id VARCHAR(255), local_operation_id VARCHAR(255), remote_operation_id VARCHAR(255), conflict_type INTEGER, detected_at DATETIME, resolved_at DATETIME, resolution TEXT)",
|
|
16
|
+
"CREATE TABLE datasets (id INTEGER PRIMARY KEY, dataset_id VARCHAR(255), schema_version INTEGER, conflict_policy VARCHAR(50), created_at DATETIME)",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_ni_db(path: Path) -> dict:
|
|
21
|
+
con = sqlite3.connect(path)
|
|
22
|
+
for ddl in NI_DDL:
|
|
23
|
+
con.execute(ddl)
|
|
24
|
+
con.execute("INSERT INTO scan_sessions VALUES (1, '2026-11-01 10:00:00', 4.2, '192.168.1.0/24', 2, 1, 'completed', NULL)")
|
|
25
|
+
con.execute("INSERT INTO devices VALUES (1, 1, '192.168.1.2', 'robot-1', 'Ubuntu', 'linux', 1, '2026-11-01', '2026-11-01')")
|
|
26
|
+
con.execute("INSERT INTO devices VALUES (2, 1, '192.168.1.3', 'robot-2', 'Ubuntu', 'linux', 0, '2026-11-01', '2026-11-01')")
|
|
27
|
+
con.execute("INSERT INTO services VALUES (1, 1, 2224, 'ros2-dds', NULL, 'udp', 'open', '2026-11-01', '2026-11-01')")
|
|
28
|
+
con.execute("INSERT INTO services VALUES (2, 1, 22, 'ssh', 'OpenSSH_9.6', 'tcp', 'open', '2026-11-01', '2026-11-01')")
|
|
29
|
+
con.execute("INSERT INTO findings VALUES (1, 1, '192.168.1.3', 'exposure', 'high', 'Open exploit reachable', 'nmap', 'svc', 'patch', 'disruption', 'CVE-9999-0001', 8.1, 1, '2026-11-01', NULL)")
|
|
30
|
+
con.commit()
|
|
31
|
+
con.close()
|
|
32
|
+
return {"scan_id": 1}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_sf_db(path: Path) -> dict:
|
|
36
|
+
con = sqlite3.connect(path)
|
|
37
|
+
for ddl in SF_DDL:
|
|
38
|
+
con.execute(ddl)
|
|
39
|
+
con.execute("INSERT INTO datasets VALUES (1, 'terrain-cells', 1, 'last-write-wins', '2026-11-01')")
|
|
40
|
+
for seq in range(1, 6):
|
|
41
|
+
con.execute(
|
|
42
|
+
"INSERT INTO operation_journal VALUES (?, 1, ?, 'nodeA', ?, 0, 1, ?, NULL, '2026-11-01 10:00:00', NULL)",
|
|
43
|
+
(seq, f"cell-{seq}", seq, b"payload"),
|
|
44
|
+
)
|
|
45
|
+
con.execute("INSERT INTO replication_checkpoints VALUES (1, 'nodeB', 1, 4, 4, '2026-11-01 10:01:00')")
|
|
46
|
+
con.execute("INSERT INTO replication_checkpoints VALUES (2, 'nodeC', 1, 3, 3, '2026-11-01 10:01:00')")
|
|
47
|
+
con.execute("INSERT INTO conflicts VALUES (1, 'op-x', 'op-x-local', 'op-x-remote', 0, '2026-11-01 10:02:00', NULL, NULL)")
|
|
48
|
+
con.commit()
|
|
49
|
+
con.close()
|
|
50
|
+
return {"peer_lagging": "nodeC", "lag": 2}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""LeRobotDataset v3.0 Parquet adapter — read-only.
|
|
2
|
+
|
|
3
|
+
Layout per docs/research/adapter-schemas.md §2 and verified against the
|
|
4
|
+
PyRoboFrames writer (lerobot.py). A directory without meta/info.json is
|
|
5
|
+
rejected rather than guessed (mirrors PyRoboFrames Rust reader behavior).
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pyarrow.parquet as pq
|
|
11
|
+
|
|
12
|
+
INFO_REQUIRED = ["codebase_version", "robot_type", "fps", "total_episodes", "total_frames", "features"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DatasetError(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LeRobotAdapter:
|
|
20
|
+
def __init__(self, root: Path) -> None:
|
|
21
|
+
self.root = root
|
|
22
|
+
info_path = root / "meta" / "info.json"
|
|
23
|
+
if not info_path.exists():
|
|
24
|
+
raise DatasetError(f"{root}: not a LeRobotDataset v3.0 (missing meta/info.json)")
|
|
25
|
+
info = json.loads(info_path.read_text())
|
|
26
|
+
missing = [k for k in INFO_REQUIRED if k not in info]
|
|
27
|
+
if missing:
|
|
28
|
+
raise DatasetError(f"{root}: meta/info.json missing keys {missing}")
|
|
29
|
+
if info["codebase_version"] != "v3.0":
|
|
30
|
+
raise DatasetError(f"{root}: codebase_version {info['codebase_version']!r} != v3.0")
|
|
31
|
+
self.info = info
|
|
32
|
+
|
|
33
|
+
def dataset_id(self) -> str:
|
|
34
|
+
return self.root.name
|
|
35
|
+
|
|
36
|
+
def episodes(self) -> list[dict]:
|
|
37
|
+
out: list[dict] = []
|
|
38
|
+
for chunk in sorted((self.root / "meta" / "episodes").glob("chunk-*/file-*.parquet")):
|
|
39
|
+
table = pq.read_table(chunk)
|
|
40
|
+
cols = {name: table.column(name).to_pylist() for name in table.column_names}
|
|
41
|
+
for i in range(table.num_rows):
|
|
42
|
+
out.append(
|
|
43
|
+
{
|
|
44
|
+
"episode_index": cols["episode_index"][i],
|
|
45
|
+
"length": cols["length"][i],
|
|
46
|
+
"dataset_from_index": cols["dataset_from_index"][i],
|
|
47
|
+
"dataset_to_index": cols["dataset_to_index"][i],
|
|
48
|
+
"data/chunk_index": cols["data/chunk_index"][i],
|
|
49
|
+
"data/file_index": cols["data/file_index"][i],
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
def features(self) -> dict:
|
|
55
|
+
return self.info["features"]
|
|
56
|
+
|
|
57
|
+
def fps(self) -> int:
|
|
58
|
+
return int(self.info["fps"])
|
|
59
|
+
|
|
60
|
+
def steps(self, episode_index: int, max_steps: int = 64) -> list[dict]:
|
|
61
|
+
eps = self.episodes()
|
|
62
|
+
ep = next((e for e in eps if e["episode_index"] == episode_index), None)
|
|
63
|
+
if ep is None:
|
|
64
|
+
raise KeyError(f"unknown episode {episode_index}")
|
|
65
|
+
from_idx = ep["dataset_from_index"]
|
|
66
|
+
to_idx = min(ep["dataset_to_index"], from_idx + max_steps)
|
|
67
|
+
chunk = self.root / "data" / f"chunk-{ep['data/chunk_index']:03d}" / f"file-{ep['data/file_index']:03d}.parquet"
|
|
68
|
+
table = pq.read_table(chunk)
|
|
69
|
+
stats = self.stats()
|
|
70
|
+
cols = {name: table.column(name) for name in table.column_names}
|
|
71
|
+
# Shards store one row per step; each column value is the step's fixed-size vector.
|
|
72
|
+
def vec(name: str, row_i: int) -> list:
|
|
73
|
+
return cols[name][row_i].as_py()
|
|
74
|
+
|
|
75
|
+
out: list[dict] = []
|
|
76
|
+
for gidx in range(from_idx, to_idx):
|
|
77
|
+
row_i = gidx # one row per step in this layout
|
|
78
|
+
if row_i >= table.num_rows:
|
|
79
|
+
raise DatasetError(f"{self.root}: data shard has {table.num_rows} rows, need row {row_i}")
|
|
80
|
+
out.append(
|
|
81
|
+
{
|
|
82
|
+
"globalStep": gidx,
|
|
83
|
+
"episode_index": episode_index,
|
|
84
|
+
"features": {
|
|
85
|
+
name: vec(name, row_i) for name in cols
|
|
86
|
+
},
|
|
87
|
+
"stats": {name: stats.get(name, {}) for name in cols},
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
def stats(self) -> dict:
|
|
93
|
+
stats_path = self.root / "meta" / "stats.json"
|
|
94
|
+
if not stats_path.exists():
|
|
95
|
+
raise DatasetError(f"{self.root}: missing meta/stats.json")
|
|
96
|
+
return json.loads(stats_path.read_text())
|
|
97
|
+
|
|
98
|
+
def summary(self) -> dict:
|
|
99
|
+
return {
|
|
100
|
+
"dataset_id": self.dataset_id(),
|
|
101
|
+
"codebase_version": self.info["codebase_version"],
|
|
102
|
+
"robot_type": self.info.get("robot_type"),
|
|
103
|
+
"fps": self.fps(),
|
|
104
|
+
"total_episodes": self.info["total_episodes"],
|
|
105
|
+
"total_frames": self.info["total_frames"],
|
|
106
|
+
"features": {name: f["shape"] for name, f in self.info["features"].items()},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def provenance(self) -> dict:
|
|
110
|
+
versions = {}
|
|
111
|
+
for name in (".versions.json", "manifest.json"):
|
|
112
|
+
p = self.root / name
|
|
113
|
+
if p.exists():
|
|
114
|
+
versions[name] = json.loads(p.read_text())
|
|
115
|
+
return versions
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Generate a deterministic MCAP fixture plus graph/event metadata for tests.
|
|
2
|
+
Run: uv run python -m pyrobostudio_sidecar.make_fixture
|
|
3
|
+
Output: sidecar/fixtures/sample_session.mcap, sample_events.json
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from mcap.writer import Writer
|
|
9
|
+
FIXTURE_DIR = Path(__file__).parent.parent.parent / "fixtures"
|
|
10
|
+
SPAN_NS = 10_000_000_000
|
|
11
|
+
STEP_NS = 200_000_000
|
|
12
|
+
CMDVEL_SCHEMA = "geometry_msgs/msg/Twist"
|
|
13
|
+
SCAN_SCHEMA = "sensor_msgs/msg/LaserScan"
|
|
14
|
+
def make_fixture() -> None:
|
|
15
|
+
FIXTURE_DIR.mkdir(exist_ok=True)
|
|
16
|
+
path = FIXTURE_DIR / "sample_session.mcap"
|
|
17
|
+
with open(path, "wb") as fp:
|
|
18
|
+
w = Writer(fp)
|
|
19
|
+
w.start(profile="ros2")
|
|
20
|
+
schema_cmdvel = w.register_schema(CMDVEL_SCHEMA, "jsonschema", b"{}")
|
|
21
|
+
schema_scan = w.register_schema(SCAN_SCHEMA, "jsonschema", b"{}")
|
|
22
|
+
ch_vel = w.register_channel("/robot1/cmd_vel", message_encoding="json", schema_id=schema_cmdvel)
|
|
23
|
+
ch_scan = w.register_channel("/robot1/scan", message_encoding="json", schema_id=schema_scan)
|
|
24
|
+
for i in range(SPAN_NS // STEP_NS):
|
|
25
|
+
t = i * STEP_NS
|
|
26
|
+
gap = 3 <= i <= 5
|
|
27
|
+
if not gap:
|
|
28
|
+
x = 1.0 + math.sin(i / 5) * 0.2
|
|
29
|
+
payload = json.dumps(
|
|
30
|
+
{"linear": {"x": round(x, 3), "y": 0.0, "z": 0.0},
|
|
31
|
+
"angular": {"x": 0.0, "y": 0.0, "z": round(math.sin(i / 7), 3)}}
|
|
32
|
+
).encode()
|
|
33
|
+
w.add_message(ch_vel, log_time=t, publish_time=t, data=payload)
|
|
34
|
+
if i % 5 == 0:
|
|
35
|
+
payload = json.dumps(
|
|
36
|
+
{"capacity": 16, "ranges": [round(0.4 + 0.2 * abs(math.cos(t / 1e9)), 2)] * 10}
|
|
37
|
+
).encode()
|
|
38
|
+
w.add_message(ch_scan, log_time=t, publish_time=t, data=payload)
|
|
39
|
+
w.finish()
|
|
40
|
+
events = [
|
|
41
|
+
{
|
|
42
|
+
"id": "ev-gap",
|
|
43
|
+
"sessionId": "sample_session",
|
|
44
|
+
"timestampNs": 1_000_000_000,
|
|
45
|
+
"kind": "communication_gap",
|
|
46
|
+
"label": "cmd_vel gap 0.8s (i=3..5)",
|
|
47
|
+
"severity": "warn",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "ev-scan-anomaly",
|
|
51
|
+
"sessionId": "sample_session",
|
|
52
|
+
"timestampNs": 6_000_000_000,
|
|
53
|
+
"kind": "scan_range_spike",
|
|
54
|
+
"label": "scan ranges spike 2.0x",
|
|
55
|
+
"severity": "error",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"id": "ev-recovered",
|
|
59
|
+
"sessionId": "sample_session",
|
|
60
|
+
"timestampNs": 9_000_000_000,
|
|
61
|
+
"kind": "recovery",
|
|
62
|
+
"label": "teleop resumed",
|
|
63
|
+
"severity": "info",
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
(FIXTURE_DIR / "sample_events.json").write_text(json.dumps(events, indent=2))
|
|
67
|
+
graph = {
|
|
68
|
+
"nodes": [
|
|
69
|
+
{"id": "n/robot1/teleop", "ns": "/robot1", "name": "teleop", "kind": "node"},
|
|
70
|
+
{"id": "n/robot1/controller", "ns": "/robot1", "name": "controller", "kind": "node"},
|
|
71
|
+
{"id": "n/robot1/lidar", "ns": "/robot1", "name": "lidar", "kind": "node"},
|
|
72
|
+
],
|
|
73
|
+
"topics": [
|
|
74
|
+
{"id": "t/robot1/cmd_vel", "name": "/robot1/cmd_vel", "msgType": CMDVEL_SCHEMA, "pubCount": 1, "subCount": 1, "qos": {"summary": "best_effort, depth 5 (fixture metadata)"}},
|
|
75
|
+
{"id": "t/robot1/scan", "name": "/robot1/scan", "msgType": SCAN_SCHEMA, "pubCount": 1, "subCount": 1, "qos": {"summary": "best_effort, depth 10 (fixture metadata)"}},
|
|
76
|
+
],
|
|
77
|
+
"edges": [
|
|
78
|
+
{"id": "e1", "nodeId": "n/robot1/teleop", "topicId": "t/robot1/cmd_vel", "direction": "pub", "qosCompat": "ok"},
|
|
79
|
+
{"id": "e2", "nodeId": "n/robot1/controller", "topicId": "t/robot1/cmd_vel", "direction": "sub", "qosCompat": "ok"},
|
|
80
|
+
{"id": "e3", "nodeId": "n/robot1/lidar", "topicId": "t/robot1/scan", "direction": "pub", "qosCompat": "ok"},
|
|
81
|
+
{"id": "e4", "nodeId": "n/robot1/controller", "topicId": "t/robot1/scan", "direction": "sub", "qosCompat": "ok"},
|
|
82
|
+
],
|
|
83
|
+
"activity": [],
|
|
84
|
+
}
|
|
85
|
+
(FIXTURE_DIR / "sample_graph.json").write_text(json.dumps(graph, indent=2))
|
|
86
|
+
tf = {
|
|
87
|
+
"frames": [
|
|
88
|
+
{"frameId": "odom", "authority": "sample_session"},
|
|
89
|
+
{"frameId": "base_link", "parentId": "odom", "translation": [1.0, 0.0, 0.0], "rotation": [0, 0, 0, 1]},
|
|
90
|
+
{"frameId": "laser", "parentId": "base_link", "translation": [0.2, 0.0, 0.3], "rotation": [0, 0, 0, 1]},
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
(FIXTURE_DIR / "sample_tf.json").write_text(json.dumps(tf, indent=2))
|
|
94
|
+
print(f"wrote {path}, events, graph, tf")
|
|
95
|
+
def make_repo_fixtures() -> None:
|
|
96
|
+
"""Phase 4 adapter fixtures: ros2bag .db3, lerobot dataset, tracks JSON, world_spec.json, sqlite stores."""
|
|
97
|
+
from pyrobostudio_sidecar.ros2bag import write_db3_fixture
|
|
98
|
+
from pyrobostudio_sidecar.fixtures_env import make_ni_db, make_sf_db
|
|
99
|
+
|
|
100
|
+
for stale in [
|
|
101
|
+
FIXTURE_DIR / "sample_session.db3",
|
|
102
|
+
FIXTURE_DIR / "sample_tracks.json",
|
|
103
|
+
FIXTURE_DIR / "pynetworkintel.db",
|
|
104
|
+
FIXTURE_DIR / "syncfleet.db",
|
|
105
|
+
]:
|
|
106
|
+
stale.unlink(missing_ok=True)
|
|
107
|
+
import numpy as np
|
|
108
|
+
import pyarrow as pa
|
|
109
|
+
import pyarrow.parquet as pq
|
|
110
|
+
cmd_vel = {"linear": {"x": 0.5, "y": 0.0, "z": 0.0}, "angular": {"x": 0.0, "y": 0.0, "z": 0.1}}
|
|
111
|
+
scan = {
|
|
112
|
+
"header": {"stamp": {"sec": 0, "nanosec": 0}, "frame_id": "laser"},
|
|
113
|
+
"angle_min": 0.0, "angle_max": 1.0, "angle_increment": 0.01,
|
|
114
|
+
"time_increment": 0.0, "scan_time": 0.0, "range_min": 0.2, "range_max": 10.0,
|
|
115
|
+
"ranges": [0.4, 0.6, 0.8], "intensities": [1.0, 1.0, 1.0],
|
|
116
|
+
}
|
|
117
|
+
odom = {
|
|
118
|
+
"header": {"stamp": {"sec": 0, "nanosec": 0}, "frame_id": "odom"},
|
|
119
|
+
"child_frame_id": "base_link",
|
|
120
|
+
"pose": {"pose": {"position": {"x": 1.0, "y": 0.0, "z": 0.0},
|
|
121
|
+
"orientation": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0}},
|
|
122
|
+
"covariance": [0.0] * 36},
|
|
123
|
+
"twist": {"twist": {"linear": {"x": 0.5, "y": 0.0, "z": 0.0},
|
|
124
|
+
"angular": {"x": 0.0, "y": 0.0, "z": 0.1}},
|
|
125
|
+
"covariance": [0.0] * 36},
|
|
126
|
+
}
|
|
127
|
+
db3 = FIXTURE_DIR / "sample_session.db3"
|
|
128
|
+
write_db3_fixture(
|
|
129
|
+
db3,
|
|
130
|
+
topics={
|
|
131
|
+
"/robot1/cmd_vel": "geometry_msgs/msg/Twist",
|
|
132
|
+
"/robot1/scan": "sensor_msgs/msg/LaserScan",
|
|
133
|
+
"/odom": "nav_msgs/msg/Odometry",
|
|
134
|
+
},
|
|
135
|
+
timed_messages={
|
|
136
|
+
"/robot1/cmd_vel": [(0, cmd_vel), (200_000_000, cmd_vel)],
|
|
137
|
+
"/robot1/scan": [(100_000_000, scan)],
|
|
138
|
+
"/odom": [(50_000_000, odom)],
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
lc = FIXTURE_DIR / "warehouse_demo"
|
|
142
|
+
(lc / "meta" / "episodes" / "chunk-000").mkdir(parents=True, exist_ok=True)
|
|
143
|
+
(lc / "data" / "chunk-000").mkdir(parents=True, exist_ok=True)
|
|
144
|
+
flat = [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]]
|
|
145
|
+
info = {
|
|
146
|
+
"codebase_version": "v3.0", "robot_type": "warehouse_bot", "fps": 10,
|
|
147
|
+
"total_episodes": 2, "total_frames": 3, "chunks_size": 1000,
|
|
148
|
+
"data_path": "data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet",
|
|
149
|
+
"video_path": None, "video_codec": None, "video_crf": None,
|
|
150
|
+
"features": {"observation.state": {"dtype": "float32", "shape": [3]},
|
|
151
|
+
"action": {"dtype": "float32", "shape": [3]}},
|
|
152
|
+
}
|
|
153
|
+
(lc / "meta" / "info.json").write_text(json.dumps(info))
|
|
154
|
+
eps = pa.table({
|
|
155
|
+
"episode_index": pa.array([0, 1], pa.int64()),
|
|
156
|
+
"length": pa.array([2, 1], pa.int64()),
|
|
157
|
+
"dataset_from_index": pa.array([0, 2], pa.int64()),
|
|
158
|
+
"dataset_to_index": pa.array([2, 3], pa.int64()),
|
|
159
|
+
"data/chunk_index": pa.array([0, 0], pa.int64()),
|
|
160
|
+
"data/file_index": pa.array([0, 0], pa.int64()),
|
|
161
|
+
})
|
|
162
|
+
pq.write_table(eps, lc / "meta" / "episodes" / "chunk-000" / "file-000.parquet")
|
|
163
|
+
d = 3
|
|
164
|
+
table = pa.table({
|
|
165
|
+
"observation.state": pa.FixedSizeListArray.from_arrays(np.array(flat, dtype=np.float32).ravel(), d),
|
|
166
|
+
"action": pa.FixedSizeListArray.from_arrays(np.array([[x * 0.5 for x in s] for s in flat], dtype=np.float32).ravel(), d),
|
|
167
|
+
})
|
|
168
|
+
pq.write_table(table, lc / "data" / "chunk-000" / "file-000.parquet")
|
|
169
|
+
(lc / "meta" / "stats.json").write_text(json.dumps({
|
|
170
|
+
"observation.state": {"mean": [0.0] * d, "std": [1.0] * d, "min": [0.0] * d, "max": [1.0] * d},
|
|
171
|
+
"action": {"mean": [0.0] * d, "std": [0.5] * d, "min": [0.0] * d, "max": [0.5] * d},
|
|
172
|
+
}))
|
|
173
|
+
(lc / ".versions.json").write_text(json.dumps({"versions": [{"tag": "v1", "created": "2026-11-01", "episodes": 2}], "current": "v1"}))
|
|
174
|
+
tracks = {
|
|
175
|
+
"schema_version": "1",
|
|
176
|
+
"clip_id": "clip-warehouse-1",
|
|
177
|
+
"max_age": 3,
|
|
178
|
+
"frames": [
|
|
179
|
+
{"frame": 0, "timestampNs": 0, "tracks": [
|
|
180
|
+
{"track_id": 1, "bbox": [10, 10, 60, 120], "class_id": 0, "confidence": 0.93,
|
|
181
|
+
"time_since_update": 0, "hit_streak": 4, "is_confirmed": True},
|
|
182
|
+
{"track_id": 2, "bbox": [200, 80, 260, 190], "class_id": 1, "confidence": 0.44,
|
|
183
|
+
"time_since_update": 2, "hit_streak": 1, "is_confirmed": False}]},
|
|
184
|
+
{"frame": 1, "timestampNs": 200_000_000, "tracks": [
|
|
185
|
+
{"track_id": 1, "bbox": [12, 10, 64, 120], "class_id": 0, "confidence": 0.91,
|
|
186
|
+
"time_since_update": 0, "hit_streak": 5, "is_confirmed": True}]},
|
|
187
|
+
],
|
|
188
|
+
}
|
|
189
|
+
(FIXTURE_DIR / "sample_tracks.json").write_text(json.dumps(tracks, indent=2))
|
|
190
|
+
world_dir = FIXTURE_DIR / "warehouse_sim"
|
|
191
|
+
world_dir.mkdir(exist_ok=True)
|
|
192
|
+
world_spec = {
|
|
193
|
+
"metadata": {"seed": "66", "name": "warehouse_sim"},
|
|
194
|
+
"scene_bounds_min": [-50.0, -50.0, 0.0],
|
|
195
|
+
"scene_bounds_max": [50.0, 50.0, 40.0],
|
|
196
|
+
"lighting": {"sun_intensity": 1.2, "sun_angle_elevation": 55.0, "sun_angle_azimuth": 200.0,
|
|
197
|
+
"ambient_intensity": 0.5, "shadow_distance": 300},
|
|
198
|
+
"weather": {"rain_intensity": 0.0, "cloud_coverage": 0.4, "fog_density": 0.05,
|
|
199
|
+
"fog_color_rgb": [0.8, 0.8, 0.9], "fog_distance": 900, "wind_speed": 0.8,
|
|
200
|
+
"temperature_celsius": 21},
|
|
201
|
+
"time_of_day": {"hour": 10, "minute": 0, "day_of_year": 1, "season": "summer"},
|
|
202
|
+
"rendering": {"profile": "edge", "resolution_width": 960, "resolution_height": 540, "fps": 30,
|
|
203
|
+
"ray_tracing": False, "motion_blur": False, "depth_of_field": False},
|
|
204
|
+
"sensors": {"rgb_enabled": True, "depth_enabled": True, "lidar_enabled": True, "thermal_enabled": False,
|
|
205
|
+
"lidar_channels": 16, "lidar_range_max": 100, "save_to_disk": True,
|
|
206
|
+
"output_format_rgb": "png", "output_format_depth": "npy", "output_format_lidar": "pcd"},
|
|
207
|
+
"objects": [
|
|
208
|
+
{"id": "crate_a", "name": "crateA", "type": "crate", "position": [2, 1, 0], "rotation": [0, 0, 0],
|
|
209
|
+
"scale": [1, 1, 1], "material": "wood", "physics_enabled": True},
|
|
210
|
+
{"id": "shelf_1", "name": "shelf", "type": "shelf", "position": [-6, 0, 0], "rotation": [0, 0, 90],
|
|
211
|
+
"scale": [2, 1, 3], "material": "metal", "physics_enabled": True},
|
|
212
|
+
],
|
|
213
|
+
}
|
|
214
|
+
(world_dir / "world_spec.json").write_text(json.dumps(world_spec, indent=2))
|
|
215
|
+
make_ni_db(FIXTURE_DIR / "pynetworkintel.db")
|
|
216
|
+
make_sf_db(FIXTURE_DIR / "syncfleet.db")
|
|
217
|
+
print(f"wrote adapter fixtures in {FIXTURE_DIR}: sample_session.db3, warehouse_demo/, sample_tracks.json, warehouse_sim/, pynetworkintel.db, syncfleet.db")
|
|
218
|
+
|
|
219
|
+
def make_mission_manifest() -> None:
|
|
220
|
+
"""mission.json v1 joining the adapter fixtures with real sha256 hashes."""
|
|
221
|
+
import hashlib
|
|
222
|
+
|
|
223
|
+
from pyrobostudio_sidecar.mission import sha256_of
|
|
224
|
+
|
|
225
|
+
def h(name: str) -> str:
|
|
226
|
+
return sha256_of(FIXTURE_DIR / name)
|
|
227
|
+
|
|
228
|
+
manifest = {
|
|
229
|
+
"schema_version": "1",
|
|
230
|
+
"mission_id": "mission-warehouse-001",
|
|
231
|
+
"run_id": "run-2026-11-04-a",
|
|
232
|
+
"robots": ["robot1", "robot2"],
|
|
233
|
+
"time_base": {"domain": "session_ns", "start_ns": 0, "end_ns": 10_000_000_000},
|
|
234
|
+
"artifacts": [
|
|
235
|
+
{"kind": "rosbag", "id": "sample_session", "uri": "sample_session.mcap", "sha256": h("sample_session.mcap"), "origin": "recorded"},
|
|
236
|
+
{"kind": "rosbag_db3", "id": "sample_session_db3", "uri": "sample_session.db3", "sha256": h("sample_session.db3"), "origin": "recorded"},
|
|
237
|
+
{"kind": "vision_tracks", "id": "clip-warehouse-1", "uri": "sample_tracks.json", "sha256": h("sample_tracks.json"), "origin": "recorded"},
|
|
238
|
+
{"kind": "world_spec", "id": "warehouse_sim", "uri": "warehouse_sim/world_spec.json", "sha256": h("warehouse_sim/world_spec.json"), "origin": "simulated"},
|
|
239
|
+
{"kind": "networkintel", "id": "pynetworkintel", "uri": "pynetworkintel.db", "sha256": h("pynetworkintel.db"), "origin": "simulated"},
|
|
240
|
+
{"kind": "syncfleet", "id": "syncfleet", "uri": "syncfleet.db", "sha256": h("syncfleet.db"), "origin": "simulated"},
|
|
241
|
+
],
|
|
242
|
+
"events": [
|
|
243
|
+
{"id": "ev-gap", "timestamp_ns": 1_000_000_000, "kind": "communication_gap", "label": "cmd_vel gap 0.8s (i=3..5)", "severity": "warn", "artifact": "sample_session"},
|
|
244
|
+
{"id": "ev-scan-anomaly", "timestamp_ns": 6_000_000_000, "kind": "scan_range_spike", "label": "scan ranges spike 2.0x", "severity": "error", "artifact": "sample_session"},
|
|
245
|
+
{"id": "ev-recovered", "timestamp_ns": 9_000_000_000, "kind": "recovery", "label": "teleop resumed", "severity": "info", "artifact": "sample_session"},
|
|
246
|
+
],
|
|
247
|
+
}
|
|
248
|
+
(FIXTURE_DIR / "mission_warehouse.json").write_text(json.dumps(manifest, indent=2))
|
|
249
|
+
print(f"wrote {FIXTURE_DIR / 'mission_warehouse.json'}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
make_fixture()
|
|
254
|
+
make_repo_fixtures()
|
|
255
|
+
make_mission_manifest()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Mission manifest v1 (competitive-analysis Gap 1 MVP).
|
|
2
|
+
|
|
3
|
+
A versioned mission.json joins PyRobo* artifacts (bags, tracks, world specs,
|
|
4
|
+
fleet stores) under one mission/run identity with real sha256 hashes and a
|
|
5
|
+
shared time base. Read-only; served from sidecar/fixtures.
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
REQUIRED_TOP = {"schema_version", "mission_id", "run_id", "robots", "time_base", "artifacts", "events"}
|
|
13
|
+
REQUIRED_ARTIFACT = {"kind", "id", "uri", "sha256", "origin"}
|
|
14
|
+
KNOWN_KINDS = {"rosbag", "rosbag_db3", "vision_tracks", "world_spec", "networkintel", "syncfleet", "terrain"}
|
|
15
|
+
KNOWN_ORIGINS = {"live", "recorded", "simulated", "derived"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MissionError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sha256_of(path: Path) -> str:
|
|
23
|
+
h = hashlib.sha256()
|
|
24
|
+
h.update(path.read_bytes())
|
|
25
|
+
return h.hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_manifest(data: dict[str, Any], base_dir: Path) -> dict[str, Any]:
|
|
29
|
+
missing = REQUIRED_TOP - set(data)
|
|
30
|
+
if missing:
|
|
31
|
+
raise MissionError(f"manifest missing keys {sorted(missing)}")
|
|
32
|
+
if data["schema_version"] != "1":
|
|
33
|
+
raise MissionError(f"unsupported manifest schema_version {data['schema_version']!r}")
|
|
34
|
+
tb = data["time_base"]
|
|
35
|
+
if not isinstance(tb, dict) or not {"domain", "start_ns", "end_ns"} <= set(tb):
|
|
36
|
+
raise MissionError("time_base must have domain/start_ns/end_ns")
|
|
37
|
+
if tb["end_ns"] <= tb["start_ns"]:
|
|
38
|
+
raise MissionError("time_base end_ns must be > start_ns")
|
|
39
|
+
seen: set[str] = set()
|
|
40
|
+
for art in data["artifacts"]:
|
|
41
|
+
miss = REQUIRED_ARTIFACT - set(art)
|
|
42
|
+
if miss:
|
|
43
|
+
raise MissionError(f"artifact missing keys {sorted(miss)}")
|
|
44
|
+
if art["kind"] not in KNOWN_KINDS:
|
|
45
|
+
raise MissionError(f"unknown artifact kind {art['kind']!r}")
|
|
46
|
+
if art["origin"] not in KNOWN_ORIGINS:
|
|
47
|
+
raise MissionError(f"unknown artifact origin {art['origin']!r}")
|
|
48
|
+
if art["id"] in seen:
|
|
49
|
+
raise MissionError(f"duplicate artifact id {art['id']!r}")
|
|
50
|
+
seen.add(art["id"])
|
|
51
|
+
p = base_dir / art["uri"]
|
|
52
|
+
if not p.exists():
|
|
53
|
+
raise MissionError(f"artifact uri not found: {art['uri']}")
|
|
54
|
+
actual = sha256_of(p)
|
|
55
|
+
if actual != art["sha256"]:
|
|
56
|
+
raise MissionError(f"artifact {art['id']}: sha256 mismatch (manifest stale)")
|
|
57
|
+
for ev in data["events"]:
|
|
58
|
+
if not {"id", "timestamp_ns", "kind", "label", "severity"} <= set(ev):
|
|
59
|
+
raise MissionError(f"event missing keys: {ev}")
|
|
60
|
+
if ev["severity"] not in {"info", "warn", "error"}:
|
|
61
|
+
raise MissionError(f"event {ev['id']}: bad severity {ev['severity']!r}")
|
|
62
|
+
if not tb["start_ns"] <= ev["timestamp_ns"] <= tb["end_ns"]:
|
|
63
|
+
raise MissionError(f"event {ev['id']}: timestamp outside mission time base")
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_manifest(path: Path) -> dict[str, Any]:
|
|
68
|
+
data = json.loads(path.read_text())
|
|
69
|
+
return validate_manifest(data, path.parent)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def mission_summary(data: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
return {
|
|
74
|
+
"missionId": data["mission_id"],
|
|
75
|
+
"runId": data["run_id"],
|
|
76
|
+
"robots": data["robots"],
|
|
77
|
+
"artifactCount": len(data["artifacts"]),
|
|
78
|
+
"eventCount": len(data["events"]),
|
|
79
|
+
"timeBase": data["time_base"],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def manifest_out(data: dict[str, Any]) -> dict[str, Any]:
|
|
84
|
+
arts = [{**a, "sha256Short": a["sha256"][:12]} for a in data["artifacts"]]
|
|
85
|
+
return {**data, "artifacts": arts}
|