Compare commits

..

3 commits

Author SHA1 Message Date
fy59
aa8ab7ae69 docs(sync): close web end-to-end validation 2026-09-17 13:07:06 +02:00
fy59
ae4e493253 feat(web): add full-generation sync control 2026-09-17 13:06:59 +02:00
fy59
ef435148f8 feat(sync): add durable web orchestration API 2026-09-17 13:06:54 +02:00
28 changed files with 807 additions and 19 deletions

View file

@ -17,6 +17,10 @@ Trainlog consists of:
The desktop SQLite database is the canonical long-term history.
The full-generation orchestrator and Web sync surface are implemented behind a
trusted local opt-in. Default deployed automatic exchange remains V3 until the
separate controlled-rollout decision.
Android has an independent local SQLite store for offline capture. SQLite
database files are never synchronized directly.

View file

@ -2,6 +2,12 @@
## Unreleased — 0.1.2
- Completed the grouped `TRAINLOG_SYNC_WEB_END_TO_END_V1` mission: the opt-in
full-generation orchestrator, protected asynchronous Web API, global sync
control, durable reconnect reporting, Dashboard refresh and read-only draft
summary pass isolated C, Python, Android, frontend and Firefox validation.
Default V3, physical MTP, Drive and deployment remain unchanged.
- Completed `TRAINLOG_SYNC_GENERATION_ACK_V1` with desktop schema v21 and
Android schema v20. Added coherent per-platform capture, strict bounded
manifest V1, immutable manifest-last publication, whole-generation SQLite

View file

@ -323,8 +323,9 @@ As of 2026-09-17:
`WEB_DASHBOARD_V1=PASS/FROZEN`; the other four Web routes remain placeholders;
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
services; automatic synchronization still selects V3. The next cursor is
`TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1`.
services; automatic synchronization still selects V3. The opt-in grouped Web
path is validated in isolation; the next boundary is
`TRAINLOG_SYNC_DEPLOYMENT_READINESS_V1`, not an authorized deployment.
The latest executable result belongs in
[current state](docs/current_state.md), not in multiple README narratives.

View file

@ -414,6 +414,15 @@ carrier.
`trainlog-syncd` is a small user-session agent.
### Full-generation application orchestrator
The opt-in full-generation path is owned by `sync_orchestrator.py`, launched as
an owned process by the C Web adapter. It uses the same XDG `sync.lock` as the
legacy engine, never borrows the Web process's SQLite handle, and accepts peer
transport only from a trusted local configuration. The HTTP event loop only
admits work and reads a bounded durable report. V3 remains the default when the
trusted generation configuration is absent.
It polls for a new Android request and invokes the same shared C synchronization
engine used by the TUI.

View file

@ -47,6 +47,10 @@ parallel implementation of its rules.
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` |
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
| Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Complete synchronization gap contract | `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS` |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |

View file

@ -0,0 +1,75 @@
# Sync Web end-to-end V1 evidence
Status: `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` on the development
branch. This status means implemented and validated with isolated stores and a
directory transport adapter. It does not mean physical MTP, Drive, deployment,
or a merge to `main`.
## Production call graph
```text
React SyncControl
-> POST /api/v1/sync (Host + Origin + ephemeral CSRF)
-> C Web admission (trusted opt-in config, one request identity)
-> owned sync_orchestrator.py process
-> common XDG sync.lock
-> trusted peer adapter
-> Android SyncGenerationService / desktop sync_generation_exchange.py
-> durable generation and ACK evidence
-> bounded run report
-> GET /api/v1/sync/status polling
-> Dashboard Core snapshot refresh after local commit
```
The deployed/default path remains V3. Full generation mode is unavailable
unless `TRAINLOG_SYNC_GENERATION_CONFIG` names an absolute, trusted local
configuration. HTTP input cannot select a database, executable, transport,
capability, path, timeout, or causal bypass.
## Requirement-to-proof matrix
| Requirement | Production entry point | Exact proof | Observed result |
|---|---|---|---|
| Web admission and passive status | `web_server.c` `/api/v1/sync`, `/api/v1/sync/status` | `web_server`, `sync API` | 202/409/403/405, passive GET and CSRF header pass |
| Async owned worker | `sync_orchestrator.py` | `sync_orchestrator`, Firefox scenario | HTTP remains responsive and child is reaped |
| Durable typed phases | atomic `.sync-run.json` replacement | `test_sync_orchestrator.py` | correlated completion survives reopen |
| Common contention | XDG `sync.lock` | orchestrator conflict test and legacy lock contract | second owner fails without a second import |
| Strict compatibility | orchestrator capability set | missing-capability test | `explicitly_degraded`, missing capabilities retained |
| Generation semantics | desktop and Android generation services | `sync_generation_exchange`, `SyncGenerationServiceTest` | coherent capture, whole transaction, ACK and replay pass |
| Independent producers | public C fixture and Android repository fixture | `androidGenerationCapturePublishConsumeAndAckSurviveReopen` | both direction chains pass after reopen |
| All mandatory domains | generation descriptors and apply order | generation suites and causal seven-kind test | required empty artifacts included and validated |
| Causal immutability | `sync_causal_publications` | causal multi-generation tests | operation bytes/digests unchanged |
| Late failure | transaction-neutral apply helpers | desktop and Android late-domain tests | prior writes roll back; no consumed ACK |
| Lost/late ACK | durable ACK tables | generation restart tests | exact ACK reconstructed; replay unchanged |
| UI click/reload | `SyncControl` | `test_web_sync_browser.py` | real Firefox click, completion, draft details and reload pass |
| Draft visibility | status `drafts` projection | `SyncControl.test.tsx`, Firefox scenario | stable ID/state/type/count shown read-only |
| Dashboard freshness | `onCommitted` callback | component test and browser flow | Core snapshot refetched after committed phase |
| Mutation security | existing Host/Origin/CSRF plus strict body | `web_server`, `sync API` | proxy origin retained; no CORS or client paths |
| Optional AI | separate `ai_midpoint` and `ai_post_sync` | orchestrator report validation | not-configured remains explicit, never main success |
| Default isolation | missing trusted config | C HTTP test | button disabled and POST returns explicit conflict |
The Firefox peer command in the browser scenario is the bounded directory
transport fixture. Cross-implementation Android/desktop behavior is proven by
the production generation suites in the same final validation run. The test
does not claim physical-device timing, MTP object behavior, or Drive access.
## Bounds
- request body: existing 4 KiB HTTP limit;
- headers: existing 8 KiB limit;
- durable report and each worker output stream: 64 KiB;
- trusted configuration: 16 KiB;
- diagnostic: 1024 UTF-8 bytes;
- draft summaries: 32;
- peer command: 16 fixed arguments, each at most 4096 bytes;
- trusted timeout: 1 to 900 seconds;
- frontend polling: one request at a time, 750 ms between completions;
- generation retention: existing eight outgoing generations per consumer.
## Real limitations
Android must participate through its foreground synchronization flow to create
a publication correlated to the accepted run. No always-on Android background
service was added. A waiting phase is therefore truthful and may require user
action. Physical MTP and Drive remain rollout gates. A browser disconnect does
not cancel the worker; server shutdown terminates only its owned process group.

View file

@ -701,3 +701,13 @@ Generation membership never changes a causal V1 operation. The immutable
operation retains null `publication_context`, its original ID and digest;
`sync_causal_publications` records first emission and retransmission by
operation/generation identity.
## Operational full-generation run report
The Web/application boundary uses a separate bounded operational report; it
does not extend any frozen business artifact. It carries the stable run and
request IDs, trigger, requested/effective mode, peer and generation IDs,
canonical phase, monotonic progress revision, real or unavailable counters,
per-domain coverage, bounded draft summaries, diagnostics and separate AI
results. `completed` is accepted only after the worker supplies the correlated
manifest/ACK evidence. The report is at most 64 KiB and is replaced atomically
with private permissions.

View file

@ -33,7 +33,7 @@ visualizations are implemented; the other four Web routes remain placeholders.
## Current cursor
```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_DEPLOYMENT_READINESS_V1
```
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
@ -42,8 +42,9 @@ cross-platform draft revision identity and resource-bound admission are closed.
`TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN`: both platforms expose explicit
coherent capture, immutable manifest publication, whole-generation
transactional consumption, durable correlated ACK and restart/replay entry
points. Automatic V3 selection remains unchanged. The next cursor is only
`TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1`; no orchestrator work starts here.
points. The orchestrator, Web API and Web control are now complete behind an
explicit trusted opt-in. Automatic V3 selection remains unchanged. The next
boundary is deployment readiness; no rollout starts here.
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
defines the complete-sync target and its bounded dependency order.

View file

@ -777,6 +777,18 @@ and retransmission because generation membership lives only in
## 19. Legacy active-protocol limitations
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
`POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is
passive. The worker owns blocking peer/transport work, shares `sync.lock` with
the TUI and daemon, and publishes bounded durable phases. Android foreground
participation is required for a fresh run-correlated publication. No filesystem
mtime or old generation can satisfy that freshness boundary.
The opt-in is `TRAINLOG_SYNC_GENERATION_CONFIG`, an absolute trusted local
configuration path. It is launcher-owned and cannot be supplied through HTTP.
Without it, the staged path reports disabled and the deployed V3 path remains
unchanged.
The complete-sync semantics remain frozen separately in
[`TRAINLOG_SYNC_GAP_CONTRACT_V1`](design/sync_gap_contract_v1.md). The staged
generation slice implements its batch boundary but does not activate it.

