Fix sync MTP timeout and prepared-item visibility

This commit is contained in:
fy59 2026-09-17 18:06:56 +02:00
parent 56ac1cdfd2
commit f7b556cc37
26 changed files with 1345 additions and 57 deletions

View file

@ -2,6 +2,19 @@
## Unreleased — 0.1.2
- Fixed a production full-generation timeout caused by recursively uploading
retained staging and unrelated transport history on every MTP push. Each
phase now publishes a private bounded outbox containing only its request,
ACK, and current referenced generation, while preserving every retained
generation, tombstone, and causal proof. Transport expiry has a stable
browser-facing code and action instead of a truncated traceback.
- Replaced the Web Next Session placeholder with a separate read-only Core
projection that distinguishes AI proposals from active/pending execution
drafts without redefining the frozen Dashboard field or counting targets as
performed work. The browser preserves the last successful durable view
across refresh failures and uses one abortable, per-run ordered sync poller.
- Completed the authorized private full-generation rollout over production
direct MTP: verified Android and desktop backups, signing-preserving Android
updates, schema migrations, persistent peer pairing, bidirectional durable

View file

@ -219,6 +219,22 @@ persistence with optimistic conflict and CSRF/Origin protection. It provides no
business mutation or automatic browser launch. Analyse, Programmes, Sessions,
and Exercises remain explicit placeholders.
The Next Session tile no longer treats the frozen Dashboard
`next_session.available` placeholder as a durable inventory. A separate bounded
`GET /api/v1/prepared-items` Core projection distinguishes imported AI
proposals from active or pending execution drafts and exposes stable identity,
state, title/date, occurrence count, and provenance. Reload and later sync
failure do not erase the last successfully read projection. Proposal targets
remain planning data and never alter Dashboard actual-work metrics.
Full-generation MTP publication now uses a private phase-owned outbox. The
request, desktop consumption ACK, and current desktop generation are published
only when their phase requires them; retained staging, inbound objects, legacy
files, prior ACKs, and causal evidence are not recursively re-uploaded. Adapter
timeouts are persisted as the stable `transport_timeout` status instead of a
truncated Python traceback. The browser polls through one abortable scheduler,
orders revisions per `run_id`, and rejects stale responses from older runs.
## Data semantics
- `exercise_id` identifies a catalogue exercise; `entry_id` identifies one

View file

@ -34,6 +34,9 @@ CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
contract, frozen interactive grid, versioned layout persistence and factual
tile rendering exist. The Dashboard's bounded progression and BODY ZONES
visualizations are implemented; the other four Web routes remain placeholders.
The corrective prepared-item projection and bounded phase-owned MTP outbox are
implemented without opening the Sessions route, changing schemas, or changing
the frozen Dashboard and exchange contracts.
## Current cursor

View file

@ -798,6 +798,23 @@ truncation, and publishes generation manifests last. Production behavior is
tested by replacing only the typed libmtp/udev I/O callbacks with a filesystem
object double; the same compiled transport algorithm remains under test.
Each worker push is additionally scoped by a private disposable outbox. The
first push contains only the correlated request; the post-import push adds the
desktop consumption ACK; the publication push adds the current desktop
generation reference and exactly its referenced generation directory. The
durable transport root remains the owner of retained generations, staging,
legacy compatibility artifacts, and evidence, but those unrelated objects are
not recursively sent on every phase. Constructing an outbox does not delete or
rewrite any source object. A bounded adapter timeout is an ambiguous transport
failure with stable code `transport_timeout`; it is not evidence of rollback
and does not authorize automatic mutation replay.
`GET /api/v1/prepared-items` is a separate read-only Web projection. It does not
change the frozen Dashboard `next_session.available` field or any exchange
format. It lists bounded durable desktop AI proposals and relevant execution
drafts as distinct kinds. Completed sessions remain outside this projection,
and finalized or causally deleted execution drafts are excluded.
## 19. Legacy active-protocol limitations
The Web end-to-end tranche adds an explicit opt-in application orchestrator.

View file

@ -6,6 +6,18 @@ through the C adapter, observes the desktop commit at an Android test barrier,
then releases the real ACK. This proves the directory transport double, not
physical MTP. Orchestrator tests cover bounded streams and descendant cleanup;
deployment-tool tests cover consistent SQLite backup and candidate inventory.
The incident regressions also prove that a phase-owned MTP outbox excludes
retained staging and unrelated legacy files, adapter expiry produces the stable
`transport_timeout` code, and operational failure classes remain distinct.
`web_prepared_items` exercises the production SQLite projection and JSON
serializer with a proposal and an execution draft, then proves finalized and
causally deleted execution drafts are absent. The HTTP test covers the
read-only route and method rejection. Vitest distinguishes proposal, active
draft, loading, empty and failed-refresh states; it retains an already rendered
durable value across a reread failure. Sync-control tests cover a single
abortable polling scheduler, per-run revisions, rejection of an older run's
late terminal response, stable error/action rendering, and cleanup on unmount.
The `ai_history_export` test covers an empty database, a simple session,
distinct multi-occurrences of one exercise, ordered sets, exact occurrence

View file

@ -0,0 +1,78 @@
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import sync_orchestrator # noqa: E402
import sync_peer_worker # noqa: E402
class SyncErrorClassificationTest(unittest.TestCase):
def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self):
timeout = subprocess.TimeoutExpired(["adapter", "push"], 30)
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout):
with self.assertRaisesRegex(
RuntimeError,
r"^transport_timeout: MTP push did not finish within 30 seconds$",
) as caught:
sync_peer_worker.run_adapter(
Path("/adapter"),
"push",
"peer_00000000-0000-4000-8000-000000000001",
Path("/transport"),
sync_peer_worker.time.monotonic() + 60,
)
self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout")
def test_known_operational_failures_remain_distinct(self):
self.assertEqual(
sync_orchestrator.failure_code(RuntimeError("expected Android peer not found")),
"device_unavailable",
)
self.assertEqual(
sync_orchestrator.failure_code(RuntimeError("pending capacity exhausted")),
"capacity_exhausted",
)
self.assertEqual(
sync_orchestrator.failure_code(RuntimeError("revision conflict")),
"data_conflict",
)
def test_mtp_outbox_excludes_retained_staging_and_unrelated_files(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "transport"
root.mkdir()
(root / "request-v1.json").write_text("{}")
(root / "staging").mkdir()
(root / "staging" / "retained.json").write_text("retained")
(root / "legacy.json").write_text("legacy")
observed = []
def inspect(command, **_kwargs):
outbox = Path(command[3])
observed.extend(
str(path.relative_to(outbox))
for path in outbox.rglob("*")
if path.is_file()
)
return subprocess.CompletedProcess(command, 0, b"", b"")
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=inspect):
sync_peer_worker.push_adapter(
Path("/adapter"),
"peer_00000000-0000-4000-8000-000000000001",
root,
["request-v1.json"],
sync_peer_worker.time.monotonic() + 60,
)
self.assertEqual(observed, ["request-v1.json"])
if __name__ == "__main__":
unittest.main()

