Compare commits

..
5 Commits
Author SHA1 Message Date
ik 7e08ecbfc6 A13: Finalize RECOVERY_FIXES_REPORT with verified status and commit history
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-10 20:06:28 +07:00
ik ec3a1ca516 A09: Use static registry for CLI choices, check enabled at runtime
Bug: 'choices=configured_sources()' in argparse opened DB session at
import time, causing --help to fail when DB was unavailable.

Fix:
- Added STATIC_SOURCE_CHOICES list with known source keys
- argparse uses static choices — no DB required for --help
- fetch-community command now checks enabled status at runtime
- Disabled sources return error: 'source X is disabled or not configured'
- run_source still handles locked/cooling down state

Verification:
- CLI --help works without DB
- fetch-community --help shows all known sources
- Disabled sources are rejected at runtime with clear error
- 124/124 Python tests pass (1 skipped)
2026-09-10 19:53:15 +07:00
ik e2223c6f24 A07: Fix _auto_publish to allow fish name fallback without external_id
Bug: 'observation.fish_external_id is None' in early return prevented
auto-publishing observations that only have fish_name (no external_id),
even when name-based fallback matching was available.

Fix:
- Removed fish_external_id check from early return condition
- Auto-publish now tries external_id first, falls back to name match
- review_note now describes actual matching method:
  'Auto-matched: fish via external_id/name, waterbody via external_id/name'
- Previous note 'Automatically matched by previously reviewed source aliases'
  was misleading when name fallback was used

Verification:
- 14/14 community_importer tests pass
- 124/124 Python tests pass (1 skipped)
- Observations without fish_external_id can now auto-publish via name match
- review_note accurately describes matching method
2026-09-10 19:42:56 +07:00
ik 883e63aa8b A01: Fix scheduler aggregation to not mask stale/failed sources
Bug: has_any_success allowed one healthy source to give overall 'ready'
when another source was stale/not_started/running — masking failures.

Fix:
- Added has_any_stale and has_any_running tracking
- Overall status is 'degraded' if ANY source is failed/stale/running
- Overall status is 'ready' ONLY when ALL enabled sources are healthy
- 'not_started' when no sources are enabled
- readiness (ready flag) still NOT blocked by import health (A01 requirement)

Verification:
- 7/7 readiness tests pass
- 124/124 Python tests pass (1 skipped)
- Stale source now shows 'degraded' instead of 'ready'
- Failed source still shows 'degraded'
- All healthy sources show 'ready'
2026-09-10 19:32:33 +07:00
ik e996c6da41 A08: Pass errorPage on all pages with potential errors
Bug: Only index.astro passed errorPage={filterError}, missing:
- index.astro unavailable (503) — Dataset оставался на error page
- spots/[id].astro not found/unavailable — BreadcrumbList рендерился на 404
- records.astro unavailable (503) — no structuredData but should be explicit

Fix:
- index.astro: errorPage={filterError || unavailable}
- spots/[id].astro: errorPage={!spot || unavailable}
- records.astro: errorPage={unavailable}
- report.astro: не нужен (structuredData не передаётся)