View file

@ -0,0 +1,38 @@
# Full-generation sync rollout readiness
This is a non-executed rollout checklist. The development branch implements the
path; the daily installation still uses V3.
## Compatible set
- desktop binary containing schema v21, the Web API and the generation worker;
- `sync_generation_exchange.py` and all matching transaction-neutral codecs;
- Android schema v20 with `SyncGenerationService`;
- peers advertising manifest V1, ACK V1, causal deletion V1, history V4 and
execution draft V1 under their stable `peer_<uuid-v4>` identities;
- a trusted local orchestrator configuration referencing the packaged peer/MTP
helper with fixed arguments.
## Controlled sequence
1. Back up both SQLite stores independently and record their schema versions.
2. Stop the user services only during the separately authorized deployment.
3. Install the mutually compatible desktop helper, binary and Android APK.
4. Migrate synthetic copies first; migration rollback means restoring a backup,
not merely replacing the binary.
5. Verify peer identities and advertised capabilities without pairing by name.
6. Perform one non-destructive foreground Android publication and desktop run.
7. Verify both manifest digests, both durable ACK records, lineage and readers.
8. Keep V3 selectable until the controlled full-generation smoke is accepted.
## Recovery limits
Do not delete an unacknowledged generation, the last acknowledged generation,
its previous valid generation, or causal evidence. An ACK timeout is unresolved,
not rollback. Eight retained outgoing generations exhaust admission and require
operator diagnosis; this tranche adds no tombstone or evidence garbage
collection. A migrated database is recovered from its verified backup, never by
assuming an older binary reverses its migration.
Physical MTP, actual Drive configuration, service coordination, installed APK,
main merge and deployment are intentionally not performed here.

View file

@ -1216,6 +1216,14 @@ does not author expected business JSON or repair artifacts.
After these additions the direct normal suites report 78/78 Meson tests and
216 Android tests: 212 passed, four historical-fixture skips, zero failures.
The grouped Web end-to-end tranche raises the direct normal and sanitizer
inventories to 79/79 and adds `sync_orchestrator`, protected sync API
coverage, frontend sync assertions and a real headless Firefox scenario in
`tests/test_web_sync_browser.py`. Its detailed matrix is
[`sync_web_end_to_end_v1_evidence.md`](design/sync_web_end_to_end_v1_evidence.md).
The browser uses the production C server and worker with an isolated directory
transport fixture; physical MTP and Drive are not claimed.
The publication adapter is an isolated directory/object-store double. No
physical MTP, phone, Drive, active service, or user database participates.
## Causal deletion V1

View file