View file

@ -76,7 +76,9 @@ class OrchestratorTest(unittest.TestCase):
values = self.fixture(root, **override)
result = self.invoke(ORCHESTRATOR, values)
self.assertEqual(result.returncode, 2)
self.assertEqual(json.loads(values[1].read_text())["phase"], "failed")
final = json.loads(values[1].read_text())
self.assertEqual(final["phase"], "failed")
self.assertEqual(final["error_code"], "internal_error")
def test_stdout_bound_is_enforced_before_complete_accumulation(self):
with tempfile.TemporaryDirectory() as directory:

View file

@ -118,6 +118,20 @@ def update(state_path: Path, state: dict, phase: str, **fields: object) -> None:
atomic_write(state_path, state)
def failure_code(error: BaseException) -> str:
"""Map bounded operational evidence to one stable browser-facing code."""
diagnostic = str(error).lower()
if "transport_timeout:" in diagnostic or "peer worker timed out" in diagnostic:
return "transport_timeout"
if "expected android peer not found" in diagnostic:
return "device_unavailable"
if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic:
return "capacity_exhausted"
if "conflict" in diagnostic or "wrong consumer" in diagnostic:
return "data_conflict"
return "internal_error"
def validate_result(value: dict, run_id: str) -> dict:
required = {
"format",
@ -303,6 +317,7 @@ def run(args: argparse.Namespace) -> int:
state,
"failed",
result="conflict",
error_code="sync_in_progress",
diagnostic="Another synchronization owns the common lock",
finished_at=datetime.now(timezone.utc).isoformat(),
)
@ -418,6 +433,7 @@ def main() -> int:
state,
phase,
result=phase,
error_code=("interrupted" if phase == "interrupted" else failure_code(error)),
diagnostic=str(error)[:MAX_DIAGNOSTIC],
finished_at=datetime.now(timezone.utc).isoformat(),
)

View file