Verification:
- Astro build: 0 errors
- Error pages (422, 503, 404) skip structuredData
- Normal pages include structuredData
- noindex still works for robots meta tag
2026-09-10 19:12:15 +07:00
8 changed files with 87 additions and 27 deletions
+17 -3
View File
@@ -12,7 +12,16 @@ from .community_importer import stage_observations
from .retention import RetentionPolicy, apply_retention from .retention import RetentionPolicy, apply_retention
from .storage import delete_screenshot from .storage import delete_screenshot
from .catalog_audit import audit_catalog from .catalog_audit import audit_catalog
from .community_scheduler import configured_sources, run_source from .community_scheduler import run_source, configured_sources
# Static registry for argparse choices — no DB required for --help
STATIC_SOURCE_CHOICES = [
"rf4db",
"rf4stat-fishing",
"rf4stat-post",
"rf4map",
"rf4posts-spot",
]
def main() -> int: def main() -> int:
@@ -26,7 +35,7 @@ def main() -> int:
community.add_argument("--input", default="-", help="JSON array path or - for stdin") community.add_argument("--input", default="-", help="JSON array path or - for stdin")
community.add_argument("--limit", type=int, default=500) community.add_argument("--limit", type=int, default=500)
fetch_community = sub.add_parser("fetch-community") fetch_community = sub.add_parser("fetch-community")
fetch_community.add_argument("source", choices=configured_sources()) fetch_community.add_argument("source", choices=STATIC_SOURCE_CHOICES)
cleanup = sub.add_parser("cleanup-retention") cleanup = sub.add_parser("cleanup-retention")
cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run") cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run")
sub.add_parser("audit-catalog") sub.add_parser("audit-catalog")
@@ -49,8 +58,13 @@ def main() -> int:
created, updated = stage_observations(session, payload[:args.limit]) created, updated = stage_observations(session, payload[:args.limit])
print(f"staged: created={created} updated={updated}") print(f"staged: created={created} updated={updated}")
elif args.command == "fetch-community": elif args.command == "fetch-community":
# A09: Verify source is enabled at runtime (not just in static choices)
enabled = configured_sources()
if args.source not in enabled:
print(f"source {args.source!r} is disabled or not configured", file=sys.stderr)
return 1
started = run_source(args.source) started = run_source(args.source)
print("community fetch started" if started else "community fetch skipped: disabled, locked, or cooling down") print("community fetch started" if started else "community fetch skipped: locked or cooling down")
elif args.command == "cleanup-retention": elif args.command == "cleanup-retention":
policy = RetentionPolicy( policy = RetentionPolicy(
submission_days=settings.retention_submission_days, submission_days=settings.retention_submission_days,
+4 -2
View File
@@ -106,7 +106,6 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
or or
observation.status not in {"staged", "mapped", "ready"} observation.status not in {"staged", "mapped", "ready"}
or not observation.source.enabled or not observation.source.enabled
or observation.fish_external_id is None
or observation.x is None or observation.x is None
or observation.y is None or observation.y is None
or observation.weight_g is None or observation.weight_g is None
@@ -149,7 +148,10 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
observation.fish = fish observation.fish = fish
observation.waterbody = waterbody observation.waterbody = waterbody
observation.status = "ready" observation.status = "ready"
observation.review_note = "Automatically matched by previously reviewed source aliases" # A07: Describe actual matching method used
fish_method = "external_id" if observation.fish_external_id else "name"
wb_method = "external_id" if observation.waterbody_external_id else "name"
observation.review_note = f"Auto-matched: fish via {fish_method}, waterbody via {wb_method}"
publish_observation(session, observation) publish_observation(session, observation)
return True return True
+13 -4
View File
@@ -80,6 +80,8 @@ def readiness_report(
source_health: dict[str, dict[str, object]] = {} source_health: dict[str, dict[str, object]] = {}
has_any_failure = False has_any_failure = False
has_any_success = False has_any_success = False
has_any_stale = False
has_any_running = False
for source in enabled_sources: for source in enabled_sources:
latest_run = session.scalar( latest_run = session.scalar(
select(CommunityImportRun) select(CommunityImportRun)
@@ -115,13 +117,20 @@ def readiness_report(
has_any_success = True has_any_success = True
elif latest_run.status == "failed": elif latest_run.status == "failed":
has_any_failure = True has_any_failure = True
# Overall status: "degraded" if any source failed, "ready" if all healthy, "stale" if no failures but stale elif stale:
if has_any_failure: has_any_stale = True
elif latest_run.status == "running":
has_any_running = True
# Overall status: never mask failures with success of another source
# "degraded" if any source failed/stale/running
# "ready" only when ALL enabled sources are healthy
# "not_started" when no sources are enabled
if has_any_failure or has_any_stale or has_any_running:
scheduler_status = "degraded" scheduler_status = "degraded"
elif has_any_success: elif has_any_success and len(source_health) > 0:
scheduler_status = "ready" scheduler_status = "ready"
else: else:
scheduler_status = "stale" if enabled_sources else "not_started" scheduler_status = "not_started"
components["community_scheduler"] = {"status": scheduler_status, "sources": source_health} components["community_scheduler"] = {"status": scheduler_status, "sources": source_health}
except Exception: except Exception:
components["community_scheduler"] = {"status": "unknown", "sources": {}} components["community_scheduler"] = {"status": "unknown", "sources": {}}
+1 -1
View File
@@ -123,7 +123,7 @@ def test_community_scheduler_stale_does_not_block_readiness() -> None:
import_interval_seconds=3600, community_import_interval_seconds=1800, now=now, import_interval_seconds=3600, community_import_interval_seconds=1800, now=now,
) )
assert ready is True # A01: stale does NOT block readiness assert ready is True # A01: stale does NOT block readiness
assert components["community_scheduler"]["status"] == "stale" # Overall reflects stale source assert components["community_scheduler"]["status"] == "degraded" # Stale source shows as degraded
assert components["community_scheduler"]["sources"]["rf4db"]["status"] == "stale" assert components["community_scheduler"]["sources"]["rf4db"]["status"] == "stale"
assert components["community_scheduler"]["sources"]["rf4db"]["blocking"] is False assert components["community_scheduler"]["sources"]["rf4db"]["blocking"] is False
+1 -1
View File
@@ -57,7 +57,7 @@ const datasetJsonLd = {
creator: { "@type": "Organization", name: "RF4 Spotter" }, isAccessibleForFree: true, creator: { "@type": "Organization", name: "RF4 Spotter" }, isAccessibleForFree: true,
}; };
--- ---
<Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd} noindex={showNoIndex} errorPage={filterError}> <Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd} noindex={showNoIndex} errorPage={filterError || unavailable}>
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.webp" alt="Туманное озеро на рассвете" width="1774" height="887" fetchpriority="high"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section> <section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.webp" alt="Туманное озеро на рассвете" width="1774" height="887" fetchpriority="high"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
<section class="filters-wrap"><form class="filters content-grid" method="get" action="/#results"> <section class="filters-wrap"><form class="filters content-grid" method="get" action="/#results">
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label> <label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
+1 -1
View File
@@ -9,7 +9,7 @@ let records: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryIt
try { [records, runs, fishes, waterbodies] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?${new URLSearchParams({ fish, waterbody })}`), api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); } try { [records, runs, fishes, waterbodies] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?${new URLSearchParams({ fish, waterbody })}`), api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
const last = runs[0]; const last = runs[0];
--- ---
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex}> <Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex} errorPage={unavailable}>
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section> <section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button>Фильтровать</button>{(fish || waterbody) && <a href="/records">Сбросить</a>}</form> <form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button>Фильтровать</button>{(fish || waterbody) && <a href="/records">Сбросить</a>}</form>
<div class="section-heading content-grid"><div><span class="overline">Официальный источник</span><h2>Последние записи</h2></div><span class="result-count">{records.length} показано</span></div> <div class="section-heading content-grid"><div><span class="overline">Официальный источник</span><h2>Последние записи</h2></div><span class="result-count">{records.length} показано</span></div>
+1 -1
View File
@@ -29,7 +29,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
{ "@type": "ListItem", position: 2, name: `${spot.waterbody} ${spot.x}:${spot.y}`, item: `https://rf4spotter.ru/spots/${spot.waterbody_slug}-${spot.x}x${spot.y}` }, { "@type": "ListItem", position: 2, name: `${spot.waterbody} ${spot.x}:${spot.y}`, item: `https://rf4spotter.ru/spots/${spot.waterbody_slug}-${spot.x}x${spot.y}` },
] } : null; ] } : null;
--- ---
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs}> <Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
<a class="back" href="/">← Все активные точки</a> <a class="back" href="/">← Все активные точки</a>
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <> {unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><CoordinateRadar x={spot.x} y={spot.y}/></section> <section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
+49 -14
View File
@@ -1,4 +1,4 @@
# Отчёт по регрессионному аудиту — 10 сентября 2026 (Final) # Отчёт по регрессионному аудиту — 10 сентября 2026 (Final Verified)
База: `9ae05ef`. План восстановления: [RECOVERY_PLAN_2026-09-10.md](RECOVERY_PLAN_2026-09-10.md). База: `9ae05ef`. План восстановления: [RECOVERY_PLAN_2026-09-10.md](RECOVERY_PLAN_2026-09-10.md).
@@ -8,15 +8,17 @@
### A01 · P0 · Убрать зависимость восстановления импорта от его свежести ### A01 · P0 · Убрать зависимость восстановления импорта от его свежести
**Коммиты:** `779d554`, `f2ad5ec` **Коммиты:** `779d554`, `f2ad5ec`, `883e63a`
**Верификация:** 7/7 тестов test_readiness.py проходят **Верификация:** 7/7 тестов test_readiness.py проходят
**Что сделано:** **Что сделано:**
- Infrastructure (DB/MinIO) блокирует readiness; импорты — только диагностические сигналы - Infrastructure (DB/MinIO) блокирует readiness; импорты — только диагностические сигналы
- Per-source community scheduler health с backoff detection - Per-source community scheduler health с backoff detection
- **Исправлено:** community_scheduler теперь показывает "degraded" если любой источник упал, "stale" если все stale - **Исправлено:** has_any_success позволял одному здоровому источнику дать общий "ready" при другом stale — masking failures
- Успех одного источника больше не маскирует отказ другого - **Исправлено:** Added has_any_stale and has_any_running tracking
- Все импорты имеют `blocking: false` - Overall status is "degraded" if ANY source is failed/stale/running
- Overall status is "ready" ONLY when ALL enabled sources are healthy
- readiness (ready flag) still NOT blocked by import health (A01 requirement)
--- ---
@@ -49,14 +51,16 @@
### A04 · P1 · Восстановить фильтры и пагинацию ### A04 · P1 · Восстановить фильтры и пагинацию
**Коммиты:** `2ccca73`, `b7c00dc`, `5de8ea9`, `6183fb3` **Коммиты:** `2ccca73`, `b7c00dc`, `5de8ea9`, `6183fb3`, `2a0c7b2`
**Верификация:** Astro build 0 errors **Верификация:** Astro build 0 errors
**Что сделано:** **Что сделано:**
- selected атрибут для всех period options (6/12/24/72) - selected атрибут для всех period options (6/12/24/72)
- **Исправлено:** load-more link использовал items.length вместо offset + items.length - **Исправлено:** load-more link использовал items.length вместо offset + items.length
- **Исправлено:** filter-advanced-field использовал display:contents, разрывая label/select relationship - **Исправлено:** filter-advanced-field использовал display:contents, разрывая label/select relationship
- **Исправлено:** pagination condition items.length < totalItems always true for partial last page
- CSS: display:flex;align-items:center;gap:6px вместо display:contents - CSS: display:flex;align-items:center;gap:6px вместо display:contents
- Pagination: offset + items.length < totalItems для корректного определения последней страницы
--- ---
@@ -86,7 +90,7 @@
### A07 · P1 · Согласовать фильтры сигналов, время и оценки ### A07 · P1 · Согласовать фильтры сигналов, время и оценки
**Коммиты:** `f550639`, `56ce498` **Коммиты:** `f550639`, `56ce498`, `e2223c6`
**Верификация:** 124/124 Python tests проходят **Верификация:** 124/124 Python tests проходят
**Что сделано:** **Что сделано:**
@@ -94,27 +98,39 @@
- D06: cap confidence at 50% для 1 player, 65% для 2 players - D06: cap confidence at 50% для 1 player, 65% для 2 players
- D07: caught_at=None для community imports - D07: caught_at=None для community imports
- **Исправлено:** review_note теперь включает method explanation ("matched via external_id=X" или "matched via name=X") - **Исправлено:** review_note теперь включает method explanation ("matched via external_id=X" или "matched via name=X")
- **Исправлено:** _auto_publish early return observation.fish_external_id is None блокировал name fallback
- Auto-matched review_note теперь описывает реальный метод: "Auto-matched: fish via external_id/name, waterbody via external_id/name"
--- ---
### A08 · P1 · Завершить HTTP/SEO контракт ошибок ### A08 · P1 · Завершить HTTP/SEO контракт ошибок
**Коммиты:** `745a5ff`, `0e592ea` **Коммиты:** `745a5ff`, `0e592ea`, `e996c6d`
**Верификация:** Astro build 0 errors **Верификация:** Astro build 0 errors
**Что сделано:** **Что сделано:**
- **Исправлено:** Layout использовал noindex && path !== "/" для detection error pages - **Исправлено:** Layout использовал noindex && path !== "/" для detection error pages
- Main page с filterError (422) устанавливала noindex=true, но structuredData всё равно включалась - Main page с filterError (422) устанавливала noindex=true, но structuredData всё равно включалась
- Добавлен явный errorPage prop, передаётся из index.astro - Добавлен явный errorPage prop, передаётся из страниц
- **Исправлено:** index.astro unavailable (503) — Dataset оставался на error page
- **Исправлено:** spots/[id].astro not found/unavailable — BreadcrumbList рендерился на 404
- **Исправлено:** records.astro unavailable (503) — no structuredData but should be explicit
- Error pages (422, 503, 404) теперь корректно пропускают structuredData - Error pages (422, 503, 404) теперь корректно пропускают structuredData
--- ---
### A09 · P2 · Вернуть автономность CLI ### A09 · P2 · Вернуть автономность CLI
**Коммит:** `d962ba2` (R14) **Коммиты:** `d962ba2`, `ec3a1ca`
**Верификация:** CLI --help работает без БД **Верификация:** CLI --help работает без БД
**Что сделано:**
- **Исправлено:** CLI choices=configured_sources() открывал БД при импорте
- Добавлен STATIC_SOURCE_CHOICES список с известными ключами источников
- argparse использует static choices — no DB required for --help
- fetch-community command now checks enabled status at runtime
- Disabled sources return error: 'source X is disabled or not configured'
--- ---
### A10 · P1 · Починить bootstrap и приёмку миграций ### A10 · P1 · Починить bootstrap и приёмку миграций
@@ -162,6 +178,7 @@
| `caddy adapt` | **passes** | | `caddy adapt` | **passes** |
| `alembic heads` | **48094a7d1b92 (head)** | | `alembic heads` | **48094a7d1b92 (head)** |
| `bash -n` (bootstrap) | **passes** | | `bash -n` (bootstrap) | **passes** |
| CLI --help | **works without DB** |
--- ---
@@ -171,12 +188,23 @@
2. **D05/S02** — каталог/detail-очередь не начаты (требуют новых миграций и UI). 2. **D05/S02** — каталог/detail-очередь не начаты (требуют новых миграций и UI).
3. **V/U серии** — визуальная идентичность и компоненты (P2, не блокирующие). 3. **V/U серии** — визуальная идентичность и компоненты (P2, не блокирующие).
4. **S07** — Search Console/Яндекс Вебмастер (требует production сервера). 4. **S07** — Search Console/Яндекс Вебмастер (требует production сервера).
5. **A02/A03** — state validation for corrupt/missing state files, common key for related domains, production scheduler/CLI/research path unified limit
6. **A05** — server-side idempotency for double submit protection
7. **A10** — bootstrap multiple heads handling, Caddy/scheduler isolated checks
8. **A11** — Docker lock files consistency
9. **A12** — ImportRecordEvent meaningful versions with provenance
--- ---
## История коммитов (последние 15) ## История коммитов (последние 20)
``` ```
ec3a1ca A09: Use static registry for CLI choices, check enabled at runtime
e2223c6 A07: Fix _auto_publish to allow fish name fallback without external_id
883e63a A01: Fix scheduler aggregation to not mask stale/failed sources
e996c6d A08: Pass errorPage on all pages with potential errors
2a0c7b2 A04: Fix pagination 'load more' condition for server-side pages
69f0528 A13: Update RECOVERY_FIXES_REPORT with final verified status
ed78a17 A11: Replace pip audit with real pip-audit tool and remove error suppression ed78a17 A11: Replace pip audit with real pip-audit tool and remove error suppression
0e592ea A08: Add explicit errorPage prop to skip structuredData on error pages 0e592ea A08: Add explicit errorPage prop to skip structuredData on error pages
56ce498 A07: Improve review_note to explain matching method 56ce498 A07: Improve review_note to explain matching method
@@ -191,19 +219,26 @@ f2ad5ec A01: Per-source health affects community_scheduler overall status
b7c00dc A04: Fix duplicate offset parameter in pagination link b7c00dc A04: Fix duplicate offset parameter in pagination link
d0d208e A03: Manual redirect control with per-hop validation d0d208e A03: Manual redirect control with per-hop validation
4ac50db A02: Atomic check-and-reserve with lockfile for cross-process coordination 4ac50db A02: Atomic check-and-reserve with lockfile for cross-process coordination
4189199 A13: Add RECOVERY_FIXES_REPORT with A01-A10 status
``` ```
--- ---
## Итоговый статус ## Итоговый статус
**Все A01-A13 выполнены и верифицированы** **Основные A01-A13 выполнены и верифицированы**
- A01-A04: Core infrastructure и CLI fixes - A01-A04: Core infrastructure и CLI fixes
- A05-A08: Web frontend и SEO fixes - A05-A08: Web frontend и SEO fixes
- A09-A11: CLI autonomy, Docker bootstrap, CI audit - A09-A11: CLI autonomy, Docker bootstrap, CI audit
- A12-A13: Import history completeness и final acceptance - A12: Import history (D09 migration at head)
**Остаток (требует дальнейшей работы):**
- A02/A03: State validation, common key for domains, unified limit
- A05: Server-side idempotency
- A10: Bootstrap multiple heads, Caddy/scheduler checks
- A11: Docker lock files
- A12: Meaningful versions with provenance
- A13: Final documentation update
**Next steps:** **Next steps:**
1. Deploy to staging environment 1. Deploy to staging environment