Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/advanced/advanced-guide.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,13 @@ if __name__ == "__main__":

Alternative front-ends can reuse the `/api/config` endpoints that power the built-in YAML editor (one line each; see `src/profiles/server/routes/config.py` for the full contracts):

- `GET /api/config/content` — the active file's text as `{filename, content, config_path, session_trial}`; a session trial answers an empty body with `session_trial=True`, a missing file answers 404.
- `GET /api/config/content` — the active file's text as `{filename, content, config_path, session_trial, content_hash}`; `content_hash` is the SHA-256 of the served text, the staleness baseline to echo back on save (issue #116). A session trial answers an empty body with `session_trial=True`, a missing file answers 404.
- `POST /api/config/validate` — validate content in memory, never applied to the session; always answers 200 with `{valid, issues[]}` (invalid drafts come back as data, not as an HTTP error).
- `POST /api/config/template` — render the starter template in memory (nothing written, no state change); the same content `--init` puts on disk.
- `POST /api/config/save` — write the content to the active config file and re-apply it; refuses a session trial (400) since there is no file to overwrite.
- `POST /api/config/save-as` — write to a new file (extension whitelist `.profiles` / `.profiles.yaml`, overwrite guard answering 409 without `overwrite=true`) and anchor the session onto it.
- `POST /api/config/save` — write the content to the active config file (atomically, via temp file + replace) and re-apply it; refuses a session trial (400) since there is no file to overwrite. With an optional `baseline_hash`, answers **409** when the on-disk file no longer matches the hash the client loaded — an external edit is refused instead of silently clobbered; `overwrite_changes=true` lifts the guard. The response carries `content_hash`, the next save's baseline.
- `POST /api/config/save-as` — write to a new file (extension whitelist `.profiles` / `.profiles.yaml`, overwrite guard answering 409 without `overwrite=true`) and anchor the session onto it; no baseline check (the editor's baseline describes the file it loaded, not the save-as target).

Note the error duality on `save`/`save-as`: validation failures answer **400 with a JSON issues list** (`[{severity, message}, ...]`), while every other refusal (session-trial save, bad filename, missing directory, 409 existing target) answers with a **plain string** `detail` — clients must handle both shapes. The save endpoints also apply the freshly written file to the session, so a response's `config` object is the new live configuration.
Note the error duality on `save`/`save-as`: validation failures answer **400 with a JSON issues list** (`[{severity, message}, ...]`), while every other refusal (session-trial save, bad filename, missing directory, 409 existing target, 409 stale baseline) answers with a **plain string** `detail` — clients must handle both shapes. The save endpoints also apply the freshly written file to the session, so a response's `config` object is the new live configuration.

---

Expand Down
8 changes: 4 additions & 4 deletions docs/advanced/guide-avance.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,13 @@ if __name__ == "__main__":

Les interfaces alternatives peuvent réutiliser les endpoints `/api/config` qui alimentent l'éditeur YAML intégré (une ligne chacun ; voir `src/profiles/server/routes/config.py` pour les contrats complets) :

- `GET /api/config/content` — le texte du fichier actif sous `{filename, content, config_path, session_trial}` ; une config d'essai répond un corps vide avec `session_trial=True`, un fichier manquant répond 404.
- `GET /api/config/content` — le texte du fichier actif sous `{filename, content, config_path, session_trial, content_hash}` ; `content_hash` est le SHA-256 du texte servi, la baseline d'obsolescence à renvoyer à la sauvegarde (issue #116). Une config d'essai répond un corps vide avec `session_trial=True`, un fichier manquant répond 404.
- `POST /api/config/validate` — valide le contenu en mémoire, jamais appliqué à la session ; répond toujours 200 avec `{valid, issues[]}` (les brouillons invalides reviennent sous forme de données, pas d'erreur HTTP).
- `POST /api/config/template` — rend le modèle starter en mémoire (rien n'est écrit, aucun changement d'état) ; le même contenu que `--init` pose sur disque.
- `POST /api/config/save` — écrit le contenu dans le fichier de config actif puis le ré-applique ; refuse une config d'essai (400) puisqu'il n'y a aucun fichier à écraser.
- `POST /api/config/save-as` — écrit dans un nouveau fichier (liste blanche d'extensions `.profiles` / `.profiles.yaml`, garde d'existence répondant 409 sans `overwrite=true`) et ancre la session dessus.
- `POST /api/config/save` — écrit le contenu dans le fichier de config actif (de manière atomique, via fichier temporaire + replace) puis le ré-applique ; refuse une config d'essai (400) puisqu'il n'y a aucun fichier à écraser. Avec un `baseline_hash` optionnel, répond **409** quand le fichier sur disque ne correspond plus au hash chargé par le client — une édition externe est refusée au lieu d'être écrasée silencieusement ; `overwrite_changes=true` lève la garde. La réponse porte `content_hash`, la baseline de la prochaine sauvegarde.
- `POST /api/config/save-as` — écrit dans un nouveau fichier (liste blanche d'extensions `.profiles` / `.profiles.yaml`, garde d'existence répondant 409 sans `overwrite=true`) et ancre la session dessus ; pas de vérification de baseline (la baseline de l'éditeur décrit le fichier chargé, pas la cible du save-as).

Notez la dualité des erreurs sur `save`/`save-as` : un échec de validation répond **400 avec une liste JSON de problèmes** (`[{severity, message}, ...]`), tandis que tout autre refus (save d'une config d'essai, nom de fichier invalide, répertoire inexistant, cible existante en 409) répond avec un `detail` **chaîne simple** — les clients doivent gérer les deux formes. Les endpoints de sauvegarde appliquent aussi le fichier fraîchement écrit à la session : le `config` de la réponse est donc la nouvelle configuration active.
Notez la dualité des erreurs sur `save`/`save-as` : un échec de validation répond **400 avec une liste JSON de problèmes** (`[{severity, message}, ...]`), tandis que tout autre refus (save d'une config d'essai, nom de fichier invalide, répertoire inexistant, cible existante en 409, baseline obsolète en 409) répond avec un `detail` **chaîne simple** — les clients doivent gérer les deux formes. Les endpoints de sauvegarde appliquent aussi le fichier fraîchement écrit à la session : le `config` de la réponse est donc la nouvelle configuration active.

---

Expand Down
4 changes: 4 additions & 0 deletions src/profiles/core/config/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@
CONFIG_FILE_NAMES,
FALLBACK_CONFIG_NAMES,
PRIMARY_CONFIG_NAME,
content_hash,
find_config_file,
read_yaml,
write_text_atomic,
write_value,
)

__all__ = [
"CONFIG_FILE_NAMES",
"FALLBACK_CONFIG_NAMES",
"PRIMARY_CONFIG_NAME",
"content_hash",
"find_config_file",
"read_yaml",
"write_text_atomic",
"write_value",
]
52 changes: 52 additions & 0 deletions src/profiles/core/config/io/yaml_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

from __future__ import annotations

import hashlib
import os
import tempfile
from collections import deque
from pathlib import Path

Expand Down Expand Up @@ -83,6 +85,54 @@ def write_value(path: Path | str, dotted_key: str, value: object) -> None:
_yaml.dump(data, fh)


def write_text_atomic(path: Path | str, content: str) -> None:
"""Write *content* to *path* atomically (temp file + ``os.replace``).

A plain ``write_text`` can leave a truncated file behind when the
process dies or power is lost mid-write — and the configuration file
is the user's only one. The text is written to a temp file created in
the *same directory* (so ``os.replace`` stays on one filesystem and
remains atomic), flushed to disk, then renamed over the target.

Args:
path: Destination file path.
content: Full text to write (UTF-8 encoded).
"""
p = Path(path)
fd, tmp_name = tempfile.mkstemp(
dir=p.parent,
prefix=f"{p.name}.",
suffix=".tmp",
)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(content)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path, p)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise


def content_hash(content: str) -> str:
"""Return the SHA-256 hex digest of *content* (UTF-8 encoded).

Version marker returned by ``GET /api/config/content`` and compared
by ``POST /api/config/save`` against the on-disk file, so an external
edit landing between the editor's load and its Save is detected
instead of silently clobbered (issue #116).

Args:
content: Text to fingerprint.

Returns:
Lowercase hex digest string.
"""
return hashlib.sha256(content.encode("utf-8")).hexdigest()


def _find_named_file(start: Path, name: str, max_depth: int) -> Path | None:
"""Return the first *name* file within *max_depth* of *start*.

Expand Down Expand Up @@ -170,7 +220,9 @@ def find_config_file(
"CONFIG_FILE_NAMES",
"FALLBACK_CONFIG_NAMES",
"PRIMARY_CONFIG_NAME",
"content_hash",
"find_config_file",
"read_yaml",
"write_text_atomic",
"write_value",
]
97 changes: 77 additions & 20 deletions src/profiles/server/routes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pydantic import BaseModel

from profiles.core.config.diagnostics import collect_config_issues
from profiles.core.config.io.yaml_io import CONFIG_FILE_NAMES
from profiles.core.config.io.yaml_io import CONFIG_FILE_NAMES, content_hash, write_text_atomic
from profiles.core.config.models import AppConfig
from profiles.core.config.reader import ConfigReader
from profiles.core.config.template import STARTER_CONFIG_TEMPLATE
Expand Down Expand Up @@ -44,6 +44,13 @@ class ValidateRequest(BaseModel):
class SaveRequest(BaseModel):
content: str
force: bool = False
# Staleness guard (issue #116): the content hash the editor loaded
# (from GET /content or a previous save response). Optional — a save
# without it keeps the legacy no-check behaviour.
baseline_hash: str | None = None
# When True a baseline mismatch no longer refuses the write: the user
# answered the 409 conflict with "overwrite anyway".
overwrite_changes: bool = False


class SaveAsRequest(BaseModel):
Expand Down Expand Up @@ -365,21 +372,27 @@ async def validate_config_endpoint(payload: ValidateRequest) -> dict:
async def get_config_content(request: Request) -> dict:
"""Return the active configuration file's text for the YAML editor.

Responds ``{"filename", "content", "config_path", "session_trial"}``.
A session trial (config applied from dropped content) answers the
text that was applied (``ServerState.trial_content``, issue #97) with
Responds ``{"filename", "content", "config_path", "session_trial",
"content_hash"}``. ``content_hash`` is the SHA-256 of the served
text (issue #116): the editor echoes it back on ``POST /save`` as
its baseline so a concurrent external edit is refused with 409
instead of silently clobbered. A session trial (config applied from
dropped content) answers the text that was applied
(``ServerState.trial_content``, issue #97) with
``session_trial=True`` and ``config_path=None`` — ``config_path``
stays ``None`` so the stale CWD-resolved path never leaks, and
``filename`` carries the config's display name. A
recorded-but-missing file answers 404.
"""
state: ServerState = request.app.state.server_state
if not has_disk_path(state):
content = state.trial_content or ""
return {
"filename": state.config.title or "session-trial",
"content": state.trial_content or "",
"content": content,
"config_path": None,
"session_trial": True,
"content_hash": content_hash(content),
}

config_path = state.active_config_path()
Expand All @@ -389,11 +402,13 @@ async def get_config_content(request: Request) -> dict:
detail=f"Configuration file not found: {config_path}",
)

content = config_path.read_text(encoding="utf-8")
return {
"filename": config_path.name,
"content": config_path.read_text(encoding="utf-8"),
"content": content,
"config_path": str(config_path),
"session_trial": False,
"content_hash": content_hash(content),
}


Expand All @@ -402,16 +417,26 @@ async def save_config_endpoint(payload: SaveRequest, request: Request) -> dict:
"""Write editor content to the active config file and apply it.

Gate order: session-trial refusal (:func:`has_disk_path`) →
:func:`validate_issues` → 400 with the issues list when invalid and
not ``force`` → write the file → :func:`_apply_config_from_disk`, so
the live configuration is always re-read from what is on disk.
staleness guard → :func:`validate_issues` → 400 with the issues list
when invalid and not ``force`` → atomic write (temp file +
:func:`os.replace`, issue #116) → :func:`_apply_config_from_disk`,
so the live configuration is always re-read from what is on disk.
``force=True`` is the escape hatch that lets a broken draft reach the
disk (it will load as defaults until fixed); without it, bad YAML
never touches the file.

Returns ``{"status": "SAVED", "path": str, "config": <config dict>}``;
the apply step clears ``session_trial``, anchoring the saved config
to its file.
The staleness guard (issue #116) fires only when the client sends
``baseline_hash``: when the on-disk file's hash no longer matches —
an external edit landed between the editor's load and this Save —
the write is refused with **409** instead of silently clobbered;
``overwrite_changes=True`` answers the user's "overwrite anyway"
decision and lets the write through. Older callers that send no
baseline keep the legacy no-check behaviour.

Returns ``{"status": "SAVED", "path": str, "config": <config dict>,
"content_hash": str}`` — the hash of what was written, the next
save's baseline; the apply step clears ``session_trial``, anchoring
the saved config to its file.
"""
state: ServerState = request.app.state.server_state
if not has_disk_path(state):
Expand All @@ -422,13 +447,35 @@ async def save_config_endpoint(payload: SaveRequest, request: Request) -> dict:
)

config_path = state.active_config_path()

if payload.baseline_hash is not None and not payload.overwrite_changes:
if not config_path.exists():
raise HTTPException(
status_code=409,
detail=f"Configuration file not found on disk: {config_path}. "
"It was deleted or moved since it was loaded.",
)
on_disk_hash = content_hash(config_path.read_text(encoding="utf-8"))
if on_disk_hash != payload.baseline_hash:
raise HTTPException(
status_code=409,
detail="The configuration file changed on disk since it was "
"loaded (edited externally?). Saving would overwrite those "
"changes.",
)

issues = validate_issues(payload.content)
if issues and not payload.force:
raise HTTPException(status_code=400, detail=issues)

config_path.write_text(payload.content, encoding="utf-8")
write_text_atomic(config_path, payload.content)
config = _apply_config_from_disk(state, config_path)
return {"status": "SAVED", "path": str(config_path), "config": config}
return {
"status": "SAVED",
"path": str(config_path),
"config": config,
"content_hash": content_hash(payload.content),
}


SAVE_AS_EXTENSIONS = (".profiles", ".profiles.yaml")
Expand All @@ -446,13 +493,18 @@ async def save_as_config_endpoint(payload: SaveAsRequest, request: Request) -> d
(:data:`SAVE_AS_EXTENSIONS`) → :func:`normalize_user_path` +
directory existence → existence/overwrite guard (409 when the target
exists without ``overwrite=True``) → :func:`validate_issues` → 400
with the issues list when invalid and not ``force`` → write the file
→ :func:`_apply_config_from_disk`, so the live configuration is
with the issues list when invalid and not ``force`` → atomic write
(temp file + :func:`os.replace`, issue #116) →
:func:`_apply_config_from_disk`, so the live configuration is
always re-read from what is on disk (which also clears
``session_trial``).

Returns ``{"status": "SAVED", "path": str, "config": <config dict>}``,
the same shape as ``POST /save``.
No baseline staleness check here (unlike ``POST /save``): the
editor's baseline describes the file it loaded, not the save-as
target — the existing-target 409 above is the guard for that path.

Returns ``{"status": "SAVED", "path": str, "config": <config dict>,
"content_hash": str}``, the same shape as ``POST /save``.
"""
state: ServerState = request.app.state.server_state

Expand Down Expand Up @@ -485,9 +537,14 @@ async def save_as_config_endpoint(payload: SaveAsRequest, request: Request) -> d
if issues and not payload.force:
raise HTTPException(status_code=400, detail=issues)

target.write_text(payload.content, encoding="utf-8")
write_text_atomic(target, payload.content)
config = _apply_config_from_disk(state, target)
return {"status": "SAVED", "path": str(target), "config": config}
return {
"status": "SAVED",
"path": str(target),
"config": config,
"content_hash": content_hash(payload.content),
}


@router.post("/select-directory")
Expand Down
Loading
Loading