@ -7,7 +7,17 @@ always produced and consumed by the existing generation services.
"""
from __future__ import annotations
import argparse, hashlib, json, os, sys, time, uuid
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
from contextlib import closing, redirect_stdout
from pathlib import Path
@ -61,13 +71,21 @@ def run_adapter(
allow_missing_peer: bool = False,
) -> bool:
remaining = max(1, int(deadline - time.monotonic()))
result = __import__("subprocess").run(
[str(adapter), operation, peer, str(root)],
stdin=__import__("subprocess").DEVNULL,
capture_output=True,
timeout=min(remaining, 30),
check=False,
)
timeout = min(remaining, 30)
try:
result = subprocess.run(
[str(adapter), operation, peer, str(root)],
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired as error:
# CONTRACT: an adapter timeout is an ambiguous transport failure. It
# never implies rollback, replay, or permission to retry a mutation.
raise RuntimeError(
f"transport_timeout: MTP {operation} did not finish within {timeout} seconds"
) from error
if result.returncode != 0:
diagnostic = (
result.stderr.decode("utf-8", "replace")[:1024] or "MTP adapter failed"
@ -82,6 +100,47 @@ def run_adapter(
return True
def push_adapter(
adapter: Path,
peer: str,
root: Path,
relative_paths: list[str],
deadline: float,
) -> None:
"""Publish only the phase-owned bounded MTP objects.
WHY: the durable transport root also retains inbound objects, legacy files,
and staging evidence; recursively uploading that root makes each exchange
grow with history and can time out before Android sees the request.
CONTRACT: callers name fixed relative files/directories already durably
published below ``root``. The adapter receives a private disposable view.
INVARIANT: no retained generation, ACK, tombstone, or source object is
deleted or modified while constructing the view.
"""
with tempfile.TemporaryDirectory(prefix="trainlog-mtp-outbox-", dir=root.parent) as directory:
outbox = Path(directory)
os.chmod(outbox, 0o700)
for relative in relative_paths:
source = root / relative
destination = outbox / relative
if (
source.is_symlink()
or not source.exists()
or not source.resolve().is_relative_to(root.resolve())
):
raise RuntimeError(f"invalid MTP outbox source: {relative}")
if source.is_dir() and any(path.is_symlink() for path in source.rglob("*")):
raise RuntimeError(f"MTP outbox source contains a symbolic link: {relative}")
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if source.is_dir():
shutil.copytree(source, destination, copy_function=shutil.copy2)
elif source.is_file():
shutil.copy2(source, destination)
else:
raise RuntimeError(f"invalid MTP outbox object: {relative}")
run_adapter(adapter, "push", peer, outbox, deadline)
def wait_file(path: Path, deadline: float, pump=None) -> None:
while not path.is_file():
if time.monotonic() >= deadline:
@ -167,8 +226,12 @@ def main() -> int:
request_path = args.transport_root / "request-v1.json"
publish_json(request_path, request)
if args.mode == "mtp":
run_adapter(
args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
push_adapter(
args.mtp_adapter,
args.expected_peer,
args.transport_root,
["request-v1.json"],
deadline,
)
emit(
args.run_id,
@ -185,8 +248,12 @@ def main() -> int:
ack_path = args.transport_root / "desktop-consumption-ack-v1.json"
publish_json(ack_path, ack)
if args.mode == "mtp":
run_adapter(
args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
push_adapter(
args.mtp_adapter,
args.expected_peer,
args.transport_root,
["request-v1.json", "desktop-consumption-ack-v1.json"],
deadline,
)
if ack["result"] != "consumed":
raise RuntimeError("desktop rejected Android generation")
@ -218,8 +285,17 @@ def main() -> int:
}
publish_json(args.transport_root / "desktop-generation-v1.json", ref)
if args.mode == "mtp":
run_adapter(
args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline
push_adapter(
args.mtp_adapter,
args.expected_peer,
args.transport_root,
[
"request-v1.json",
"desktop-consumption-ack-v1.json",
"desktop-generation-v1.json",
ref["relative_path"],
],
deadline,
)
emit(
args.run_id,

View file

@ -0,0 +1,49 @@
/* Trainlog Web prepared-item V1 read model. */
#ifndef TRAINLOG_WEB_PREPARED_ITEMS_H
#define TRAINLOG_WEB_PREPARED_ITEMS_H
#include <stddef.h>
#include "trainlog/database.h"
#define TRAINLOG_WEB_PREPARED_ITEM_CAPACITY 32U
#define TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY (64U * 1024U)
typedef enum TrainlogWebPreparedItemKind {
TRAINLOG_WEB_PREPARED_AI_PROPOSAL = 0,
TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT = 1
} TrainlogWebPreparedItemKind;
typedef struct TrainlogWebPreparedItem {
TrainlogWebPreparedItemKind kind;
char identity[TRAINLOG_ID_MAX + 1U];
char state[32];
char title[TRAINLOG_NAME_MAX + 1U];
char planned_for[11];
size_t occurrence_count;
char provenance[32];
} TrainlogWebPreparedItem;
typedef struct TrainlogWebPreparedItems {
char generated_at[TRAINLOG_TIMESTAMP_MAX + 1U];
TrainlogWebPreparedItem items[TRAINLOG_WEB_PREPARED_ITEM_CAPACITY];
size_t item_count;
bool partial;
} TrainlogWebPreparedItems;
/* WHY: sync status is one run's evidence, not a durable inventory.
* CONTRACT: database/output are required and borrowed for the call. The
* projection is bounded, deterministic, read-only, and keeps AI proposals
* separate from executable drafts and completed sessions.
* INVARIANT: targets never become performed occurrences through this API. */
TrainlogStatus trainlog_web_prepared_items_load(
TrainlogDatabase *database, TrainlogWebPreparedItems *output);
/* Caller owns output. Success guarantees valid NUL-terminated JSON. */
TrainlogStatus trainlog_web_prepared_items_serialize(
const TrainlogWebPreparedItems *items,
char *output,
size_t capacity,
size_t *output_size);
#endif

View file

@ -192,6 +192,8 @@ trainlog_core_sources = files(
'src/json_writer.c',
'src/web_dashboard.c',
'src/web_dashboard_json.c',
'src/web_prepared_items.c',
'src/web_prepared_items_json.c',
'src/duration.c',
'src/id.c',
'src/timeutil.c',
@ -335,6 +337,15 @@ test_web_dashboard = executable(
)
test('web_dashboard', test_web_dashboard)
test_web_prepared_items = executable(
'test_web_prepared_items',
'tests/test_web_prepared_items.c',
dependencies: trainlog_core_dep,
include_directories: trainlog_private_include,
c_args: strict_c_args,
)
test('web_prepared_items', test_web_prepared_items)
test_dashboard_header = executable(
'test_dashboard_header',
'tests/test_dashboard_header.c',

View file

@ -0,0 +1,174 @@
#include "trainlog/web_prepared_items.h"
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <sqlite3.h>
#include "database_internal.h"
static bool copy_column(sqlite3_stmt *statement, int column, char *output, size_t capacity) {
const unsigned char *text;
int bytes;
if (sqlite3_column_type(statement, column) == SQLITE_NULL) {
output[0] = '\0';
return true;
}
if (sqlite3_column_type(statement, column) != SQLITE_TEXT) {
return false;
}
text = sqlite3_column_text(statement, column);
bytes = sqlite3_column_bytes(statement, column);
if (text == NULL || bytes < 0 || (size_t)bytes >= capacity) {
return false;
}
(void)memcpy(output, text, (size_t)bytes);
output[bytes] = '\0';
return true;
}
static bool read_count(sqlite3_stmt *statement, int column, size_t *output) {
sqlite3_int64 value;
if (sqlite3_column_type(statement, column) != SQLITE_INTEGER) {
return false;
}
value = sqlite3_column_int64(statement, column);
if (value < 0 || (uint64_t)value > (uint64_t)SIZE_MAX) {
return false;
}
*output = (size_t)value;
return true;
}
static bool format_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) {
time_t now = time(NULL);
struct tm utc;
return now != (time_t)-1 && gmtime_r(&now, &utc) != NULL &&
strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
}
static TrainlogStatus load_ai_proposals(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
static const char SQL[] =
"SELECT d.draft_id,COALESCE(d.title,''),COALESCE(d.planned_for,''),"
"COUNT(e.id),CASE WHEN d.published_at IS NULL THEN 'pending_publication' ELSE 'published' "
"END FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON "
"e.draft_row_id=d.id GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),"
"d.created_at,d.draft_id LIMIT ?1;";
sqlite3_stmt *statement = NULL;
int step;
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_int64(
statement, 1, (sqlite3_int64)(TRAINLOG_WEB_PREPARED_ITEM_CAPACITY + 1U)) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
TrainlogWebPreparedItem *item;
if (output->item_count == TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) {
output->partial = true;
continue;
}
item = &output->items[output->item_count];
item->kind = TRAINLOG_WEB_PREPARED_AI_PROPOSAL;
if (!copy_column(statement, 0, item->identity, sizeof(item->identity)) ||
!copy_column(statement, 1, item->title, sizeof(item->title)) ||
!copy_column(statement, 2, item->planned_for, sizeof(item->planned_for)) ||
!read_count(statement, 3, &item->occurrence_count) ||
!copy_column(statement, 4, item->state, sizeof(item->state))) {
goto fail;
}
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "ai_import");
++output->item_count;
}
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus load_execution_drafts(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
static const char SQL[] =
"SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),''),"
"COALESCE(json_array_length(d.payload_json,'$.exercises'),0) FROM execution_drafts d "
"WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE "
"f.session_id=d.session_id) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
"c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) "
"ORDER BY CASE d.state WHEN 'active' THEN 0 ELSE 1 END,d.session_id LIMIT ?1;";
sqlite3_stmt *statement = NULL;
int step;
size_t remaining = TRAINLOG_WEB_PREPARED_ITEM_CAPACITY - output->item_count;
if (remaining == 0U) {
return TRAINLOG_STATUS_OK;
}
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_int64(statement, 1, (sqlite3_int64)(remaining + 1U)) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
TrainlogWebPreparedItem *item;
if (output->item_count == TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) {
output->partial = true;
continue;
}
item = &output->items[output->item_count];
item->kind = TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT;
if (!copy_column(statement, 0, item->identity, sizeof(item->identity)) ||
!copy_column(statement, 1, item->state, sizeof(item->state)) ||
!copy_column(statement, 2, item->title, sizeof(item->title)) ||
!copy_column(statement, 3, item->planned_for, sizeof(item->planned_for)) ||
!read_count(statement, 4, &item->occurrence_count)) {
goto fail;
}
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "execution_store");
++output->item_count;
}
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_web_prepared_items_load(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
TrainlogStatus status;
TrainlogStatus end_status;
if (database == NULL || output == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memset(output, 0, sizeof(*output));
if (!format_now(output->generated_at)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
status = trainlog_database_read_snapshot_begin(database);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
status = load_ai_proposals(database, output);
if (status == TRAINLOG_STATUS_OK) {
status = load_execution_drafts(database, output);
}
end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
return status == TRAINLOG_STATUS_OK ? end_status : status;
}

View file

@ -0,0 +1,140 @@
#include "trainlog/web_prepared_items.h"
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <utf8proc.h>
typedef struct JsonWriter {
char *output;
size_t capacity;
size_t length;
bool failed;
} JsonWriter;
static void append(JsonWriter *writer, const char *format, ...)
__attribute__((format(printf, 2, 3)));
static void append(JsonWriter *writer, const char *format, ...) {
va_list arguments;
int written;
if (writer->failed || writer->length >= writer->capacity) {
writer->failed = true;
return;
}
va_start(arguments, format);
written = vsnprintf(
writer->output + writer->length, writer->capacity - writer->length, format, arguments);
va_end(arguments);
if (written < 0 || (size_t)written >= writer->capacity - writer->length) {
writer->failed = true;
return;
}
writer->length += (size_t)written;
}
static bool valid_utf8(const char *value) {
const utf8proc_uint8_t *cursor = (const utf8proc_uint8_t *)value;
while (*cursor != '\0') {
utf8proc_int32_t codepoint;
utf8proc_ssize_t sequence = utf8proc_iterate(cursor, -1, &codepoint);
(void)codepoint;
if (sequence <= 0) {
return false;
}
cursor += (size_t)sequence;
}
return true;
}
static void string(JsonWriter *writer, const char *value) {
const unsigned char *cursor = (const unsigned char *)value;
if (!valid_utf8(value)) {
writer->failed = true;
return;
}
append(writer, "\"");
while (!writer->failed && *cursor != '\0') {
unsigned char byte = *cursor++;
if (byte == '"' || byte == '\\') {
append(writer, "\\%c", (int)byte);
} else if (byte == '\b') {
append(writer, "\\b");
} else if (byte == '\f') {
append(writer, "\\f");
} else if (byte == '\n') {
append(writer, "\\n");
} else if (byte == '\r') {
append(writer, "\\r");
} else if (byte == '\t') {
append(writer, "\\t");
} else if (byte < 0x20U) {
append(writer, "\\u%04x", (unsigned int)byte);
} else {
append(writer, "%c", (int)byte);
}
}
append(writer, "\"");
}
TrainlogStatus trainlog_web_prepared_items_serialize(
const TrainlogWebPreparedItems *items,
char *output,
size_t capacity,
size_t *output_size) {
JsonWriter writer = {output, capacity, 0U, false};
size_t index;
if (items == NULL || output == NULL || output_size == NULL || capacity == 0U ||
items->item_count > TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_size = 0U;
append(&writer, "{\"api_version\":1,\"generated_at\":");
string(&writer, items->generated_at);
append(&writer, ",\"partial\":%s,\"items\":[", items->partial ? "true" : "false");
for (index = 0U; index < items->item_count; ++index) {
const TrainlogWebPreparedItem *item = &items->items[index];
const char *kind;
if (item->kind == TRAINLOG_WEB_PREPARED_AI_PROPOSAL) {
kind = "ai_proposal";
} else if (item->kind == TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT) {
kind = "execution_draft";
} else {
output[0] = '\0';
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
append(&writer, "%s{\"identity\":", index == 0U ? "" : ",");
string(&writer, item->identity);
append(&writer, ",\"kind\":");
string(&writer, kind);
append(&writer, ",\"title\":");
string(&writer, item->title);
append(&writer, ",\"planned_for\":");
if (item->planned_for[0] == '\0') {
append(&writer, "null");
} else {
string(&writer, item->planned_for);
}
append(&writer, ",\"state\":");
string(&writer, item->state);
append(&writer, ",\"occurrence_count\":%zu,\"provenance\":", item->occurrence_count);
string(&writer, item->provenance);
append(&writer, "}");
}
append(&writer, "]}\n");
if (writer.failed) {
if (capacity > 0U) {
output[0] = '\0';
}
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
*output_size = writer.length;
return TRAINLOG_STATUS_OK;
}

View file

@ -21,6 +21,7 @@
#include <yyjson.h>
#include "trainlog/web_dashboard.h"
#include "trainlog/web_prepared_items.h"
#include "trainlog/dashboard_layout.h"
#include "web_assets.h"
@ -707,6 +708,27 @@ static enum MHD_Result handle_request(void *closure,
}
return queue_json(connection, MHD_HTTP_OK, json, NULL);
}
if (strcmp(url, "/api/v1/prepared-items") == 0) {
char json[TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY];
size_t json_size = 0U;
TrainlogWebPreparedItems items;
if (!is_get) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"GET");
}
if (trainlog_web_prepared_items_load(context->database, &items) != TRAINLOG_STATUS_OK ||
trainlog_web_prepared_items_serialize(&items, json, sizeof(json), &json_size) !=
TRAINLOG_STATUS_OK) {
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"prepared_items_unavailable\"}\n",
NULL);
}
return queue_json(connection, MHD_HTTP_OK, json, NULL);
}
if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
TrainlogDashboardLayout layout;
TrainlogDashboardLayout saved;

View file

@ -0,0 +1,110 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sqlite3.h>
#include "trainlog/database.h"
#include "trainlog/web_prepared_items.h"
#include "database_internal.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf(stderr, "check failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
return false; \
} \
} while (0)
static bool execute(TrainlogDatabase *database, const char *sql) {
char *error = NULL;
int status = sqlite3_exec(database->connection, sql, NULL, NULL, &error);
if (status != SQLITE_OK) {
(void)fprintf(stderr, "SQLite fixture failed: %s\n", error == NULL ? "unknown" : error);
}
sqlite3_free(error);
return status == SQLITE_OK;
}
static bool test_distinct_durable_items(void) {
TrainlogDatabase *database = NULL;
TrainlogWebPreparedItems items;
char json[TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY];
size_t json_size = 0U;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(execute(database,
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,"
"data_fields) VALUES('ex_00000000-0000-4000-8000-000000000001','Test','test','reps',"
"'sets',0);"
"INSERT INTO ai_session_drafts(draft_id,created_at,planned_for,session_type,title,notes,"
"published_at) VALUES('aid_00000000-0000-4000-8000-000000000001',"
"'2026-09-16T10:00:00Z','2026-09-17','training','Prepared title',NULL,"
"'2026-09-16T11:00:00Z');"
"INSERT INTO ai_session_draft_entries(draft_row_id,entry_id,position,exercise_row_id,"
"source_exercise_id,recording_mode,tracking_mode,data_fields,equipment_id,load_mode,"
"target_sets,target_reps,target_duration_seconds,target_weight_kg,rest_seconds) "
"SELECT d.id,'sxe_00000000-0000-4000-8000-000000000001',0,e.id,e.exercise_id,'sets',"
"'reps',0,NULL,'none',2,8,NULL,NULL,60 FROM ai_session_drafts d,exercises e WHERE "
"e.exercise_id='ex_00000000-0000-4000-8000-000000000001';"
"INSERT INTO execution_drafts(session_id,session_type,source_session_id,started_at,"
"revision_id,parent_revision_id,state,payload_json) VALUES("
"'se_00000000-0000-4000-8000-000000000001','training',NULL,'2026-09-17T12:00:00Z',"
"'dr_one',NULL,'active','{\"exercises\":[{},{}]}');"));
{
TrainlogStatus status = trainlog_web_prepared_items_load(database, &items);
if (status != TRAINLOG_STATUS_OK) {
(void)fprintf(stderr, "prepared load failed: %d sqlite=%s\n", (int)status,
sqlite3_errmsg(database->connection));
}
CHECK(status == TRAINLOG_STATUS_OK);
}
CHECK(items.item_count == 2U);
CHECK(items.items[0].kind == TRAINLOG_WEB_PREPARED_AI_PROPOSAL);
CHECK(strcmp(items.items[0].state, "published") == 0);
CHECK(strcmp(items.items[0].title, "Prepared title") == 0);
CHECK(items.items[0].occurrence_count == 1U);
CHECK(items.items[1].kind == TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT);
CHECK(strcmp(items.items[1].state, "active") == 0);
CHECK(items.items[1].occurrence_count == 2U);
CHECK(trainlog_web_prepared_items_serialize(
&items, json, sizeof(json), &json_size) == TRAINLOG_STATUS_OK);
CHECK(json_size > 0U);
CHECK(strstr(json, "\"kind\":\"ai_proposal\"") != NULL);
CHECK(strstr(json, "\"kind\":\"execution_draft\"") != NULL);
items.items[0].title[0] = (char)0xc3;
items.items[0].title[1] = '\0';
CHECK(trainlog_web_prepared_items_serialize(
&items, json, sizeof(json), &json_size) == TRAINLOG_STATUS_SYSTEM_ERROR);
CHECK(json_size == 0U && json[0] == '\0');
trainlog_database_close(database);
return true;
}
static bool test_finalized_and_deleted_drafts_are_hidden(void) {
TrainlogDatabase *database = NULL;
TrainlogWebPreparedItems items;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(execute(database,
"INSERT INTO execution_drafts(session_id,session_type,revision_id,state,payload_json) "
"VALUES('se_00000000-0000-4000-8000-000000000002','training','dr_two','pending',"
"'{\"exercises\":[]}'),('se_00000000-0000-4000-8000-000000000003','training',"
"'dr_three','pending','{\"exercises\":[]}');"
"INSERT INTO execution_draft_finalizations(session_id,final_revision_id,finalized_at) "
"VALUES('se_00000000-0000-4000-8000-000000000002','dr_final',"
"'2026-09-17T12:00:00Z');"
"INSERT INTO sync_causal_state(target_kind,target_id,current_revision_id,deleted) "
"VALUES('execution_draft','se_00000000-0000-4000-8000-000000000003','dr_delete',1);"));
CHECK(trainlog_web_prepared_items_load(database, &items) == TRAINLOG_STATUS_OK);
CHECK(items.item_count == 0U);
trainlog_database_close(database);
return true;
}
int main(void) {
if (!test_distinct_durable_items() || !test_finalized_and_deleted_drafts_are_hidden()) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}

View file

@ -255,6 +255,19 @@ static bool test_http_contract(TrainlogDatabase *database) {
CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL);
CHECK(strstr(response, "\"window_days\":90") != NULL);
CHECK(strstr(response, "\"partial\":false") != NULL);
CHECK(exchange(port,
"GET /api/v1/prepared-items 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, "\"api_version\":1") != NULL);
CHECK(strstr(response, "\"items\":[]") != NULL);
CHECK(exchange(port,
"POST /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Content-Length: 0\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 405") != NULL);
{
TrainlogDashboardLayout layout;
char body[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { parsePreparedItems } from './preparedItems'
describe('projection des préparations', () => {
it('distingue une proposition IA d’un brouillon d’exécution', () => {
const value = parsePreparedItems({
api_version: 1,
generated_at: '2026-09-17T12:00:00Z',
partial: false,
items: [
{ identity: 'aid_one', kind: 'ai_proposal', title: 'Plan', planned_for: '2026-09-18', state: 'published', occurrence_count: 6, provenance: 'ai_import' },
{ identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', occurrence_count: 2, provenance: 'execution_store' },
],
})
expect(value.items.map((item) => item.kind)).toEqual(['ai_proposal', 'execution_draft'])
})
it('refuse les compteurs et types non bornés', () => {
expect(() => parsePreparedItems({ api_version: 1, generated_at: 'x', partial: false, items: [{ identity: 'x', kind: 'session', title: '', planned_for: null, state: '', occurrence_count: -1, provenance: '' }] })).toThrow()
})
})

View file

@ -0,0 +1,56 @@
export type PreparedItemKind = 'ai_proposal' | 'execution_draft'
export interface PreparedItem {
identity: string
kind: PreparedItemKind
title: string
planned_for: string | null
state: string
occurrence_count: number
provenance: string
}
export interface PreparedItemsSnapshot {
api_version: 1
generated_at: string
partial: boolean
items: PreparedItem[]
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function validItem(value: unknown): value is PreparedItem {
if (!record(value)) return false
const kind = value.kind
return (kind === 'ai_proposal' || kind === 'execution_draft') &&
typeof value.identity === 'string' &&
typeof value.title === 'string' &&
(value.planned_for === null || typeof value.planned_for === 'string') &&
typeof value.state === 'string' &&
Number.isSafeInteger(value.occurrence_count) &&
Number(value.occurrence_count) >= 0 &&
typeof value.provenance === 'string'
}
export function parsePreparedItems(value: unknown): PreparedItemsSnapshot {
if (!record(value) || value.api_version !== 1 ||
typeof value.generated_at !== 'string' ||
typeof value.partial !== 'boolean' ||
!Array.isArray(value.items) ||
value.items.length > 32 ||
!value.items.every(validItem)) {
throw new TypeError('projection des préparations invalide')
}
return value as unknown as PreparedItemsSnapshot
}
export async function fetchPreparedItems(signal?: AbortSignal): Promise<PreparedItemsSnapshot> {
const response = await fetch('/api/v1/prepared-items', {
headers: { Accept: 'application/json' },
signal,
})
if (!response.ok) throw new Error(`prepared-items HTTP ${response.status}`)
return parsePreparedItems(await response.json())
}

View file

@ -1,11 +1,126 @@
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 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
error_code?: string
sessions_reconciled?: number | null
inbound_generation_id?: string | null
outbound_generation_id?: string | null
missing_capabilities?: string[]
domains?: Record<string, boolean>
drafts?: SyncDraftSummary[]
started_at?: string | null
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 optionalString(value: unknown): boolean {
return value === undefined || value === null || typeof value === 'string'
}
function validDraft(value: unknown): value is SyncDraftSummary {
if (typeof value !== 'object' || value === null) return false
const draft = value as Partial<SyncDraftSummary>
return typeof draft.draft_id === 'string' &&
['active', 'pending', 'finalized', 'stale'].includes(draft.state ?? '') &&
typeof draft.session_type === 'string' &&
Number.isSafeInteger(draft.occurrence_count) &&
Number(draft.occurrence_count) >= 0
}
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' ||
!optionalString(status.run_id) ||
!optionalString(status.request_id) ||
!optionalString(status.diagnostic) ||
!optionalString(status.error_code) ||
!optionalString(status.started_at) ||
!optionalString(status.finished_at) ||
(status.progress_revision !== undefined &&
(!Number.isSafeInteger(status.progress_revision) || status.progress_revision < 0))) {
throw new Error('statut sync invalide')
}
if (status.drafts !== undefined &&
(!Array.isArray(status.drafts) || status.drafts.length > 32 || !status.drafts.every(validDraft))) {
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 {
/*
* WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure
@ -27,4 +142,6 @@ export function newRequestId(): string {
].join('-')
return `sy_${uuid}`
}
export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)}
export function syncIsActive(phase: SyncPhase): boolean {
return !['idle', 'completed', 'failed', 'interrupted', 'explicitly_degraded'].includes(phase)
}

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { fetchHealth, type Health } from '../api/health'
import { fetchDashboard, type DashboardSnapshot } from '../api/dashboard'
import { fetchPreparedItems, type PreparedItemsSnapshot } from '../api/preparedItems'
import { Footer } from '../components/Footer'
import { Header } from '../components/Header'
import { DashboardPage } from '../routes/DashboardPage'
@ -23,10 +24,24 @@ export function App() {
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null)
const [dashboardPending, setDashboardPending] = useState(true)
const [dashboardFailed, setDashboardFailed] = useState(false)
const [preparedItems, setPreparedItems] = useState<PreparedItemsSnapshot | null>(null)
const [preparedItemsPending, setPreparedItemsPending] = useState(true)
const [preparedItemsFailed, setPreparedItemsFailed] = useState(false)
const reloadDashboard = useCallback(() => {
fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) })
.catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false))
}, [])
const reloadPreparedItems = useCallback(() => {
setPreparedItemsPending(true)
fetchPreparedItems().then((value) => {
setPreparedItems(value)
setPreparedItemsFailed(false)
}).catch(() => {
// INVARIANT: a transient reread failure does not erase the last durable
// projection already shown to the user.
setPreparedItemsFailed(true)
}).finally(() => setPreparedItemsPending(false))
}, [])
useEffect(() => {
const controller = new AbortController()
@ -39,10 +54,25 @@ export function App() {
}).catch(() => {
setDashboard(null); setDashboardFailed(true)
}).finally(() => setDashboardPending(false))
fetchPreparedItems(controller.signal).then((value) => {
setPreparedItems(value)
setPreparedItemsFailed(false)
}).catch(() => {
setPreparedItemsFailed(true)
}).finally(() => setPreparedItemsPending(false))
return () => controller.abort()
}, [])
const content = route.id === 'dashboard' ? <DashboardPage dashboard={dashboard} pending={dashboardPending} failed={dashboardFailed} /> :
const content = route.id === 'dashboard' ? (
<DashboardPage
dashboard={dashboard}
pending={dashboardPending}
failed={dashboardFailed}
preparedItems={preparedItems}
preparedItemsPending={preparedItemsPending}
preparedItemsFailed={preparedItemsFailed}
/>
) :
<PlaceholderPage title={placeholderContent[route.id][0]}
eyebrow={placeholderContent[route.id][1]}
description={placeholderContent[route.id][2]} />
@ -50,7 +80,10 @@ 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} onSyncCommitted={() => {
reloadDashboard()
reloadPreparedItems()
}} />
<main className="app-main" id="main-content">{content}</main>
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} />
</div>

View file

@ -1,11 +1,61 @@
import { fireEvent,render,screen,waitFor } from '@testing-library/react'
import { describe,expect,it,vi } from 'vitest'
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)})
const headers = new Headers({ 'X-Trainlog-CSRF-Token': 'c'.repeat(64) })
function reply(value: unknown, status = 200) {
return { 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 runId = 'sy_00000000-0000-4000-8000-000000000001'
const fetch = vi.fn()
.mockResolvedValueOnce(reply({ phase: 'idle', result: 'idle' }))
.mockResolvedValueOnce(reply({ phase: 'requested', result: 'running', run_id: runId, progress_revision: 1 }, 202))
.mockResolvedValueOnce(reply({
phase: 'completed', result: 'completed', run_id: runId, 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)
})
it('refuse une réponse terminale d’un ancien run et poursuit le run admis', async () => {
const current = 'sy_00000000-0000-4000-8000-000000000002'
const stale = 'sy_00000000-0000-4000-8000-000000000001'
const fetch = vi.fn()
.mockResolvedValueOnce(reply({ phase: 'idle', result: 'idle' }))
.mockResolvedValueOnce(reply({ phase: 'running', result: 'running', run_id: current, progress_revision: 2, started_at: '2026-09-17T13:00:00Z' }, 202))
.mockResolvedValueOnce(reply({ phase: 'failed', result: 'failed', run_id: stale, progress_revision: 9, started_at: '2026-09-17T12:00:00Z' }))
.mockResolvedValueOnce(reply({ phase: 'completed', result: 'completed', run_id: current, progress_revision: 8, started_at: '2026-09-17T13:00:00Z' }))
vi.stubGlobal('fetch', fetch)
render(<SyncControl onCommitted={vi.fn()} />)
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: 3500 })
expect(screen.queryByText('Échec de synchronisation')).not.toBeInTheDocument()
})
it('affiche le code stable et l’action pour un timeout MTP', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(reply({
phase: 'failed', result: 'failed', error_code: 'transport_timeout',
diagnostic: 'transport_timeout: MTP push did not finish within 30 seconds',
})))
render(<SyncControl onCommitted={vi.fn()} />)
const summary = await screen.findByText('Échec de synchronisation')
fireEvent.click(summary)
expect(screen.getByText('Code : transport_timeout')).toBeInTheDocument()
expect(screen.getByText(/Vérifiez le dialogue USB\/MTP/)).toBeInTheDocument()
})
})

View file

@ -1,10 +1,153 @@
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>
import { useCallback, 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',
}
const committedPhases = new Set([
'local_import_committed',
'published',
'waiting_acknowledgement',
'peer_consumed',
'completed',
])
function statusAction(status: SyncStatus | null): string {
if (status?.phase === 'waiting_android_publication') {
return 'Ouvrez l’écran de synchronisation Android et laissez le téléphone déverrouillé.'
}
if (status?.phase === 'failed' && status.error_code === 'device_unavailable') {
return 'Connectez et déverrouillez le téléphone, puis réessayez.'
}
if (status?.phase === 'failed' && status.error_code === 'transport_timeout') {
return 'Vérifiez le dialogue USB/MTP sur Android avant de relancer.'
}
return ''
}
export function SyncControl({ onCommitted }: { onCommitted: () => void }) {
const [status, setStatus] = useState<SyncStatus | null>(null)
const [error, setError] = useState('')
const statusRef = useRef<SyncStatus | null>(null)
const committedByRun = useRef(new Map<string, number>())
const timer = useRef<number | undefined>(undefined)
const controller = useRef<AbortController | null>(null)
const mounted = useRef(false)
const accept = useCallback((next: SyncStatus): boolean => {
const current = statusRef.current
if (current?.run_id && next.run_id === current.run_id &&
(next.progress_revision ?? 0) < (current.progress_revision ?? 0)) {
return false
}
if (current?.run_id && next.run_id && next.run_id !== current.run_id &&
syncIsActive(current.phase)) {
if (!next.started_at || !current.started_at || next.started_at <= current.started_at) {
return false
}
}
statusRef.current = next
setStatus(next)
setError('')
if (next.run_id && committedPhases.has(next.phase)) {
const revision = next.progress_revision ?? 0
const prior = committedByRun.current.get(next.run_id) ?? -1
if (revision > prior) {
committedByRun.current.set(next.run_id, revision)
onCommitted()
}
}
return true
}, [onCommitted])
const schedulePoll = useCallback((delay: number) => {
if (timer.current !== undefined) window.clearTimeout(timer.current)
timer.current = window.setTimeout(async () => {
controller.current?.abort()
const nextController = new AbortController()
controller.current = nextController
try {
const next = await fetchSyncStatus(nextController.signal)
if (!mounted.current) return
const accepted = accept(next)
const current = accepted ? next : statusRef.current
if (current && syncIsActive(current.phase)) schedulePoll(750)
} catch (reason) {
if (!mounted.current || nextController.signal.aborted) return
setError('Statut indisponible : le dernier état confirmé reste affiché.')
}
}, delay)
}, [accept])
useEffect(() => {
mounted.current = true
schedulePoll(0)
return () => {
mounted.current = false
controller.current?.abort()
if (timer.current !== undefined) window.clearTimeout(timer.current)
}
}, [schedulePoll])
async function start() {
setError('')
try {
const admitted = await startSync(newRequestId())
accept(admitted)
schedulePoll(250)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Démarrage impossible')
}
}
const active = status ? syncIsActive(status.phase) : false
const action = statusAction(status)
return (
<div className="sync-control">
<button
type="button"
onClick={() => void start()}
disabled={active || status?.enabled === false}
>
Synchroniser
</button>
<details className="sync-details">
<summary aria-live="polite">{error || labels[status?.phase ?? 'idle']}</summary>
{action && <p>{action}</p>}
{status?.error_code && <p>Code : {status.error_code}</p>}
{status?.diagnostic && <p>{status.diagnostic}</p>}
{status?.sessions_reconciled !== null && status?.sessions_reconciled !== undefined && (
<p>Séances rapprochées : {status.sessions_reconciled}</p>
)}
{(status?.drafts?.length ?? 0) > 0 && (
<section aria-label="Brouillons synchronisés">
<h2>Brouillons reçus</h2>
<ul>
{status?.drafts?.map((draft) => (
<li key={draft.draft_id}>
<code>{draft.draft_id}</code> · {draft.state} · {draft.session_type} ·{' '}
{draft.occurrence_count} occurrence(s)
</li>
))}
</ul>
</section>
)}
{status?.started_at && <p>Démarrée : {status.started_at}</p>}
{status?.finished_at && <p>Dernier résultat : {status.finished_at}</p>}
</details>
</div>
)
}

View file

@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout'
import { Tile } from '../components/Tile'
import type { DashboardSnapshot } from '../api/dashboard'
import type { PreparedItemsSnapshot } from '../api/preparedItems'
import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout'
import {
cloneLayout,
@ -35,9 +36,23 @@ function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
return { columns: 12, rowHeight: 52 }
}
interface DashboardGridProps { dashboard?: DashboardSnapshot | null; pending?: boolean; failed?: boolean }
interface DashboardGridProps {
dashboard?: DashboardSnapshot | null
pending?: boolean
failed?: boolean
preparedItems?: PreparedItemsSnapshot | null
preparedItemsPending?: boolean
preparedItemsFailed?: boolean
}
export function DashboardGrid({ dashboard = null, pending = false, failed = false }: DashboardGridProps) {
export function DashboardGrid({
dashboard = null,
pending = false,
failed = false,
preparedItems = null,
preparedItemsPending = false,
preparedItemsFailed = false,
}: DashboardGridProps) {
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
const [editing, setEditing] = useState(false)
const [announcement, setAnnouncement] = useState('')
@ -169,7 +184,10 @@ export function DashboardGrid({ dashboard = null, pending = false, failed = fals
const content = dashboardTiles[id]
const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0]
const size = tileSize(dimensions)
const available = dashboard === null ? false : tileAvailable(dashboard, id)
const available = id === 'next-session'
? (preparedItems?.items.length ?? 0) > 0
: dashboard !== null && tileAvailable(dashboard, id)
const tilePending = id === 'next-session' ? preparedItemsPending : pending
const partial = dashboard?.meta.partial === true && id === 'max-records'
const TileContent = content.component
return (
@ -180,10 +198,21 @@ export function DashboardGrid({ dashboard = null, pending = false, failed = fals
size={size}
editable={editing && desktop}
onKeyDown={(event) => onTileKeyDown(event, id)}
stateLabel={pending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'}
stateLabel={tilePending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'}
stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'}
>
{dashboard === null ? <div className="tile-data-placeholder" aria-hidden="true"><span /><span /><span /></div> : <TileContent snapshot={dashboard} size={size} />}
{id === 'next-session' ? (
<NextSessionTile
size={size}
preparedItems={preparedItems}
pending={preparedItemsPending}
failed={preparedItemsFailed}
/>
) : dashboard === null ? (
<div className="tile-data-placeholder" aria-hidden="true"><span /><span /><span /></div>
) : (
<TileContent snapshot={dashboard} size={size} />
)}
</Tile>
</div>
)
@ -195,7 +224,7 @@ export function DashboardGrid({ dashboard = null, pending = false, failed = fals
}
function tileAvailable(snapshot: DashboardSnapshot, id: TileId): boolean {
if (id === 'next-session') return snapshot.data.next_session.available
if (id === 'next-session') return false
if (id === 'activity') return snapshot.data.activity.available
if (id === 'progression') return snapshot.data.progression.available
if (id === 'last-session') return snapshot.data.last_session.available

View file

@ -6,8 +6,8 @@ import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistri
describe('tuiles Dashboard alimentées par le contrat', () => {
it('présente les indisponibilités sans exposer les raisons techniques ni inventer de faits', () => {
const snapshot = dashboardFixture()
const { rerender } = render(<NextSessionTile snapshot={snapshot} size="large" />)
expect(screen.getByText('Aucune séance préparée')).toBeInTheDocument()
const { rerender } = render(<NextSessionTile preparedItems={{api_version:1,generated_at:'2026-09-17T12:00:00Z',partial:false,items:[]}} pending={false} failed={false} size="large" />)
expect(screen.getByText('Aucun élément disponible')).toBeInTheDocument()
expect(document.body).not.toHaveTextContent('no_persisted_executable_plan')
rerender(<CardioTile snapshot={snapshot} size="large" />)
expect(screen.getByText('Aucune donnée cardio disponible')).toBeInTheDocument()
@ -15,6 +15,31 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
expect(document.body).not.toHaveTextContent(/bpm|%/i)
})
it('sépare proposition à valider et brouillon actif sans fabriquer de réalisé', () => {
const proposal = {
api_version: 1 as const,
generated_at: '2026-09-17T12:00:00Z',
partial: false,
items: [{ identity: 'aid_one', kind: 'ai_proposal' as const, title: 'Préparation', planned_for: '2026-09-18', state: 'published', occurrence_count: 6, provenance: 'ai_import' }],
}
const { rerender } = render(
<NextSessionTile size="large" preparedItems={proposal} pending={false} failed={false} />,
)
expect(screen.getByText('Proposition préparée · à valider')).toBeInTheDocument()
expect(screen.getByText('Préparation')).toBeInTheDocument()
expect(document.body).not.toHaveTextContent(/réalisée|terminée/i)
rerender(<NextSessionTile size="large" preparedItems={{ ...proposal, items: [{ ...proposal.items[0], identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', occurrence_count: 2, provenance: 'execution_store' }] }} pending={false} failed={false} />)
expect(screen.getByText('Brouillon d’exécution · à reprendre')).toBeInTheDocument()
})
it('conserve une lecture durable lorsque son actualisation échoue', () => {
render(<NextSessionTile size="medium" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [{ identity: 'aid_one', kind: 'ai_proposal', title: 'Conservée', planned_for: null, state: 'published', occurrence_count: 1, provenance: 'ai_import' }] }} pending={false} failed />)
expect(screen.getByText('Conservée')).toBeInTheDocument()
expect(screen.getByText(/Dernière lecture conservée/)).toBeInTheDocument()
expect(screen.queryByText('Aucun élément disponible')).not.toBeInTheDocument()
})
it.each(['compact', 'medium', 'large'] as const)('rend les 90 jours et les sommes factuelles en taille %s', (size) => {
const { container } = render(<ActivityTile snapshot={dashboardFixture()} size={size} />)
const heatmap = screen.getByRole('img')

View file

@ -3,13 +3,53 @@ import { lazy, Suspense, type ComponentType } from 'react'
import type { TileSize } from './dashboardLayout'
import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat'
import { BodyZoneFigure } from './BodyZoneFigure'
import type { PreparedItemsSnapshot } from '../api/preparedItems'
const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart })))
interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize }
export function NextSessionTile({ size }: TileDataProps) {
return <div className="unavailable-content"><strong aria-hidden="true">—</strong><p>Aucune séance préparée</p>{size !== 'compact' && <small>Aucune séance préparée n’est actuellement disponible.</small>}{size === 'large' && <span className="future-action" aria-hidden="true">Préparation à venir</span>}</div>
export interface NextSessionTileProps {
size: TileSize
preparedItems?: PreparedItemsSnapshot | null
pending?: boolean
failed?: boolean
}
export function NextSessionTile({
size,
preparedItems = null,
pending = false,
failed = false,
}: NextSessionTileProps) {
if (preparedItems === null) {
return <Unavailable text={failed ? 'Données indisponibles' : 'Chargement des préparations…'} />
}
const item = preparedItems.items[0]
if (item === undefined) {
return <Unavailable text={failed ? 'Données indisponibles' : 'Aucun élément disponible'} />
}
const proposal = item.kind === 'ai_proposal'
const heading = proposal
? 'Proposition préparée · à valider'
: item.state === 'active'
? 'Brouillon d’exécution · à reprendre'
: 'Brouillon d’exécution · en attente'
const title = item.title || (proposal ? 'Proposition sans titre' : 'Séance préparée')
return (
<div className="session-content">
<p className="primary-label">{heading}</p>
<strong>{title}</strong>
{size !== 'compact' && (
<dl className="fact-list horizontal">
<Fact label="Éléments" value={String(item.occurrence_count)} />
<Fact label="Prévue" value={item.planned_for ? formatDate(item.planned_for) : 'Non renseignée'} />
</dl>
)}
{failed && <small>Dernière lecture conservée · actualisation indisponible</small>}
{pending && !failed && <small>Actualisation…</small>}
</div>
)
}
export function ActivityTile({ snapshot, size }: TileDataProps) {

View file

@ -1,15 +1,37 @@
import { DashboardGrid } from '../dashboard/DashboardGrid'
import type { DashboardSnapshot } from '../api/dashboard'
import type { PreparedItemsSnapshot } from '../api/preparedItems'
interface DashboardPageProps { dashboard: DashboardSnapshot | null; pending: boolean; failed: boolean }
interface DashboardPageProps {
dashboard: DashboardSnapshot | null
pending: boolean
failed: boolean
preparedItems: PreparedItemsSnapshot | null
preparedItemsPending: boolean
preparedItemsFailed: boolean
}
export function DashboardPage({ dashboard, pending, failed }: DashboardPageProps) {
export function DashboardPage({
dashboard,
pending,
failed,
preparedItems,
preparedItemsPending,
preparedItemsFailed,
}: DashboardPageProps) {
return (
<section className="page" aria-labelledby="page-title">
<div className="page-heading">
<div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div>
</div>
<DashboardGrid dashboard={dashboard} pending={pending} failed={failed} />
<DashboardGrid
dashboard={dashboard}
pending={pending}
failed={failed}
preparedItems={preparedItems}
preparedItemsPending={preparedItemsPending}
preparedItemsFailed={preparedItemsFailed}
/>
</section>
)
}