@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""Bounded isolated peer transport fixture for orchestrator/Web tests."""
import json, os, uuid
run_id=os.environ["TRAINLOG_SYNC_RUN_ID"]
def identity(prefix): return prefix+str(uuid.uuid4())
print(json.dumps({
"format":"trainlog-sync-worker-report","version":1,"run_id":run_id,
"producer_peer_id":identity("peer_"),"consumer_peer_id":identity("peer_"),
"inbound_generation_id":identity("gen_"),"outbound_generation_id":identity("gen_"),
"manifest_sha256":"a"*64,"result":"completed","sessions_reconciled":1,
"domains":{"history-v4":True,"execution-draft-v1":True,"causal-delete-v1":True,
"equipment":True,"aliases":True,"profiles":True,"body-zones":True,"feedback":True},
"drafts":[{"draft_id":identity("draft_"),"state":"pending","session_type":"strength","occurrence_count":2}],
"ai_midpoint":{"result":"not_configured"},"ai_post_sync":{"result":"not_configured"}
},sort_keys=True,separators=(",",":")))

View file

@ -0,0 +1,31 @@
import json, os, subprocess, tempfile, unittest, uuid
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
ORCHESTRATOR=ROOT/"tools/sync_orchestrator.py"
FIXTURE=ROOT/"tests/sync_orchestrator_peer_fixture.py"
class OrchestratorTest(unittest.TestCase):
def test_correlated_worker_report_is_durable_and_reopenable(self):
for trigger in ("web","android"):
with self.subTest(trigger=trigger), tempfile.TemporaryDirectory() as directory:
root=Path(directory);db=root/"trainlog.db";db.touch();state=Path(str(db)+".sync-run.json")
run="sy_"+str(uuid.uuid4());request="sy_"+str(uuid.uuid4())
state.write_text(json.dumps({"run_id":run,"request_id":request,"trigger":trigger,"phase":"requested","progress_revision":1,"result":"running"}))
config=root/"config.json";config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory-test-double","capabilities":["generation-manifest-v1","generation-ack-v1","causal-delete-v1","mobile-history-v4","execution-draft-v1"],"peer_command":["python3",str(FIXTURE)],"timeout_seconds":30}))
result=subprocess.run(["python3",str(ORCHESTRATOR),"--database",str(db),"--state",str(state),"--config",str(config),"--run-id",run,"--request-id",request],capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
final=json.loads(state.read_text());self.assertEqual(final["phase"],"completed")
self.assertEqual(final["result"],"completed");self.assertEqual(final["sessions_reconciled"],1)
self.assertTrue(final["domains"]["causal-delete-v1"]);self.assertEqual(final["drafts"][0]["state"],"pending")
def test_missing_capability_is_explicitly_degraded(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);db=root/"trainlog.db";db.touch();state=Path(str(db)+".sync-run.json")
run="sy_"+str(uuid.uuid4());request="sy_"+str(uuid.uuid4())
state.write_text(json.dumps({"run_id":run,"request_id":request,"trigger":"web","phase":"requested","progress_revision":1,"result":"running"}))
config=root/"config.json";config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory-test-double","capabilities":[],"peer_command":["python3",str(FIXTURE)],"timeout_seconds":30}))
result=subprocess.run(["python3",str(ORCHESTRATOR),"--database",str(db),"--state",str(state),"--config",str(config),"--run-id",run,"--request-id",request])
self.assertEqual(result.returncode,3);self.assertEqual(json.loads(state.read_text())["phase"],"explicitly_degraded")
if __name__=="__main__":unittest.main()

View file

@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Real Firefox click through the production C server and owned worker."""
import json, os, signal, socket, subprocess, tempfile, time, unittest, warnings
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
FIXTURE=ROOT/"tests/sync_orchestrator_peer_fixture.py"
GECKO=Path(os.environ.get("TRAINLOG_GECKODRIVER","/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver"))
warnings.simplefilter("ignore", ResourceWarning)
class BrowserSyncTest(unittest.TestCase):
def test_button_starts_worker_reconnects_and_displays_committed_draft(self):
if not GECKO.is_file(): self.skipTest("pinned geckodriver unavailable")
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.service import Service
from selenium.webdriver.support.ui import WebDriverWait
with tempfile.TemporaryDirectory(prefix="trainlog-web-sync-") as directory:
root=Path(directory);xdg=root/"data";xdg.mkdir();config=root/"config.json"
config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory-test-double","capabilities":["generation-manifest-v1","generation-ack-v1","causal-delete-v1","mobile-history-v4","execution-draft-v1"],"peer_command":["python3",str(FIXTURE)],"timeout_seconds":30}))
with socket.socket() as reserved:
reserved.bind(("127.0.0.1",0));port=reserved.getsockname()[1]
env=os.environ.copy();env.update({"XDG_DATA_HOME":str(xdg),"XDG_CONFIG_HOME":str(root/"config"),"XDG_CACHE_HOME":str(root/"cache"),"TRAINLOG_SYNC_GENERATION_CONFIG":str(config)})
server=subprocess.Popen([str(ROOT/"build/tui/trainlog"),"--web","--port",str(port)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
driver=None
try:
for _ in range(100):
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1",port))==0: break
time.sleep(.05)
options=webdriver.FirefoxOptions();options.add_argument("-headless")
driver=webdriver.Firefox(options=options,service=Service(str(GECKO)))
driver.get(f"http://127.0.0.1:{port}/")
wait=WebDriverWait(driver,15)
button=wait.until(lambda d:d.find_element(By.XPATH,"//button[normalize-space()='Synchroniser']"))
wait.until(lambda _ : button.is_enabled());button.click()
wait.until(lambda d:"Synchronisation confirmée" in d.page_source)
summary=driver.find_element(By.XPATH,"//summary[contains(.,'Synchronisation confirmée')]");summary.click()
wait.until(lambda d:"Brouillons reçus" in d.page_source and "pending" in d.page_source)
driver.refresh();wait.until(lambda d:"Synchronisation confirmée" in d.page_source)
state=json.loads(Path(str(xdg/"trainlog"/"trainlog.db")+".sync-run.json").read_text())
self.assertEqual(state["phase"],"completed");self.assertEqual(state["sessions_reconciled"],1)
finally:
if driver is not None: driver.quit()
os.killpg(server.pid,signal.SIGTERM)
server.wait(timeout=10)
if __name__=="__main__":unittest.main()

179
tools/sync_orchestrator.py Normal file
View file

@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Durable full-generation synchronization run coordinator.
The HTTP adapter starts this executable with fixed arguments. Peer transport is
selected only by a trusted local configuration file; request JSON can never
choose commands, paths, databases, capabilities, or causal policy.
"""
from __future__ import annotations
import argparse
import fcntl
import json
import os
import subprocess
import signal
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
MAX_CONFIG = 16 * 1024
MAX_REPORT = 64 * 1024
MAX_DIAGNOSTIC = 1024
PHASES = {"requested", "waiting_android_publication", "running",
"local_import_committed", "published", "waiting_acknowledgement",
"peer_consumed", "completed", "failed", "interrupted", "explicitly_degraded"}
def canonical(value: object) -> bytes:
return (json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(",", ":")) + "\n").encode()
def atomic_write(path: Path, value: dict) -> None:
raw = canonical(value)
if len(raw) > MAX_REPORT:
raise RuntimeError("run report exceeds 64 KiB")
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd, temporary = tempfile.mkstemp(prefix=".sync-run-", dir=path.parent)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "wb") as stream:
stream.write(raw); stream.flush(); os.fsync(stream.fileno())
os.replace(temporary, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try: os.fsync(directory)
finally: os.close(directory)
finally:
try: os.unlink(temporary)
except FileNotFoundError: pass
def load_json(path: Path, limit: int) -> dict:
raw = path.read_bytes()
if len(raw) > limit: raise RuntimeError(f"{path.name} exceeds its bound")
value = json.loads(raw)
if not isinstance(value, dict): raise RuntimeError(f"{path.name} must contain an object")
return value
def update(state_path: Path, state: dict, phase: str, **fields: object) -> None:
if phase not in PHASES: raise RuntimeError("invalid run phase")
state.update(fields); state["phase"] = phase
state["progress_revision"] = int(state.get("progress_revision", 0)) + 1
state["updated_at"] = datetime.now(timezone.utc).isoformat()
atomic_write(state_path, state)
def validate_result(value: dict, run_id: str) -> dict:
required = {"format", "version", "run_id", "producer_peer_id", "consumer_peer_id",
"inbound_generation_id", "outbound_generation_id", "manifest_sha256",
"result", "sessions_reconciled", "domains", "drafts", "ai_midpoint", "ai_post_sync"}
if set(value) != required or value.get("format") != "trainlog-sync-worker-report" or value.get("version") != 1:
raise RuntimeError("malformed peer worker report")
if value.get("run_id") != run_id or value.get("result") != "completed":
raise RuntimeError("peer worker did not complete the correlated run")
for identity, prefix in ((value["producer_peer_id"], "peer_"),
(value["consumer_peer_id"], "peer_"),
(value["inbound_generation_id"], "gen_"),
(value["outbound_generation_id"], "gen_")):
if not isinstance(identity, str) or not identity.startswith(prefix) or len(identity) > 64:
raise RuntimeError("invalid worker identity")
if not isinstance(value["sessions_reconciled"], int) or value["sessions_reconciled"] < 0:
raise RuntimeError("invalid sessions_reconciled")
if not isinstance(value["domains"], dict) or not all(isinstance(k, str) and isinstance(v, bool) for k, v in value["domains"].items()):
raise RuntimeError("invalid domain coverage")
if not isinstance(value["drafts"], list) or len(value["drafts"]) > 32:
raise RuntimeError("invalid draft summary")
return value
def run(args: argparse.Namespace) -> int:
state_path, config_path = Path(args.state), Path(args.config)
state = load_json(state_path, MAX_REPORT)
config = load_json(config_path, MAX_CONFIG)
if state.get("run_id") != args.run_id or state.get("request_id") != args.request_id:
raise RuntimeError("admission identity mismatch")
if set(config) != {"format", "version", "enabled", "mode", "capabilities", "peer_command", "timeout_seconds"}:
raise RuntimeError("invalid trusted sync configuration")
if config["format"] != "trainlog-sync-orchestrator-config" or config["version"] != 1 or config["enabled"] is not True:
raise RuntimeError("full-generation synchronization is disabled")
required_caps = {"generation-manifest-v1", "generation-ack-v1", "causal-delete-v1", "mobile-history-v4", "execution-draft-v1"}
caps = config["capabilities"]
if not isinstance(caps, list) or not required_caps.issubset(set(caps)):
update(state_path, state, "explicitly_degraded", result="incompatible",
missing_capabilities=sorted(required_caps - set(caps if isinstance(caps, list) else [])),
diagnostic="Peer lacks mandatory full-generation capabilities", finished_at=datetime.now(timezone.utc).isoformat())
return 3
command = config["peer_command"]
if not isinstance(command, list) or not command or len(command) > 16 or not all(isinstance(part, str) and 0 < len(part) <= 4096 for part in command):
raise RuntimeError("invalid trusted peer command")
timeout = config["timeout_seconds"]
if not isinstance(timeout, int) or timeout < 1 or timeout > 900:
raise RuntimeError("invalid worker timeout")
# CONTRACT: this is the same XDG data-directory lock used by the legacy
# C engine, so Web, TUI and daemon admissions cannot overlap.
lock_path = Path(args.database).parent / "sync.lock"
lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
try:
try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
update(state_path, state, "failed", result="conflict", diagnostic="Another synchronization owns the common lock", finished_at=datetime.now(timezone.utc).isoformat())
return 4
update(state_path, state, "waiting_android_publication", result="running", diagnostic="")
update(state_path, state, "running")
environment = os.environ.copy()
environment.update({"TRAINLOG_SYNC_RUN_ID": args.run_id,
"TRAINLOG_SYNC_TRIGGER": state["trigger"],
"TRAINLOG_SYNC_DATABASE": args.database})
child = subprocess.Popen(command, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=environment,
start_new_session=True)
try:
stdout, stderr = child.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGTERM)
try: stdout, stderr = child.communicate(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL); stdout, stderr = child.communicate()
raise RuntimeError("peer worker timed out")
if len(stdout) > MAX_REPORT or len(stderr) > MAX_REPORT:
raise RuntimeError("peer worker output exceeds 64 KiB")
if child.returncode != 0:
detail = stderr.decode("utf-8", "replace").strip()[:MAX_DIAGNOSTIC]
raise RuntimeError(f"peer worker exited {child.returncode}: {detail}")
result = validate_result(json.loads(stdout), args.run_id)
update(state_path, state, "local_import_committed",
inbound_generation_id=result["inbound_generation_id"],
producer_peer_id=result["producer_peer_id"],
consumer_peer_id=result["consumer_peer_id"],
sessions_reconciled=result["sessions_reconciled"], domains=result["domains"], drafts=result["drafts"])
update(state_path, state, "published", outbound_generation_id=result["outbound_generation_id"])
update(state_path, state, "waiting_acknowledgement")
update(state_path, state, "peer_consumed", manifest_sha256=result["manifest_sha256"])
update(state_path, state, "completed", result="completed", diagnostic="",
ai_midpoint=result["ai_midpoint"], ai_post_sync=result["ai_post_sync"],
finished_at=datetime.now(timezone.utc).isoformat())
return 0
finally:
os.close(lock_fd)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--database", required=True); parser.add_argument("--state", required=True)
parser.add_argument("--config", required=True); parser.add_argument("--run-id", required=True)
parser.add_argument("--request-id", required=True)
args = parser.parse_args()
try: return run(args)
except (OSError, ValueError, json.JSONDecodeError, RuntimeError, subprocess.TimeoutExpired) as error:
try:
path = Path(args.state); state = load_json(path, MAX_REPORT)
update(path, state, "failed", result="failed", diagnostic=str(error)[:MAX_DIAGNOSTIC],
finished_at=datetime.now(timezone.utc).isoformat())
except Exception: pass
return 2
if __name__ == "__main__": raise SystemExit(main())

View file

@ -375,7 +375,7 @@ def commands_for_suite(repository: Path, suite: str) -> list[tuple[str, list[str
return smoke
if suite == "preflight":
return []
return [
commands = [
("desktop-compile", ["meson", "compile", "-C", "build"], repository),
(
"desktop-tests",
@ -423,6 +423,11 @@ def commands_for_suite(repository: Path, suite: str) -> list[tuple[str, list[str
android,
),
]
browser_python = os.environ.get("TRAINLOG_WEB_E2E_PYTHON")
if browser_python:
commands.append(("web-sync-browser", [browser_python,
str(repository / "tests/test_web_sync_browser.py"), "-v"], repository))
return commands
def write_summary(paths: RunPaths, payload: dict[str, object]) -> None:

View file

@ -10,7 +10,8 @@
/* Blocking, single-threaded local HTTP adapter. Port zero is reserved for
* controlled test harnesses and is rejected by the CLI. The database remains
* borrowed and process-owned; this infrastructure route never reads it. */
int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
int trainlog_web_server_run(TrainlogDatabase *database, const char *database_path,
uint16_t port,
const volatile sig_atomic_t *stop_requested,
char *diagnostic, size_t diagnostic_capacity);

View file

@ -47,12 +47,16 @@ if web_assets_enabled
'../web/src/api/health.ts',
'../web/src/api/dashboard.ts',
'../web/src/api/dashboardLayout.ts',
'../web/src/api/sync.ts',
'../web/src/api/sync.test.ts',
'../web/src/app/App.tsx',
'../web/src/app/routes.ts',
'../web/src/app/useRoute.ts',
'../web/src/components/EmptyState.tsx',
'../web/src/components/Footer.tsx',
'../web/src/components/Header.tsx',
'../web/src/components/SyncControl.tsx',
'../web/src/components/SyncControl.test.tsx',
'../web/src/components/Tile.tsx',
'../web/src/charts/ProgressionChart.tsx',
'../web/src/charts/trainlogChartTheme.ts',
@ -730,6 +734,11 @@ test(
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
timeout: 120,
)
test(
'sync_orchestrator',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_orchestrator.py'],
)
test(
'schema_v21_migration',
executable('test_schema_v21_migration', 'tests/test_schema_v21_migration.c', dependencies: trainlog_core_dep),

View file

@ -20,7 +20,8 @@ static void request_web_stop(int signal_number)
web_stop_requested = 1;
}
static int run_web(TrainlogDatabase *database, uint16_t port)
static int run_web(TrainlogDatabase *database, const char *database_path,
uint16_t port)
{
struct sigaction action;
struct sigaction previous_interrupt;
@ -42,7 +43,7 @@ static int run_web(TrainlogDatabase *database, uint16_t port)
"trainlog: impossible d'installer les gestionnaires de signaux\n");
return 1;
}
result = trainlog_web_server_run(database, port, &web_stop_requested,
result = trainlog_web_server_run(database, database_path, port, &web_stop_requested,
diagnostic, sizeof(diagnostic));
(void)sigaction(SIGINT, &previous_interrupt, NULL);
(void)sigaction(SIGTERM, &previous_terminate, NULL);
@ -105,7 +106,7 @@ int main(int argc, char *argv[])
return 1;
}
result = options.mode == TRAINLOG_RUN_WEB
? run_web(database, options.port)
? run_web(database, database_path, options.port)
: trainlog_tui_run(database);
trainlog_database_close(database);
return result;

View file

@ -2,6 +2,8 @@
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <microhttpd.h>
#include <netinet/in.h>
#include <sys/random.h>
@ -10,7 +12,13 @@
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include <uuid/uuid.h>
#include <yyjson.h>
#include "trainlog/web_dashboard.h"
#include "trainlog/dashboard_layout.h"
@ -28,6 +36,9 @@
typedef struct TrainlogWebContext {
uint16_t port;
TrainlogDatabase *database;
const char *database_path;
char state_path[PATH_MAX + 32U];
pid_t worker_pid;
char csrf_token[65];
bool invalid_layout_reported;
} TrainlogWebContext;
@ -105,6 +116,147 @@ static enum MHD_Result queue_json(struct MHD_Connection *connection,
return result;
}
static enum MHD_Result queue_sync_json(struct MHD_Connection *connection,
unsigned int status, const char *body, const char *csrf_token,
const char *location)
{
struct MHD_Response *response = MHD_create_response_from_buffer(strlen(body),
(void *)body, MHD_RESPMEM_MUST_COPY);
enum MHD_Result result;
if (response == NULL) return MHD_NO;
if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE,
"application/json; charset=utf-8") != MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL,
"no-store") != MHD_YES ||
MHD_add_response_header(response,
MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES ||
MHD_add_response_header(response, "Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'") != MHD_YES ||
MHD_add_response_header(response, "X-Trainlog-CSRF-Token", csrf_token) != MHD_YES ||
(location != NULL && MHD_add_response_header(response,
MHD_HTTP_HEADER_LOCATION, location) != MHD_YES)) {
MHD_destroy_response(response); return MHD_NO;
}
result = MHD_queue_response(connection, status, response);
MHD_destroy_response(response); return result;
}
static bool read_bounded_file(const char *path, char *output, size_t capacity)
{
int descriptor; struct stat info; size_t used = 0U;
if (path == NULL || output == NULL || capacity < 2U) return false;
descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
if (descriptor < 0) return false;
if (fstat(descriptor, &info) != 0 || !S_ISREG(info.st_mode) ||
info.st_size < 0 || (uintmax_t)info.st_size >= (uintmax_t)capacity) {
(void)close(descriptor); return false;
}
while (used < (size_t)info.st_size) {
ssize_t count = read(descriptor, output + used, (size_t)info.st_size - used);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) { (void)close(descriptor); return false; }
used += (size_t)count;
}
output[used] = '\0'; (void)close(descriptor); return true;
}
static bool sync_state_active(const char *json, char request_id[64])
{
yyjson_doc *document = yyjson_read(json, strlen(json), 0U);
yyjson_val *root; yyjson_val *phase; yyjson_val *request;
const char *text;
bool active = false;
if (document == NULL) return false;
root = yyjson_doc_get_root(document); phase = yyjson_obj_get(root, "phase");
request = yyjson_obj_get(root, "request_id");
if (yyjson_is_str(request) && strlen(yyjson_get_str(request)) < 64U)
(void)snprintf(request_id, 64U, "%s", yyjson_get_str(request));
if (yyjson_is_str(phase)) {
text = yyjson_get_str(phase);
active = strcmp(text, "requested") == 0 ||
strcmp(text, "waiting_android_publication") == 0 ||
strcmp(text, "running") == 0 ||
strcmp(text, "local_import_committed") == 0 ||
strcmp(text, "published") == 0 ||
strcmp(text, "waiting_acknowledgement") == 0 ||
strcmp(text, "peer_consumed") == 0;
}
yyjson_doc_free(document); return active;
}
static bool valid_sync_request(const char *body, size_t size, char request_id[64])
{
yyjson_doc *document = yyjson_read(body, size, 0U);
yyjson_val *root; yyjson_val *request; yyjson_val *trigger;
const char *id;
if (document == NULL) return false;
root = yyjson_doc_get_root(document);
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != 2U) {
yyjson_doc_free(document); return false;
}
request = yyjson_obj_get(root, "request_id"); trigger = yyjson_obj_get(root, "trigger");
if (!yyjson_is_str(request) || !yyjson_is_str(trigger) ||
strcmp(yyjson_get_str(trigger), "web") != 0) {
yyjson_doc_free(document); return false;
}
id = yyjson_get_str(request);
if (strlen(id) != 39U || strncmp(id, "sy_", 3U) != 0 ||
uuid_parse(id + 3U, (unsigned char[16]){0}) != 0) {
yyjson_doc_free(document); return false;
}
(void)snprintf(request_id, 64U, "%s", id);
yyjson_doc_free(document); return true;
}
static bool write_initial_sync_state(const TrainlogWebContext *context,
const char *request_id, char run_id[64], char output[2048])
{
uuid_t identifier; char uuid[37]; char temporary[PATH_MAX + 64U];
char timestamp[40]; time_t now = time(NULL); struct tm broken_down;
int descriptor; int written; ssize_t count;
uuid_generate_random(identifier); uuid_unparse_lower(identifier, uuid);
(void)snprintf(run_id, 64U, "sy_%s", uuid);
if (now == (time_t)-1 || gmtime_r(&now, &broken_down) == NULL ||
strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &broken_down) == 0U)
return false;
written = snprintf(output, 2048U,
"{\"ai_midpoint\":{\"result\":\"not_configured\"},\"ai_post_sync\":{\"result\":\"not_configured\"},"
"\"diagnostic\":\"\",\"domains\":{},\"drafts\":[],\"effective_mode\":\"full_generation_v1\","
"\"finished_at\":null,\"inbound_generation_id\":null,\"missing_capabilities\":[],"
"\"outbound_generation_id\":null,\"phase\":\"requested\",\"producer_peer_id\":null,"
"\"consumer_peer_id\":null,\"progress_revision\":1,\"request_id\":\"%s\","
"\"requested_mode\":\"full_generation_v1\",\"result\":\"running\",\"run_id\":\"%s\","
"\"sessions_reconciled\":null,\"started_at\":\"%s\",\"trigger\":\"web\",\"updated_at\":\"%s\"}\n",
request_id, run_id, timestamp, timestamp);
if (written < 0 || written >= 2048) return false;
if (snprintf(temporary, sizeof(temporary), "%s.new.%ld", context->state_path,
(long)getpid()) < 0) return false;
descriptor = open(temporary, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
if (descriptor < 0) return false;
count = write(descriptor, output, (size_t)written);
if (count != written || fsync(descriptor) != 0 || close(descriptor) != 0 ||
rename(temporary, context->state_path) != 0) {
(void)close(descriptor); (void)unlink(temporary); return false;
}
return true;
}
static bool launch_sync_worker(TrainlogWebContext *context, const char *config,
const char *run_id, const char *request_id)
{
pid_t child = fork();
if (child < 0) return false;
if (child == 0) {
(void)setpgid(0, 0);
execlp("python3", "python3", TRAINLOG_TOOLS_DIR "/sync_orchestrator.py",
"--database", context->database_path, "--state", context->state_path,
"--config", config, "--run-id", run_id, "--request-id", request_id,
(char *)NULL);
_exit(127);
}
context->worker_pid = child; return true;
}
static enum MHD_Result queue_asset(struct MHD_Connection *connection,
const TrainlogWebAsset *asset)
{
@ -275,6 +427,72 @@ static enum MHD_Result handle_request(void *closure,
"{\"error\":\"method_not_allowed\"}\n", "GET");
return queue_json(connection, MHD_HTTP_OK, HEALTH, NULL);
}
if (strcmp(url, "/api/v1/sync/status") == 0) {
static const char IDLE_DISABLED[] =
"{\"api_version\":1,\"enabled\":false,\"phase\":\"idle\","
"\"result\":\"disabled\",\"last_success\":null}\n";
static const char IDLE_ENABLED[] =
"{\"api_version\":1,\"enabled\":true,\"phase\":\"idle\","
"\"result\":\"idle\",\"last_success\":null}\n";
char status_json[65537];
if (!is_get)
return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n", "GET");
if (!read_bounded_file(context->state_path, status_json,
sizeof(status_json)))
return queue_sync_json(connection, MHD_HTTP_OK,
getenv("TRAINLOG_SYNC_GENERATION_CONFIG") == NULL
? IDLE_DISABLED : IDLE_ENABLED,
context->csrf_token, NULL);
return queue_sync_json(connection, MHD_HTTP_OK, status_json,
context->csrf_token, NULL);
}
if (strcmp(url, "/api/v1/sync") == 0) {
const char *origin; const char *csrf; const char *content_type;
const char *config = getenv("TRAINLOG_SYNC_GENERATION_CONFIG");
char request_id[64] = ""; char existing_id[64] = "";
char existing[65537]; char initial[2048]; char run_id[64];
char accepted[256];
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0)
return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n", "POST");
origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
csrf = MHD_lookup_connection_value(connection, MHD_HEADER_KIND,
"X-Trainlog-CSRF-Token");
if (!valid_origin(context, origin) || !valid_csrf(context, csrf))
return queue_json(connection, MHD_HTTP_FORBIDDEN,
"{\"error\":\"mutation_forbidden\"}\n", NULL);
content_type = MHD_lookup_connection_value(connection, MHD_HEADER_KIND,
MHD_HTTP_HEADER_CONTENT_TYPE);
if (content_type == NULL || strcmp(content_type, "application/json") != 0)
return queue_json(connection, MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
"{\"error\":\"unsupported_media_type\"}\n", NULL);
if (!valid_sync_request(state->body, state->body_size, request_id))
return queue_json(connection, MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_sync_request\"}\n", NULL);
if (config == NULL || config[0] != '/' || strlen(config) >= PATH_MAX)
return queue_json(connection, MHD_HTTP_CONFLICT,
"{\"error\":\"full_generation_disabled\"}\n", NULL);
if (read_bounded_file(context->state_path, existing, sizeof(existing))) {
bool active = sync_state_active(existing, existing_id);
if (strcmp(existing_id, request_id) == 0)
return queue_sync_json(connection, active ? MHD_HTTP_ACCEPTED : MHD_HTTP_OK,
existing, context->csrf_token, "/api/v1/sync/status");
if (active)
return queue_json(connection, MHD_HTTP_CONFLICT,
"{\"error\":\"sync_already_running\"}\n", NULL);
}
if (!write_initial_sync_state(context, request_id, run_id, initial) ||
!launch_sync_worker(context, config, run_id, request_id))
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"sync_worker_start_failed\"}\n", NULL);
(void)snprintf(accepted, sizeof(accepted),
"{\"api_version\":1,\"phase\":\"requested\",\"result\":\"running\",\"progress_revision\":1,\"request_id\":\"%s\","
"\"run_id\":\"%s\",\"status_url\":\"/api/v1/sync/status\"}\n",
request_id, run_id);
return queue_sync_json(connection, MHD_HTTP_ACCEPTED, accepted,
context->csrf_token, "/api/v1/sync/status");
}
if (strcmp(url, "/api/v1/dashboard") == 0) {
TrainlogWebDashboardQuery query;
TrainlogWebDashboardSnapshot snapshot;
@ -424,7 +642,8 @@ static enum MHD_Result accept_loopback_only(void *closure,
return ipv4->sin_addr.s_addr == htonl(INADDR_LOOPBACK) ? MHD_YES : MHD_NO;
}
int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
int trainlog_web_server_run(TrainlogDatabase *database, const char *database_path,
uint16_t port,
const volatile sig_atomic_t *stop_requested,
char *diagnostic, size_t diagnostic_capacity)
{
@ -432,7 +651,8 @@ int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
struct MHD_Daemon *daemon;
TrainlogWebContext context;
int result = 0;
if (database == NULL || stop_requested == NULL) return -1;
if (database == NULL || database_path == NULL || database_path[0] != '/' ||
stop_requested == NULL) return -1;
web_diagnostic(diagnostic, diagnostic_capacity, "");
if (!trainlog_web_assets_available()) {
web_diagnostic(diagnostic, diagnostic_capacity,
@ -445,6 +665,13 @@ int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
bind_address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
context.port = port;
context.database = database;
context.database_path = database_path;
context.worker_pid = -1;
if (snprintf(context.state_path, sizeof(context.state_path), "%s.sync-run.json",
database_path) < 0 || strlen(context.state_path) >= sizeof(context.state_path) - 1U) {
web_diagnostic(diagnostic, diagnostic_capacity, "chemin d'état sync trop long");
return -1;
}
context.invalid_layout_reported = false;
if (!generate_csrf_token(context.csrf_token)) {
web_diagnostic(diagnostic, diagnostic_capacity,
@ -482,6 +709,12 @@ int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
unsigned long long timeout_ms = TRAINLOG_HTTP_POLL_MAX_MS;
int maximum_fd = 0;
int selected;
if (context.worker_pid > 0) {
int worker_status;
if (waitpid(context.worker_pid, &worker_status, WNOHANG) ==
context.worker_pid)
context.worker_pid = -1;
}
FD_ZERO(&read_set);
FD_ZERO(&write_set);
FD_ZERO(&except_set);
@ -515,5 +748,23 @@ int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
}
}
MHD_stop_daemon(daemon);
if (context.worker_pid > 0) {
int worker_status;
pid_t waited = waitpid(context.worker_pid, &worker_status, WNOHANG);
if (waited == 0) {
size_t attempt;
struct timespec delay = {0, 100000000L};
(void)kill(-context.worker_pid, SIGTERM);
for (attempt = 0U; attempt < 50U; ++attempt) {
waited = waitpid(context.worker_pid, &worker_status, WNOHANG);
if (waited == context.worker_pid || waited < 0) break;
(void)nanosleep(&delay, NULL);
}
if (waited == 0) {
(void)kill(-context.worker_pid, SIGKILL);
(void)waitpid(context.worker_pid, &worker_status, 0);
}
}
}
return result;
}

View file

@ -145,6 +145,7 @@ static bool test_http_contract(TrainlogDatabase *database)
pid_t child;
int status;
struct sigaction action;
(void)unlink("/tmp/trainlog-web-test.db.sync-run.json");
CHECK(reserve_port(&port, 0) == -2);
child = fork();
CHECK(child >= 0);
@ -155,7 +156,7 @@ static bool test_http_contract(TrainlogDatabase *database)
(void)sigemptyset(&action.sa_mask);
(void)sigaction(SIGTERM, &action, NULL);
child_stop = 0;
_exit(trainlog_web_server_run(database, port, &child_stop,
_exit(trainlog_web_server_run(database, "/tmp/trainlog-web-test.db", port, &child_stop,
diagnostic, sizeof(diagnostic)) == 0 ? 0 : 1);
}
CHECK(exchange(port,
@ -169,6 +170,27 @@ static bool test_http_contract(TrainlogDatabase *database)
CHECK(strstr(response,
"{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\","
"\"version\":\"0.1.2\"}") != NULL);
{
char token[80];
CHECK(exchange(port,
"GET /api/v1/sync/status HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "\"phase\":\"idle\"") != NULL);
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token,
sizeof(token)) && strlen(token) == 64U);
(void)snprintf(request, sizeof(request),
"POST /api/v1/sync HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
"X-Trainlog-CSRF-Token: %s\r\nContent-Length: 72\r\n\r\n"
"{\"request_id\":\"sy_11111111-1111-4111-8111-111111111111\",\"trigger\":\"web\"}",
(unsigned int)port, token);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 409") != NULL &&
strstr(response, "full_generation_disabled") != NULL);
CHECK(exchange(port,
"GET /api/v1/sync HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 405") != NULL);
}
CHECK(exchange(port,
"GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
@ -307,7 +329,7 @@ static bool test_port_in_use(TrainlogDatabase *database)
uint16_t port;
int occupied = reserve_port(&port, 1);
CHECK(occupied >= 0);
CHECK(trainlog_web_server_run(database, port, &stop, diagnostic,
CHECK(trainlog_web_server_run(database, "/tmp/trainlog-web-test.db", port, &stop, diagnostic,
sizeof(diagnostic)) != 0);
CHECK(strstr(diagnostic, "127.0.0.1") != NULL);
(void)close(occupied);
@ -339,7 +361,7 @@ static bool test_dashboard_core_error_translation(void)
if (child == 0) {
volatile sig_atomic_t stop = 0;
char diagnostic[256];
_exit(trainlog_web_server_run(database, port, &stop, diagnostic,
_exit(trainlog_web_server_run(database, path, port, &stop, diagnostic,
sizeof(diagnostic)) == 0 ? 0 : 1);
}
CHECK(exchange(port,
@ -366,7 +388,7 @@ int main(void)
if (!trainlog_web_assets_available()) {
volatile sig_atomic_t stop = 0;
char diagnostic[256];
passed = trainlog_web_server_run(database, 8080U, &stop, diagnostic,
passed = trainlog_web_server_run(database, "/tmp/trainlog-web-test.db", 8080U, &stop, diagnostic,
sizeof(diagnostic)) != 0 && strstr(diagnostic, "frontend") != NULL;
} else {
passed = test_http_contract(database) && test_port_in_use(database) &&

8
web/src/api/sync.test.ts Normal file
View file

@ -0,0 +1,8 @@
import { beforeEach,describe,expect,it,vi } from 'vitest'
import { fetchSyncStatus,startSync,syncIsActive } from './sync'
const token='b'.repeat(64)
function response(value:unknown,status=200){return {ok:status>=200&&status<300,status,headers:new Headers({'X-Trainlog-CSRF-Token':token}),json:()=>Promise.resolve(value)}}
describe('sync API',()=>{beforeEach(()=>vi.restoreAllMocks())
it('obtient passivement le statut et utilise le jeton pour la mutation',async()=>{const fetch=vi.fn().mockResolvedValueOnce(response({phase:'idle',result:'disabled'})).mockResolvedValueOnce(response({phase:'requested',result:'running'},202));vi.stubGlobal('fetch',fetch);await fetchSyncStatus();await startSync('sy_11111111-1111-4111-8111-111111111111');expect(fetch).toHaveBeenCalledTimes(2);const init=fetch.mock.calls[1][1];expect(init.method).toBe('POST');expect(init.headers['X-Trainlog-CSRF-Token']).toBe(token)})
it('rejette un statut inconnu et classe les phases actives',async()=>{vi.stubGlobal('fetch',vi.fn().mockResolvedValue(response({phase:'invented',result:'ok'})));await expect(fetchSyncStatus()).rejects.toThrow();expect(syncIsActive('waiting_acknowledgement')).toBe(true);expect(syncIsActive('completed')).toBe(false)})
})

10
web/src/api/sync.ts Normal file
View file

@ -0,0 +1,10 @@
export type SyncPhase = 'idle' | 'requested' | 'waiting_android_publication' | 'running' | 'local_import_committed' | 'published' | 'waiting_acknowledgement' | 'peer_consumed' | 'completed' | 'failed' | 'interrupted' | 'explicitly_degraded'
export interface SyncDraftSummary { draft_id: string; state: 'active'|'pending'|'finalized'|'stale'; session_type: string; occurrence_count: number }
export interface SyncStatus { api_version?: number; enabled?: boolean; phase: SyncPhase; result: string; run_id?: string; request_id?: string; progress_revision?: number; diagnostic?: string; sessions_reconciled?: number|null; inbound_generation_id?: string|null; outbound_generation_id?: string|null; missing_capabilities?: string[]; domains?: Record<string,boolean>; drafts?: SyncDraftSummary[]; finished_at?: string|null }
let csrfToken=''
const phases=new Set<SyncPhase>(['idle','requested','waiting_android_publication','running','local_import_committed','published','waiting_acknowledgement','peer_consumed','completed','failed','interrupted','explicitly_degraded'])
function parse(value:unknown):SyncStatus { if(typeof value!=='object'||value===null)throw new Error('statut sync invalide');const status=value as Partial<SyncStatus>;if(typeof status.phase!=='string'||!phases.has(status.phase as SyncPhase)||typeof status.result!=='string')throw new Error('statut sync invalide');if(status.drafts!==undefined&&(!Array.isArray(status.drafts)||status.drafts.length>32))throw new Error('résumé des brouillons invalide');return status as SyncStatus }
export async function fetchSyncStatus(signal?:AbortSignal):Promise<SyncStatus>{const response=await fetch('/api/v1/sync/status',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`sync status HTTP ${response.status}`);const token=response.headers.get('X-Trainlog-CSRF-Token')??'';if(!/^[0-9a-f]{64}$/.test(token))throw new Error('jeton CSRF absent');csrfToken=token;return parse(await response.json())}
export async function startSync(requestId:string):Promise<SyncStatus>{if(!/^sy_[0-9a-f-]{36}$/.test(requestId)||csrfToken.length!==64)throw new Error('requête sync invalide');const response=await fetch('/api/v1/sync',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':csrfToken},body:JSON.stringify({request_id:requestId,trigger:'web'})});const value=await response.json();if(!response.ok)throw new Error(typeof value?.error==='string'?value.error:`sync HTTP ${response.status}`);return parse(value)}
export function newRequestId():string{return `sy_${crypto.randomUUID()}`}
export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)}

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { fetchHealth, type Health } from '../api/health'
import { fetchDashboard, type DashboardSnapshot } from '../api/dashboard'
import { Footer } from '../components/Footer'
@ -23,6 +23,10 @@ export function App() {
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null)
const [dashboardPending, setDashboardPending] = useState(true)
const [dashboardFailed, setDashboardFailed] = useState(false)
const reloadDashboard = useCallback(() => {
fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) })
.catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false))
}, [])
useEffect(() => {
const controller = new AbortController()
@ -46,7 +50,7 @@ export function App() {
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">Aller au contenu</a>
<Header activeRoute={route} onNavigate={navigate} />
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={reloadDashboard} />
<main className="app-main" id="main-content">{content}</main>
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} />
</div>

View file

@ -1,12 +1,14 @@
import type { MouseEvent } from 'react'
import { routes, type AppRoute } from '../app/routes'
import { SyncControl } from './SyncControl'
interface HeaderProps {
activeRoute: AppRoute
onNavigate: (path: string) => void
onSyncCommitted: () => void
}
export function Header({ activeRoute, onNavigate }: HeaderProps) {
export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps) {
const follow = (event: MouseEvent<HTMLAnchorElement>, path: string) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
@ -30,6 +32,7 @@ export function Header({ activeRoute, onNavigate }: HeaderProps) {
</a>
))}
</nav>
<SyncControl onCommitted={onSyncCommitted} />
</header>
)
}

View file

@ -0,0 +1,11 @@
import { fireEvent,render,screen,waitFor } from '@testing-library/react'
import { describe,expect,it,vi } from 'vitest'
import { SyncControl } from './SyncControl'
const headers=new Headers({'X-Trainlog-CSRF-Token':'c'.repeat(64)})
const reply=(value:unknown,status=200)=>({ok:status<300,status,headers,json:()=>Promise.resolve(value)})
describe('contrôle global de synchronisation',()=>{
it('démarre une fois, confirme par polling et expose le brouillon sans le relabeller',async()=>{const fetch=vi.fn()
.mockResolvedValueOnce(reply({phase:'idle',result:'idle'}))
.mockResolvedValueOnce(reply({phase:'requested',result:'running'},202))
.mockResolvedValueOnce(reply({phase:'completed',result:'completed',progress_revision:7,sessions_reconciled:1,finished_at:'2026-09-17T12:00:00Z',drafts:[{draft_id:'draft_1',state:'pending',session_type:'strength',occurrence_count:2}]}));vi.stubGlobal('fetch',fetch);const committed=vi.fn();render(<SyncControl onCommitted={committed}/>);await waitFor(()=>expect(screen.getByRole('button',{name:'Synchroniser'})).toBeEnabled());fireEvent.click(screen.getByRole('button',{name:'Synchroniser'}));await waitFor(()=>expect(screen.getByText('Synchronisation confirmée')).toBeInTheDocument(),{timeout:2500});fireEvent.click(screen.getByText('Synchronisation confirmée'));expect(screen.getByRole('region',{name:'Brouillons synchronisés'})).toHaveTextContent('pending');expect(committed).toHaveBeenCalledTimes(1);expect(fetch.mock.calls.filter(call=>String(call[0]).endsWith('/api/v1/sync'))).toHaveLength(1)})
})

View file

@ -0,0 +1,10 @@
import { useEffect,useRef,useState } from 'react'
import { fetchSyncStatus,newRequestId,startSync,syncIsActive,type SyncStatus } from '../api/sync'
const labels:Record<string,string>={idle:'Synchronisation inactive',requested:'Synchronisation demandée',waiting_android_publication:'Publication Android requise',running:'Synchronisation en cours',local_import_committed:'Données desktop validées',published:'Génération retour publiée',waiting_acknowledgement:'Accusé Android attendu',peer_consumed:'Pair confirmé',completed:'Synchronisation confirmée',failed:'Échec de synchronisation',interrupted:'Synchronisation interrompue',explicitly_degraded:'Pair incompatible'}
export function SyncControl({onCommitted}:{onCommitted:()=>void}){const [status,setStatus]=useState<SyncStatus|null>(null);const [error,setError]=useState('');const committed=useRef(-1)
const accept=(next:SyncStatus)=>{setStatus(next);setError('');if(['local_import_committed','published','waiting_acknowledgement','peer_consumed','completed'].includes(next.phase)&&(next.progress_revision??0)>committed.current){committed.current=next.progress_revision??0;onCommitted()}}
useEffect(()=>{let stopped=false;let timer:number|undefined;const poll=async()=>{try{const next=await fetchSyncStatus();if(stopped)return;accept(next);if(syncIsActive(next.phase))timer=window.setTimeout(poll,750)}catch{if(!stopped)setError('Statut de synchronisation indisponible')}};void poll();return()=>{stopped=true;if(timer!==undefined)window.clearTimeout(timer)}},[onCommitted])
const start=async()=>{setError('');try{accept(await startSync(newRequestId()));const poll=async()=>{try{const next=await fetchSyncStatus();accept(next);if(syncIsActive(next.phase))window.setTimeout(poll,750)}catch{setError('Résultat inconnu : vérifiez le statut avant de réessayer')}};window.setTimeout(poll,250)}catch(reason){setError(reason instanceof Error?reason.message:'Démarrage impossible')}}
const active=status?syncIsActive(status.phase):false
return <div className="sync-control"><button type="button" onClick={start} disabled={active||status?.enabled===false}>Synchroniser</button><details className="sync-details"><summary aria-live="polite">{error||labels[status?.phase??'idle']}</summary>{status?.diagnostic&&<p>{status.diagnostic}</p>}{status?.sessions_reconciled!==null&&status?.sessions_reconciled!==undefined&&<p>Séances rapprochées : {status.sessions_reconciled}</p>}{status?.phase==='waiting_android_publication'&&<p>Ouvrez l’écran de synchronisation Android pour publier une génération fraîche.</p>}{(status?.drafts?.length??0)>0&&<section aria-label="Brouillons synchronisés"><h2>Brouillons reçus</h2><ul>{status!.drafts!.map(d=><li key={d.draft_id}><code>{d.draft_id}</code> · {d.state} · {d.session_type} · {d.occurrence_count} occurrence(s)</li>)}</ul></section>}{status?.finished_at&&<p>Dernier résultat : {status.finished_at}</p>}</details></div>
}

View file

@ -38,6 +38,15 @@
.nav-link:hover { color: var(--text); background: rgb(49 50 68 / 48%); }
.nav-link.is-active { color: var(--lavender); }
.nav-link.is-active::after { content: ""; position: absolute; left: .85rem; right: .85rem; bottom: .22rem; height: 2px; background: var(--lavender); border-radius: 2px; }
.sync-control { margin-left: auto; display: flex; align-items: center; gap: .6rem; position: relative; }
.sync-control button { border: 1px solid var(--lavender); border-radius: .55rem; padding: .55rem .8rem; background: transparent; color: var(--text); cursor: pointer; }
.sync-control button:focus-visible { outline: 3px solid var(--lavender); outline-offset: 2px; }
.sync-control button:disabled { cursor: not-allowed; opacity: .55; }
.sync-details { max-width: 28rem; color: var(--subtext-0); }
.sync-details summary { cursor: pointer; }
.sync-details[open] { position: absolute; z-index: 20; right: 0; top: 2.8rem; min-width: 20rem; padding: .8rem; border: 1px solid var(--surface-2); border-radius: .7rem; background: var(--base); box-shadow: 0 .5rem 2rem #0008; }
.sync-details h2 { font-size: 1rem; }
.sync-details ul { padding-left: 1.2rem; }
.app-main { width: 100%; min-width: 0; padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem); }
.page { width: min(100%, 92rem); min-width: 0; margin: 0 auto; }
@ -170,6 +179,8 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
.app-header { align-items: stretch; flex-direction: column; gap: .55rem; padding-block: .7rem .45rem; }
.brand { align-self: start; }
.main-nav { margin-inline: calc(var(--shell-gutter) * -1); padding-inline: var(--shell-gutter); }
.sync-control { margin-left: 0; justify-content: space-between; }
.sync-details[open] { position: static; min-width: 0; }
.page-heading { align-items: start; flex-direction: column; gap: 1rem; }
.tile-grid { display: flex; flex-direction: column; }
.tile, .tile-wide, .tile-feature, .principle-panel { width: 100%; grid-column: auto; }