Compare commits
No commits in common. "aa8ab7ae695601c837f45d439d2ec6f620f0854d" and "a6fe9be2969ea1fce0061d6268db67180733909d" have entirely different histories.
aa8ab7ae69
...
a6fe9be296
28 changed files with 19 additions and 807 deletions
|
|
@ -17,10 +17,6 @@ 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,6 @@
|
|||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -323,9 +323,8 @@ 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 opt-in grouped Web
|
||||
path is validated in isolation; the next boundary is
|
||||
`TRAINLOG_SYNC_DEPLOYMENT_READINESS_V1`, not an authorized deployment.
|
||||
services; automatic synchronization still selects V3. The next cursor is
|
||||
`TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1`.
|
||||
|
||||
The latest executable result belongs in
|
||||
[current state](docs/current_state.md), not in multiple README narratives.
|
||||
|
|
|
|||
|
|
@ -414,15 +414,6 @@ 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -47,10 +47,6 @@ 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` |
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -701,13 +701,3 @@ 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.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ visualizations are implemented; the other four Web routes remain placeholders.
|
|||
## Current cursor
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_DEPLOYMENT_READINESS_V1
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1
|
||||
```
|
||||
|
||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||
|
|
@ -42,9 +42,8 @@ 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. 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.
|
||||
points. Automatic V3 selection remains unchanged. The next cursor is only
|
||||
`TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1`; no orchestrator work starts here.
|
||||
|
||||
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
|
||||
defines the complete-sync target and its bounded dependency order.
|
||||
|
|
|
|||
|
|
@ -777,18 +777,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1216,14 +1216,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
#!/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=(",",":")))
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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()
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
#!/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()
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
#!/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())
|
||||
|
|
@ -375,7 +375,7 @@ def commands_for_suite(repository: Path, suite: str) -> list[tuple[str, list[str
|
|||
return smoke
|
||||
if suite == "preflight":
|
||||
return []
|
||||
commands = [
|
||||
return [
|
||||
("desktop-compile", ["meson", "compile", "-C", "build"], repository),
|
||||
(
|
||||
"desktop-tests",
|
||||
|
|
@ -423,11 +423,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@
|
|||
/* 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, const char *database_path,
|
||||
uint16_t port,
|
||||
int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
|
||||
const volatile sig_atomic_t *stop_requested,
|
||||
char *diagnostic, size_t diagnostic_capacity);
|
||||
|
||||
|
|
|
|||
|
|
@ -47,16 +47,12 @@ 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',
|
||||
|
|
@ -734,11 +730,6 @@ 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),
|
||||
|
|
|
|||
|
|
@ -20,8 +20,7 @@ static void request_web_stop(int signal_number)
|
|||
web_stop_requested = 1;
|
||||
}
|
||||
|
||||
static int run_web(TrainlogDatabase *database, const char *database_path,
|
||||
uint16_t port)
|
||||
static int run_web(TrainlogDatabase *database, uint16_t port)
|
||||
{
|
||||
struct sigaction action;
|
||||
struct sigaction previous_interrupt;
|
||||
|
|
@ -43,7 +42,7 @@ static int run_web(TrainlogDatabase *database, const char *database_path,
|
|||
"trainlog: impossible d'installer les gestionnaires de signaux\n");
|
||||
return 1;
|
||||
}
|
||||
result = trainlog_web_server_run(database, database_path, port, &web_stop_requested,
|
||||
result = trainlog_web_server_run(database, port, &web_stop_requested,
|
||||
diagnostic, sizeof(diagnostic));
|
||||
(void)sigaction(SIGINT, &previous_interrupt, NULL);
|
||||
(void)sigaction(SIGTERM, &previous_terminate, NULL);
|
||||
|
|
@ -106,7 +105,7 @@ int main(int argc, char *argv[])
|
|||
return 1;
|
||||
}
|
||||
result = options.mode == TRAINLOG_RUN_WEB
|
||||
? run_web(database, database_path, options.port)
|
||||
? run_web(database, options.port)
|
||||
: trainlog_tui_run(database);
|
||||
trainlog_database_close(database);
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <microhttpd.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/random.h>
|
||||
|
|
@ -12,13 +10,7 @@
|
|||
#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"
|
||||
|
|
@ -36,9 +28,6 @@
|
|||
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;
|
||||
|
|
@ -116,147 +105,6 @@ 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)
|
||||
{
|
||||
|
|
@ -427,72 +275,6 @@ 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;
|
||||
|
|
@ -642,8 +424,7 @@ 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, const char *database_path,
|
||||
uint16_t port,
|
||||
int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
|
||||
const volatile sig_atomic_t *stop_requested,
|
||||
char *diagnostic, size_t diagnostic_capacity)
|
||||
{
|
||||
|
|
@ -651,8 +432,7 @@ int trainlog_web_server_run(TrainlogDatabase *database, const char *database_pat
|
|||
struct MHD_Daemon *daemon;
|
||||
TrainlogWebContext context;
|
||||
int result = 0;
|
||||
if (database == NULL || database_path == NULL || database_path[0] != '/' ||
|
||||
stop_requested == NULL) return -1;
|
||||
if (database == NULL || stop_requested == NULL) return -1;
|
||||
web_diagnostic(diagnostic, diagnostic_capacity, "");
|
||||
if (!trainlog_web_assets_available()) {
|
||||
web_diagnostic(diagnostic, diagnostic_capacity,
|
||||
|
|
@ -665,13 +445,6 @@ int trainlog_web_server_run(TrainlogDatabase *database, const char *database_pat
|
|||
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,
|
||||
|
|
@ -709,12 +482,6 @@ int trainlog_web_server_run(TrainlogDatabase *database, const char *database_pat
|
|||
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);
|
||||
|
|
@ -748,23 +515,5 @@ int trainlog_web_server_run(TrainlogDatabase *database, const char *database_pat
|
|||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,6 @@ 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);
|
||||
|
|
@ -156,7 +155,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, "/tmp/trainlog-web-test.db", port, &child_stop,
|
||||
_exit(trainlog_web_server_run(database, port, &child_stop,
|
||||
diagnostic, sizeof(diagnostic)) == 0 ? 0 : 1);
|
||||
}
|
||||
CHECK(exchange(port,
|
||||
|
|
@ -170,27 +169,6 @@ 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);
|
||||
|
|
@ -329,7 +307,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, "/tmp/trainlog-web-test.db", port, &stop, diagnostic,
|
||||
CHECK(trainlog_web_server_run(database, port, &stop, diagnostic,
|
||||
sizeof(diagnostic)) != 0);
|
||||
CHECK(strstr(diagnostic, "127.0.0.1") != NULL);
|
||||
(void)close(occupied);
|
||||
|
|
@ -361,7 +339,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, path, port, &stop, diagnostic,
|
||||
_exit(trainlog_web_server_run(database, port, &stop, diagnostic,
|
||||
sizeof(diagnostic)) == 0 ? 0 : 1);
|
||||
}
|
||||
CHECK(exchange(port,
|
||||
|
|
@ -388,7 +366,7 @@ int main(void)
|
|||
if (!trainlog_web_assets_available()) {
|
||||
volatile sig_atomic_t stop = 0;
|
||||
char diagnostic[256];
|
||||
passed = trainlog_web_server_run(database, "/tmp/trainlog-web-test.db", 8080U, &stop, diagnostic,
|
||||
passed = trainlog_web_server_run(database, 8080U, &stop, diagnostic,
|
||||
sizeof(diagnostic)) != 0 && strstr(diagnostic, "frontend") != NULL;
|
||||
} else {
|
||||
passed = test_http_contract(database) && test_port_in_use(database) &&
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
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)})
|
||||
})
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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)}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchHealth, type Health } from '../api/health'
|
||||
import { fetchDashboard, type DashboardSnapshot } from '../api/dashboard'
|
||||
import { Footer } from '../components/Footer'
|
||||
|
|
@ -23,10 +23,6 @@ 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()
|
||||
|
|
@ -50,7 +46,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} onSyncCommitted={reloadDashboard} />
|
||||
<Header activeRoute={route} onNavigate={navigate} />
|
||||
<main className="app-main" id="main-content">{content}</main>
|
||||
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
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, onSyncCommitted }: HeaderProps) {
|
||||
export function Header({ activeRoute, onNavigate }: HeaderProps) {
|
||||
const follow = (event: MouseEvent<HTMLAnchorElement>, path: string) => {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
||||
event.preventDefault()
|
||||
|
|
@ -32,7 +30,6 @@ export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps
|
|||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<SyncControl onCommitted={onSyncCommitted} />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
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)})
|
||||
})
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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>
|
||||
}
|
||||
|
|
@ -38,15 +38,6 @@
|
|||
.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; }
|
||||
|
|
@ -179,8 +170,6 @@ 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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue