sdlc-hub/docs/superpowers/plans/2026-10-14-viewer-live-hub.md
Andrey Epifancev cc7b83fbb8 docs: план viewer-live и решения этапа плана R-6…R-13 (#17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:50:03 +04:00

1032 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# viewer-live — хаб: план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** доска связей «Спринт 2 линк» открывается с хаба (`http://127.0.0.1:8790/`) и показывает живые данные зеркала:
вход по клиентскому токену, снимок `GET /board`, «Перечитать», «Обновить из Jira» с ожиданием пакета, перенос раскладки
импортом JSON, CSV — запасной путь.
**Architecture:** хаб получает два маршрута: `GET /board` (`mirror.board` — снимок в одной транзакции чтения) и `GET /`,
`GET /links` без токена (статический файл `src/jira_hub/web/links.html`, не-JSON ответ `Raw`). Страница — копия
`admin/sprint-2-links.html`, к которой добавлены вход, загрузка из хаба и обновление; логика доски не меняется.
Первый коммит страницы — копия как есть, чтобы дальнейшие правки читались диффом.
**Tech Stack:** Python 3.13, uv, pytest, SQLite (stdlib), stdlib `http.server`; страница — один HTML без зависимостей
(ванильный JS, `fetch`, `localStorage`); проверка страницы — Playwright (MCP) и `node --check`.
**Spec:** `openspec/changes/viewer-live/` (proposal.md, design.md, specs/hub-api/spec.md).
## Global Constraints
- Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/viewer` (ветка `feat/17-viewer-live`). Все
команды — с этим cwd; основной checkout `jira-hub/` (main) и `admin/sprint-2-links.html` не правятся (из `admin/` только
копирование в Task 2).
- Комментарии, docstring, тексты страницы, сообщения ошибок и коммитов — по-русски; идентификаторы — английские.
- `uv run pytest -q` — зелёным считается только весь набор (до начала: 206 passed).
- Новых зависимостей нет; миграции схемы нет (`SCHEMA_VERSION` остаётся 2); контракт агента не меняется.
- Ключ токена в браузере — ровно `localStorage["jira-hub-token"]`; заголовок — `Authorization: Bearer <токен>`; запросы
страницы — относительные пути (тот же хост).
- «Обновить из Jira» — ровно `POST /refresh {"full": true, "author": "viewer"}`; опрос `GET /batches/{id}` каждые 3000 мс.
- Страница не содержит данных зеркала и встроенной выгрузки (`DEFAULT_CSV`, `DEFAULT_NAME` удаляются); `EPICS`,
`KIND_LABELS`, `LINK_TYPES` остаются.
- Служебная дорожка ключей вне зеркала — ровно «Нет в зеркале»; заглушка — «нет в зеркале».
- Логика доски (раскладка, критический путь, проблемы, таблица, тема, ключи хранилища `graph-*`) не меняется.
- Каждая задача заканчивается коммитом с заголовком на русском, суффиксом `(#17)` и строкой
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Решения исполнителя без обсуждения — в `openspec/changes/viewer-live/design.md`, раздел
`## Decisions made without discussion`, со следующим свободным маркером (занято R-1…R-13).
- Клиентский токен **живого** хаба не печатать и не вводить в браузер в ходе проверок до merge: проверки идут на
тестовом хабе и на копии базы с пересозданными токенами.
## Review Focus
1. Токен вставлен с пробелом/переводом строки или с не-ASCII символом (кириллица из буфера): `fetch` с таким заголовком
бросает `TypeError`, страница не должна сообщать «хаб не отвечает» — поле токена показывает «недопустимые символы».
Проверка — шаг Playwright в Task 3.
2. Хаб остановлен во время опроса пакета: страница не падает и не бросает опрос, а пишет предупреждение и повторяет;
после запуска хаба доводит пакет и перечитывает доску. Проверка — шаг Playwright в Task 4.
3. Токен пересоздан, пока страница открыта (401 на «Перечитать» или посреди опроса): форма входа с пояснением, без
зацикливания запросов. Проверка — шаги Playwright в Task 3 и Task 4.
4. В зеркале связь неизвестного типа (`Cloners`), связь с эпиком и связь на ключ вне зеркала: первые две пропускаются,
третья даёт заглушку на дорожке «Нет в зеркале». Проверка — фикстура учебного агента и шаг Playwright в Task 3.
5. Тема задачи с HTML-разметкой и кавычками (`<b>разметка</b> & "кавычки"`): показывается текстом, не разметкой.
Проверка — фикстура учебного агента и шаг Playwright в Task 3.
## Проверочный стенд (Task 3–5)
Тестовый хаб и учебный агент живут в рабочем каталоге плана `WS=$(<repo>/.superpowers/sdd/2026-10-14-viewer-live-hub)`
(git-ignored). Учебный агент — файл `$WS/fake_agent.py` (создаётся в Task 3, шаг 1, не коммитится):
```python
"""Учебный агент для проверки страницы доски: берёт команды тестового хаба и отвечает загрузкой фикстуры.
Запуск (JIRA_HUB_HOME — каталог тестового хаба): uv run python fake_agent.py http://127.0.0.1:8791 [--once] [--fail]"""
import sys
import time
from jira_hub import db, paths, settings
from jira_hub.client import HubClient
ISSUES = [
{"key": "AURORA-1351", "issue_type": "Фича", "summary": "Справочники ГК / ГСК / ГСЗ / ЮЛ", "labels": ["AWPCA"]},
{"key": "AURORA-1620", "issue_type": "Задача", "summary": 'ТЕСТ доски: <b>разметка</b> & "кавычки"',
"status": "В работе", "priority": "Высокий", "assignee": "Epifantsev-AA@rshbavt.ru", "sprint": "AWPCA_Sprint_2_VGO",
"planned_end": "2026-10-21", "duedate": "2026-10-25", "epic_key": "AURORA-1351", "labels": ["AWPCA", "kind-dev"]},
{"key": "AURORA-1621", "issue_type": "Задача", "summary": "ТЕСТ доски: блокируемая", "status": "Ожидание разработки",
"priority": "Средний", "assignee": "Stepanov-MAl", "sprint": "AWPCA_Sprint_2_VGO", "duedate": "2026-10-23",
"epic_key": "AURORA-1351", "labels": ["AWPCA", "kind-ui"]},
{"key": "AURORA-1500", "issue_type": "Ошибка", "summary": "ТЕСТ доски: сосед без спринта", "status": "Готово",
"priority": "Низкий", "labels": []},
]
LINKS = [
{"source_key": "AURORA-1620", "target_key": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"},
{"source_key": "AURORA-1621", "target_key": "AURORA-1500", "link_type": "ASUPR_Связанные"},
{"source_key": "AURORA-1620", "target_key": "AURORA-9999", "link_type": "ASUPR_Блокер_Завершения"},
{"source_key": "AURORA-1620", "target_key": "AURORA-1621", "link_type": "Cloners"},
{"source_key": "AURORA-1621", "target_key": "AURORA-1351", "link_type": "ASUPR_Связанные"},
]
def main():
base, once, fail = sys.argv[1], "--once" in sys.argv, "--fail" in sys.argv
conn = db.connect(paths.db_path())
token = settings.tokens(conn)["agent"]
conn.close()
agent = HubClient(base, token, timeout=90)
n = 0
while True:
cmd = agent.post("/agent/claim", {"agent_version": 4})["command"]
if not cmd:
if once:
return
continue
n += 1
time.sleep(4) # страница успевает показать «выполняется»
if fail:
agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "учебная ошибка: Jira ответила 503"})
else:
issues = [dict(i) for i in ISSUES]
issues[2]["summary"] = f"ТЕСТ доски: блокируемая (прогон {n})"
agent.post("/agent/state", {"command_id": cmd["id"], "issues": issues, "links": LINKS,
"neighbor_keys": ["AURORA-1500"]})
agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1})
print("команда", cmd["id"], cmd["type"], "→", "failed" if fail else "done", flush=True)
if once:
return
if __name__ == "__main__":
main()
```
Запуск тестового хаба (фоном) и заполнение зеркала одним полным прогоном:
```bash
WS=$(pwd)/.superpowers/sdd/2026-10-14-viewer-live-hub
export JIRA_HUB_HOME=$WS/hub-test
uv run jira-hub serve --host 127.0.0.1 --port 8791 # фоном (run_in_background)
CT=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p')
curl -s -X POST -H "Authorization: Bearer $CT" -H 'Content-Type: application/json' \
-d '{"full": true, "author": "seed"}' http://127.0.0.1:8791/refresh
uv run python $WS/fake_agent.py http://127.0.0.1:8791 --once
```
Токен тестового хаба одноразовый — его можно вводить в браузер Playwright. Страница: `http://127.0.0.1:8791/`.
---
### Task 1: `GET /board` — снимок зеркала для доски
**Files:**
- Modify: `src/jira_hub/mirror.py` (новая функция `board` и список `BOARD_FIELDS` после `get_issue`)
- Modify: `src/jira_hub/api.py` (обработчик `h_board`, строка в `ROUTES`)
- Test: `tests/test_mirror.py`, `tests/test_api.py`
**Interfaces:**
- Consumes: `mirror._issue_dict(row)` (labels → список, neighbor → bool), `settings.get(conn, "base_jql")`, `db.now()`.
- Produces: `mirror.board(conn, base_jql: str) -> dict` с ключами `generated_at: str`, `last_sync: dict | None`
(`started_at`, `finished_at`, `issues_count`), `issues: list[dict]` (поля `BOARD_FIELDS` + `fact_status`,
`work_kind`), `links: list[dict]` (`source`, `target`, `link_type`); маршрут `GET /board` (клиентский токен) отдаёт
его как есть. Страница (Task 3) читает именно эти имена.
- [ ] **Step 1: Написать падающие тесты `mirror.board`**
В конец `tests/test_mirror.py`:
```python
BASE = "project = AURORA AND labels = AWPCA"
def _sync_run(conn, started, finished, kind, query, status="done", count=5):
"""Строка журнала синхронизаций с командой refresh в нужном статусе."""
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('t', 'test', ?)", (started,))
batch_id = conn.execute("SELECT last_insert_rowid() AS i").fetchone()["i"]
conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at) VALUES (?, 1, 'refresh', '{}', ?, ?)",
(batch_id, status, finished))
command_id = conn.execute("SELECT last_insert_rowid() AS i").fetchone()["i"]
conn.execute("INSERT INTO sync_run(started_at, finished_at, kind, query, issues_count, command_id) VALUES (?, ?, ?, ?, ?, ?)",
(started, finished, kind, query, count, command_id))
def test_board_snapshot_raw_mirror_data():
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("AURORA-1351", issue_type="Фича", epic_key=None),
issue("AURORA-1620", labels=["kind-dev"], epic_key="AURORA-1351"),
issue("AURORA-1621", epic_key="AURORA-1351")],
[{"source_key": "AURORA-1620", "target_key": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}],
neighbor_keys=["AURORA-1621"])
mirror.set_note(conn, "AURORA-1620", {"work_kind": "разработка"}, "test")
snap = mirror.board(conn, BASE)
assert [i["key"] for i in snap["issues"]] == ["AURORA-1351", "AURORA-1620", "AURORA-1621"]
a = snap["issues"][1]
assert set(a) == {"key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate",
"planned_start", "planned_end", "epic_key", "labels", "neighbor", "fact_status", "work_kind"}
assert a["labels"] == ["kind-dev"] and a["epic_key"] == "AURORA-1351"
assert a["work_kind"] == "разработка" and a["fact_status"] is None and a["neighbor"] is False
assert snap["issues"][2]["neighbor"] is True and snap["issues"][2]["work_kind"] is None
assert snap["links"] == [{"source": "AURORA-1620", "target": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}]
assert snap["last_sync"] is None and snap["generated_at"]
def test_board_last_sync_is_last_successful_full_refresh():
conn = db.connect(":memory:")
_sync_run(conn, "2026-10-14T09:59:00+00:00", "2026-10-14T10:00:00+00:00", "jql", BASE, count=198)
_sync_run(conn, "2026-10-14T10:59:00+00:00", "2026-10-14T11:00:00+00:00", "keys", "AURORA-1620", count=1)
_sync_run(conn, "2026-10-14T11:30:00+00:00", "2026-10-14T11:31:00+00:00", "jql", "key = AURORA-1", count=1)
_sync_run(conn, "2026-10-14T12:00:00+00:00", "2026-10-14T12:01:00+00:00", "jql", BASE, status="failed", count=0)
_sync_run(conn, "2026-10-14T13:00:00+00:00", None, "jql", BASE, status="claimed", count=50)
assert mirror.board(conn, BASE)["last_sync"] == {"started_at": "2026-10-14T09:59:00+00:00",
"finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198}
```
- [ ] **Step 2: Запустить — тесты падают**
Run: `uv run pytest tests/test_mirror.py -q -k board`
Expected: 2 failed, `AttributeError: module 'jira_hub.mirror' has no attribute 'board'`
- [ ] **Step 3: Реализовать `mirror.board`**
В `src/jira_hub/mirror.py` после `get_issue`:
```python
BOARD_FIELDS = ["key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate", "planned_start",
"planned_end", "epic_key", "labels", "neighbor"]
def board(conn, base_jql) -> dict:
"""Снимок зеркала для доски связей (GET /board): все задачи с заметками fact_status/work_kind, все связи и последний
успешный полный прогон (refresh по базовой JQL, команда done). Данные без преобразований — перевод в модель доски
делает интерфейс. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы."""
own = not conn.in_transaction
if own:
conn.execute("BEGIN")
try:
issues = [_issue_dict(r) for r in conn.execute(
f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.fact_status, n.work_kind "
"FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")]
links = [dict(r) for r in conn.execute(
"SELECT source_key AS source, target_key AS target, link_type FROM issue_link "
"ORDER BY source_key, target_key, link_type")]
run = conn.execute(
"SELECT s.started_at, s.finished_at, s.issues_count FROM sync_run s JOIN command c ON c.id = s.command_id "
"WHERE s.kind = 'jql' AND s.query = ? AND s.finished_at IS NOT NULL AND c.status = 'done' "
"ORDER BY s.started_at DESC, s.id DESC LIMIT 1", (base_jql,)).fetchone()
finally:
if own:
conn.execute("COMMIT")
return {"generated_at": db.now(), "last_sync": dict(run) if run else None, "issues": issues, "links": links}
```
- [ ] **Step 4: Запустить — тесты проходят**
Run: `uv run pytest tests/test_mirror.py -q -k board`
Expected: 2 passed
- [ ] **Step 5: Написать падающий тест маршрута**
В конец `tests/test_api.py`:
```python
def test_board_route(hub):
a, c = hub["agent"], hub["client"]
a.post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "labels": ["kind-dev"]},
{"key": "B-1", "summary": "Сосед", "labels": []}],
"links": [{"source_key": "A-1", "target_key": "B-1", "link_type": "ASUPR_Связанные"}],
"neighbor_keys": ["B-1"]})
snap = c.get("/board")
assert [i["key"] for i in snap["issues"]] == ["A-1", "B-1"]
assert snap["issues"][0]["labels"] == ["kind-dev"] and snap["issues"][1]["neighbor"] is True
assert snap["links"] == [{"source": "A-1", "target": "B-1", "link_type": "ASUPR_Связанные"}]
assert snap["last_sync"] is None
b = c.post("/refresh", {"full": True, "author": "viewer"})
cmd = a.post("/agent/claim", {})["command"]
a.post("/agent/state", {"command_id": cmd["id"], "issues": [{"key": "A-1", "summary": "Т2", "labels": []}], "links": []})
a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1})
assert c.get(f"/batches/{b['id']}")["author"] == "viewer"
last = c.get("/board")["last_sync"]
assert last["issues_count"] == 1 and last["finished_at"]
for client in (a, HubClient(hub["base"], "")):
with pytest.raises(HubError) as e:
client.get("/board")
assert e.value.status == 401
```
- [ ] **Step 6: Запустить — тест падает**
Run: `uv run pytest tests/test_api.py -q -k board_route`
Expected: 1 failed, `HubError: HTTP 404 not_found: /board`
- [ ] **Step 7: Добавить маршрут**
В `src/jira_hub/api.py` после `h_issue`:
```python
def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, settings.get(conn, "base_jql"))
```
В `ROUTES` после строки `("GET", r"^/issues/([^/]+)$", h_issue, "client"),`:
```python
("GET", r"^/board$", h_board, "client"),
```
- [ ] **Step 8: Весь набор зелёный**
Run: `uv run pytest -q`
Expected: 209 passed
- [ ] **Step 9: Коммит**
```bash
git add src/jira_hub/mirror.py src/jira_hub/api.py tests/test_mirror.py tests/test_api.py
git commit -m "feat(api): GET /board — снимок зеркала для доски связей (#17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: страница доски — статический файл хаба без токена
**Files:**
- Create: `src/jira_hub/web/links.html` (копия `admin/sprint-2-links.html` + doctype)
- Modify: `src/jira_hub/api.py` (`PAGE`, `Raw`, `h_page`, маршрут с областью `None`, `_dispatch`, `_send`)
- Test: `tests/test_api.py`
**Interfaces:**
- Consumes: —
- Produces: `api.PAGE: Path` (путь к странице; Task 3 читает его в статической проверке), `api.Raw(content_type, data)`
— не-JSON ответ обработчика; маршруты `GET /`, `GET /links` без токена, `Content-Type: text/html; charset=utf-8`,
`Cache-Control: no-cache`.
- [ ] **Step 1: Скопировать страницу как есть и закоммитить отдельно**
```bash
mkdir -p src/jira_hub/web
cp /Users/andrey.epifancev/projects/awp-ca/admin/sprint-2-links.html src/jira_hub/web/links.html
git add src/jira_hub/web/links.html
git commit -m "chore(web): страница «Спринт 2 линк» как есть — основа доски хаба (#17)
Копия admin/sprint-2-links.html от 11.10.2026 без правок, чтобы дальнейшие изменения читались диффом.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
- [ ] **Step 2: Написать падающий тест**
В `tests/test_api.py` к импортам добавить `import urllib.error` и `import urllib.request`; в конец файла:
```python
def _raw_get(base, path):
with urllib.request.urlopen(base + path, timeout=10) as resp:
return resp.status, resp.headers, resp.read().decode("utf-8")
def test_page_served_without_token(hub):
hub["agent"].post("/agent/state", {"issues": [{"key": "ZZTEST-9001", "summary": "тема из зеркала", "labels": []}],
"links": []})
for path in ("/", "/links"):
status, headers, body = _raw_get(hub["base"], path)
assert status == 200
assert headers["Content-Type"] == "text/html; charset=utf-8"
assert headers["Cache-Control"] == "no-cache"
assert body.lstrip().lower().startswith("<!doctype html>")
assert "ZZTEST-9001" not in body and "тема из зеркала" not in body
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(hub["base"] + "/links/x", timeout=10)
assert e.value.code == 404
```
- [ ] **Step 3: Запустить — тест падает**
Run: `uv run pytest tests/test_api.py -q -k page_served`
Expected: 1 failed, `urllib.error.HTTPError: HTTP Error 404` (маршрута `/` ещё нет)
- [ ] **Step 4: Добавить doctype в страницу**
Первые строки `src/jira_hub/web/links.html` сейчас:
```html
<meta charset="utf-8">
<title>Спринт 2 линк</title>
```
Заменить на:
```html
<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<title>Спринт 2 линк</title>
```
- [ ] **Step 5: Реализовать отдачу страницы**
В `src/jira_hub/api.py`: к импортам добавить `from typing import NamedTuple`; после `SOCKET_TIMEOUT = …`:
```python
PAGE = Path(__file__).parent / "web" / "links.html" # страница доски связей; читается на каждый запрос
class Raw(NamedTuple):
"""Не-JSON ответ обработчика (страница доски)."""
content_type: str
data: bytes
```
После `h_board`:
```python
def h_page(hub, conn, m, q, body):
return 200, Raw("text/html; charset=utf-8", PAGE.read_bytes())
```
В `ROUTES` первой строкой (область `None` — без токена):
```python
("GET", r"^/(links)?$", h_page, None),
```
Docstring модуля дополнить: `Страница доски (GET /, GET /links) — без токена: статический файл без данных.`
В `Handler._dispatch` заменить `self._auth(scope)` на:
```python
if scope is not None:
self._auth(scope)
```
`Handler._send` заменить на:
```python
def _send(self, status, payload):
if isinstance(payload, Raw):
ctype, data, extra = payload.content_type, payload.data, {"Cache-Control": "no-cache"}
else:
ctype, extra = "application/json; charset=utf-8", {}
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
for name, value in extra.items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(data)
```
- [ ] **Step 6: Запустить — тест проходит; весь набор зелёный**
Run: `uv run pytest -q`
Expected: 210 passed
- [ ] **Step 7: Страница попадает в колесо пакета**
Run: `uv build --wheel -o $WS/dist -q && unzip -l $WS/dist/jira_hub-*.whl | grep web/links.html`
(`WS=$(pwd)/.superpowers/sdd/2026-10-14-viewer-live-hub`)
Expected: одна строка `jira_hub/web/links.html`
- [ ] **Step 8: Коммит**
```bash
git add src/jira_hub/api.py src/jira_hub/web/links.html tests/test_api.py
git commit -m "feat(api): страница доски связей по GET / и /links без токена (#17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: страница — вход по токену и доска из `GET /board`
**Files:**
- Modify: `src/jira_hub/web/links.html`
- Test: `tests/test_api.py` (статическая проверка страницы); Playwright-сценарий на тестовом хабе
**Interfaces:**
- Consumes: `GET /board` (Task 1: `issues[].{key, summary, issue_type, status, priority, assignee, sprint, duedate,
planned_end, epic_key, labels}`, `links[].{source, target, link_type}`, `last_sync.{started_at, finished_at,
issues_count}`, `generated_at`), `api.PAGE` (Task 2).
- Produces (JS, Task 4 использует): `TOKEN` (строка), `hubFetch(path, opts) → Promise<данные>` (бросает `HubAuthError`,
`HubDownError`, `Error`), `loadHub() → Promise<"ok"|"auth"|"down">`, `showModel(model, label, title)`,
`showLogin(msg)`, `hubError(e)`, `say(html)`, `sleep(ms)`, `syncHubButtons()`, `emptyModel(text)`, переменная
`polling` (id пакета или `null`), элемент `#hubState`.
- [ ] **Step 1: Поднять проверочный стенд**
Создать `$WS/fake_agent.py` (текст — в разделе «Проверочный стенд»), запустить тестовый хаб и заполнить зеркало
(команды там же). Проверить:
Run: `curl -s -H "Authorization: Bearer $CT" http://127.0.0.1:8791/board | python3 -c 'import json,sys; d=json.load(sys.stdin); print(len(d["issues"]), len(d["links"]), d["last_sync"]["issues_count"])'`
Expected: `4 5 4`
- [ ] **Step 2: Написать падающую статическую проверку**
В конец `tests/test_api.py`:
```python
def test_page_reads_hub_and_has_no_embedded_export():
html = api.PAGE.read_text(encoding="utf-8")
assert "DEFAULT_CSV" not in html and "Ключ проблемы,Тема" not in html # встроенной выгрузки нет
assert '"jira-hub-token"' in html and 'hubFetch("/board")' in html
assert "Нет в зеркале" in html
```
Run: `uv run pytest tests/test_api.py -q -k embedded_export`
Expected: 1 failed (`assert 'DEFAULT_CSV' not in html`)
- [ ] **Step 3: Константы, заглушки, удаление встроенной выгрузки**
В `src/jira_hub/web/links.html`:
1. Удалить строки `const DEFAULT_CSV = "…";` и `const DEFAULT_NAME = "…";` целиком.
2. Строку `const NO_EPIC = "Без эпика", NOT_IN_FILE = "Нет в файле"; // служебные дорожки, всегда внизу` заменить на:
```js
const NO_EPIC = "Без эпика", NOT_IN_FILE = "Нет в файле", NOT_IN_HUB = "Нет в зеркале"; // служебные дорожки, всегда внизу
const STUB_TEXT = { [NOT_IN_FILE]: "нет в файле", [NOT_IN_HUB]: "нет в зеркале" }; // подпись заглушки на служебной дорожке
const TOKEN_KEY = "jira-hub-token", POLL_MS = 3000; // токен хаба в localStorage; период опроса пакета обновления, мс
```
3. `stubNode` и `addStubs` заменить на:
```js
const stubNode = (key, lane) => ({ key, title: STUB_TEXT[lane], kind: "", prio: "", due: "", status: "", epic: lane, sprint: "", stub: true, who: "" });
// связи без дублей; для «связанных» порядок концов не важен
function addLink(links, seen, src, dst, type) {
const id = type === RELATED ? [src, dst].sort().join("|") + type : src + ">" + dst + type;
if (seen.has(id)) return; seen.add(id); links.push({ src, dst, type });
}
// задачи, упомянутые в связях, но отсутствующие в источнике, добавляются заглушками на служебную дорожку lane
function addStubs(nodes, links, lane = NOT_IN_FILE) {
const keys = new Set(nodes.map(n => n.key)), missing = new Set();
links.forEach(l => [l.src, l.dst].forEach(k => { if (!keys.has(k)) missing.add(k); }));
missing.forEach(k => nodes.push(stubNode(k, lane)));
return missing.size;
}
```
(`addLink` в исходнике стоит между `stubNode` и `addStubs` — блок заменяется целиком, текст `addLink` не меняется.)
4. В `migrateStore` строку сортировки заменить на `names.sort((a, b) => size(b) - size(a));`, а комментарий над функцией
— «…переносим на общий ключ набор того файла, где расставлено больше всего карточек».
5. В `computeVisible` строку `const tail = name => name === NO_EPIC || name === NOT_IN_FILE;` заменить на
`const tail = name => name === NO_EPIC || name === NOT_IN_FILE || name === NOT_IN_HUB;`.
6. В `emptyReason` строку `if (!MODEL.nodes.some(n => !n.stub)) return "В файле нет задач.";` заменить на
`if (!MODEL.nodes.some(n => !n.stub)) return MODEL.emptyText || (MODEL.stubLane === NOT_IN_HUB ? "В зеркале хаба нет задач." : "В файле нет задач.");`
7. В `renderStatus` строку `if (m.missing) notes.push(…);` заменить на
`` if (m.missing) notes.push(`упомянуты в связях, но в ${m.stubLane === NOT_IN_HUB ? "зеркале хаба" : "файле"} отсутствуют: ${m.missing}`); ``
8. Защита пустой модели (хаб недоступен, идёт загрузка): в `fillSprints` сразу после строки `const box = $("sprint");`
```js
if (!MODEL.nodes.length) { box.innerHTML = ""; S.sprints = new Set(); sprintDd = null; return; }
```
и в `fillWho` сразу после строки `const box = $("who"), { total, items } = whoItems();`
```js
if (!MODEL.nodes.length) { box.innerHTML = ""; S.who = new Set(); whoDd = null; return; }
```
- [ ] **Step 4: Разметка шапки и формы входа, стили**
1. Строку `<span class="src">…заменить…</span>` (в `.top`) заменить на:
```html
<span class="src"><span class="file-name" id="fileName"></span><button type="button" class="link" id="hubReload" hidden title="Загрузить снимок зеркала хаба заново, без обращения к Jira">Перечитать</button><label class="link" for="file" title="CSV из фильтра Jira или JSON, выгруженный с этой страницы. JSON при работе с хабом переносит только расстановку карточек, дорожки и фильтры; CSV показывает снимок файла до «Перечитать»">файл…</label><input type="file" id="file" accept=".csv,.json,text/csv,application/json" hidden></span>
<span class="stats" id="hubState"></span>
```
2. Перед `<div class="export" id="exportBox" hidden>` вставить:
```html
<div class="login" id="login" hidden>
<h2>Вход в хаб</h2>
<p>Доске нужен клиентский токен хаба. Его показывает команда <code>uv run jira-hub tokens</code> в каталоге jira-hub на этом Mac. Токен сохранится в этом браузере.</p>
<p class="err" id="loginErr" hidden></p>
<form id="loginForm"><input type="password" id="tokenInput" autocomplete="off" spellcheck="false" aria-label="Клиентский токен хаба" placeholder="клиентский токен"><button class="btn" type="submit">Войти</button></form>
<p>Без хаба: <label class="link" for="file">загрузить файл CSV или JSON…</label></p>
</div>
```
3. После строки `.empty{…}` в стилях:
```css
.login{max-width:560px;margin:48px auto;padding:20px 24px;background:var(--panel);border:1px solid var(--line);border-radius:8px}
.login h2{margin:0 0 8px;font-size:17px;font-weight:600}
.login p{margin:0 0 12px;font-size:14px;color:var(--muted)}
.login p.err{color:var(--p-crit)}
.login code{font-family:var(--font-mono);font-size:13px;color:var(--fg)}
.login form{display:flex;gap:8px;margin:0 0 12px}
.login input{flex:1;min-width:0;font:inherit;font-size:14px;height:34px;box-sizing:border-box;padding:0 10px;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px}
.login input:focus-visible{border-color:var(--accent);outline:none}
```
- [ ] **Step 5: Модуль хаба в скрипте и запуск**
1. Сразу после функции `fromJson` (конец раздела 3) вставить:
```js
// снимок хаба (GET /board) → модель доски. Эпики («Фича»/Epic) — имена дорожек, не карточки; соседи по связям — обычные карточки;
// вид работы — первая метка kind-*, иначе тип задачи; срок — плановая дата окончания, иначе срок исполнения; связи — только известных
// типов (LINK_TYPES) и не с эпиками; ключи из связей, которых нет в зеркале, — заглушки на дорожке «Нет в зеркале»
function fromHub(snap) {
const isEpic = it => /^(Фича|Epic|Эпик)$/i.test(it.issue_type || "");
const epicNames = { ...EPICS }, epics = new Set();
snap.issues.forEach(it => { if (isEpic(it)) { epics.add(it.key); epicNames[it.key] = it.summary || it.key; } });
const nodes = [], links = [], seen = new Set(), sprints = {};
snap.issues.forEach(it => {
if (isEpic(it) || !KEY_RE.test(it.key)) return;
const label = (it.labels || []).find(l => KIND_LABELS[l]), sprint = it.sprint || "";
if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1;
nodes.push({ key: it.key, title: it.summary || "", kind: label ? KIND_LABELS[label] : (it.issue_type || ""), prio: it.priority || "",
due: sortableDate(it.planned_end || it.duedate || ""), status: it.status || "",
epic: it.epic_key ? (epicNames[it.epic_key] || it.epic_key) : NO_EPIC, sprint, who: it.assignee || "", stub: false });
});
snap.links.forEach(l => {
const type = LINK_TYPES[l.link_type];
if (type && !epics.has(l.source) && !epics.has(l.target)) addLink(links, seen, l.source, l.target, type);
});
const missing = addStubs(nodes, links, NOT_IN_HUB);
const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"];
return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint: true, missing, stubLane: NOT_IN_HUB };
}
```
2. В разделе 7 перед `function loadCsv` вставить:
```js
// --- хаб: вход по токену, снимок доски. Токен — в localStorage (TOKEN_KEY) и в памяти (на случай закрытого хранилища);
// все запросы — к тому же хосту, с заголовком Authorization
let TOKEN = store.get(TOKEN_KEY, "");
let polling = null; // id пакета обновления, ход которого показываем (Task 4)
class HubAuthError extends Error {}
class HubDownError extends Error {}
const say = html => { $("hubState").innerHTML = html; };
const sleep = ms => new Promise(r => setTimeout(r, ms));
const fmtStamp = iso => { const d = new Date(iso); return isNaN(d) ? String(iso) : d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }).replace(",", ""); };
const emptyModel = text => ({ nodes: [], links: [], sprints: {}, fileName: "", problems: [], hasSprint: false, missing: 0, emptyText: text });
async function hubFetch(path, opts = {}) {
if (!TOKEN) throw new HubAuthError("нет токена");
const headers = { "Authorization": "Bearer " + TOKEN };
if (opts.body) headers["Content-Type"] = "application/json; charset=utf-8";
let resp;
try { resp = await fetch(path, { ...opts, headers, cache: "no-store" }); }
catch (e) { throw new HubDownError("хаб не отвечает"); }
if (resp.status === 401) throw new HubAuthError("токен не подошёл");
let data = null; try { data = await resp.json(); } catch (e) {}
if (!resp.ok) throw new Error(`хаб ответил ${resp.status}${data && data.error ? ": " + data.error + (data.detail ? " — " + data.detail : "") : ""}`);
return data;
}
function syncHubButtons() {
$("hubReload").hidden = !TOKEN;
const r = $("hubRefresh"); if (r) { r.hidden = !TOKEN; r.disabled = polling !== null; }
}
function showLogin(msg) {
$("login").hidden = false; $("stage").hidden = true; $("datesBar").hidden = true; hscrollEl.hidden = true;
$("loginErr").hidden = !msg; $("loginErr").textContent = msg || "";
$("tokenInput").value = ""; $("tokenInput").focus(); syncHubButtons();
}
function hubError(e) {
if (e instanceof HubAuthError) return showLogin(TOKEN ? "Токен не подошёл: возможно, его пересоздали. Введите действующий клиентский токен." : "");
say(`<span class="warn">${esc(e.message)}</span>`);
}
function showModel(model, label, title) {
MODEL = model; $("login").hidden = true; $("stage").hidden = false;
$("fileName").textContent = label; $("fileName").title = title || label;
fillSprints(); fillWho(); refresh(); syncHubButtons();
}
// загрузить снимок хаба: "ok" — доска построена; "auth" — показана форма входа; "down" — хаб не ответил (доска прежняя, если была)
async function loadHub() {
let snap;
try { snap = await hubFetch("/board"); }
catch (e) {
if (e instanceof HubAuthError) { hubError(e); return "auth"; }
if (MODEL && MODEL.nodes.length) say(`<span class="warn">${esc(e.message)} — на доске прежние данные</span>`);
else showModel(emptyModel(`Хаб не отвечает (${e.message}). Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira.`), "Хаб недоступен");
return "down";
}
const ls = snap.last_sync;
showModel(fromHub(snap), ls ? `Хаб · данные на ${fmtStamp(ls.finished_at)}` : "Хаб · полного обновления не было",
ls ? `Последнее полное обновление из Jira: ${fmtStamp(ls.started_at)} – ${fmtStamp(ls.finished_at)}, задач: ${ls.issues_count}. Снимок хаба: ${fmtStamp(snap.generated_at)}` : "");
return "ok";
}
$("loginForm").addEventListener("submit", ev => {
ev.preventDefault();
const t = $("tokenInput").value.trim(); if (!t) return;
if (!/^[\x21-\x7e]+$/.test(t)) { $("loginErr").hidden = false; $("loginErr").textContent = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы."; return; }
TOKEN = t; store.set(TOKEN_KEY, t); say(""); loadHub();
});
$("hubReload").addEventListener("click", () => { say(""); loadHub(); });
```
3. `loadCsv` заменить на:
```js
function loadCsv(text, fileName) {
showModel(fromJiraCsv(text, fileName), `${fileName} (из файла)`, `${fileName} — снимок из файла; «Перечитать» вернёт данные хаба`);
}
```
4. В `loadJson` последние две строки (`$("fileName").textContent = …(из JSON)…` и `fillSprints(); fillWho(); refresh();`)
заменить на:
```js
showModel(MODEL, `${MODEL.fileName} (из файла)`, `${MODEL.fileName} — загружено из ${fileName}`);
```
5. Комментарий `// запуск: настройки из хранилища, затем встроенный пример` заменить на
`// запуск: настройки из хранилища, затем данные хаба (без токена — форма входа)`, а последнюю строку
`loadCsv(DEFAULT_CSV, DEFAULT_NAME);` — на:
```js
MODEL = emptyModel("Загрузка данных хаба…");
if (TOKEN) { showModel(MODEL, "Хаб"); loadHub(); } else showLogin("");
```
- [ ] **Step 6: Статическая проверка и синтаксис**
Run: `uv run pytest tests/test_api.py -q -k "embedded_export or page_served"`
Expected: 2 passed
Run: `python3 -c "import re,sys; h=open('src/jira_hub/web/links.html',encoding='utf-8').read(); open(sys.argv[1],'w',encoding='utf-8').write(re.search(r'<script>(.*)</script>', h, re.S).group(1))" $WS/page.js && node --check $WS/page.js && echo OK`
Expected: `OK`
- [ ] **Step 7: Перезапустить тестовый хаб и пройти сценарий в Playwright**
Тестовый хаб перезапустить (страница читается с диска на каждый запрос, но перезапуск исключает старый процесс).
В Playwright (`browser_navigate http://127.0.0.1:8791/`), каждый пункт — снимок/скриншот и сверка:
1. Первый вход: видна форма «Вход в хаб» с подсказкой `uv run jira-hub tokens`, доски нет.
2. Ввести `токен с пробелом` → сообщение «недопустимые символы», запросов к `/board` нет (`browser_network_requests`).
3. Ввести `неверныйТокен` (кириллица) → то же сообщение; ввести `wrong-token` → форма с «Токен не подошёл…».
4. Ввести `$CT` → доска: дорожка «Справочники ГК / ГСК / ГСЗ / ЮЛ» с AURORA-1620 и AURORA-1621; AURORA-1500 на дорожке
«Без эпика» видна при выборе «Все спринты» или «+ соседи по связям»; заглушка AURORA-9999 «нет в зеркале» на дорожке
«Нет в зеркале» внизу; эпика AURORA-1351 карточкой нет; стрелок: 1620→1621 «блокирует начало», 1621—1500 «связанные»,
1620→9999; связи `Cloners` и 1621—1351 нет (таблица связей в меню «⋯»: 3 строки).
5. Тема AURORA-1620 показана текстом `ТЕСТ доски: <b>разметка</b> & "кавычки"` (не жирным).
6. Срок AURORA-1620 — `до 21.10` (плановая дата, а не срок исполнения 25.10); вид работы — «разработка», у AURORA-1621 — «UI».
7. Шапка: «Хаб · данные на <дата время>», в подсказке — число задач 4. Строка предупреждений: «упомянуты в связях, но в
зеркале хаба отсутствуют: 1».
8. `localStorage["jira-hub-token"]` равен `$CT` (`browser_evaluate`); перезагрузка страницы — доска без формы входа.
9. «Перечитать» → после `fake_agent.py --once` с новым `POST /refresh` (curl) тема AURORA-1621 — «(прогон 2)».
10. Подменить токен: `browser_evaluate localStorage.setItem("jira-hub-token","stale")`, перезагрузить → форма «Токен не
подошёл…»; ввести `$CT` → доска.
11. Остановить тестовый хаб, «Перечитать» → в шапке «хаб не отвечает — на доске прежние данные», доска на месте.
Пустую доску без хаба проверить на `file://<worktree>/src/jira_hub/web/links.html` (у `file://` своё хранилище):
без токена — форма входа; `browser_evaluate localStorage.setItem("jira-hub-token","x")` и перезагрузка — пустая
доска «Хаб не отвечает (хаб не отвечает). Запустите хаб…», «Хаб недоступен» в шапке. Запустить тестовый хаб снова.
12. Загрузить CSV (любая выгрузка фильтра Jira; при отсутствии — `$WS/sample.csv` из двух строк с колонками
`Ключ проблемы,Тема,Статус,Спринт`) → в шапке «<имя> (из файла)»; «Перечитать» → снова «Хаб · данные на …».
13. Скриншот доски в светлой и тёмной теме — без съехавших элементов в шапке.
Отклонения — исправить в странице, повторить пункт; каждое исправление поведения — строка в ledger.
- [ ] **Step 8: Коммит**
```bash
git add src/jira_hub/web/links.html tests/test_api.py
git commit -m "feat(web): доска на данных хаба — вход по токену, GET /board, «Перечитать» (#17)
Встроенная выгрузка от 11.10.2026 удалена; CSV и JSON — загрузка файла.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: страница — «Обновить из Jira», перенос раскладки, смена токена
**Files:**
- Modify: `src/jira_hub/web/links.html`
- Test: `tests/test_api.py` (статическая проверка); Playwright-сценарий на тестовом хабе с учебным агентом
**Interfaces:**
- Consumes (Task 3): `TOKEN`, `hubFetch`, `loadHub() → "ok"|"auth"|"down"`, `showModel`, `showLogin`, `hubError`,
`say`, `sleep`, `syncHubButtons`, `polling`, `POLL_MS`, `fromJson(text, fileName) → {model, layout, …}`; хаб:
`GET /agent → {online}`, `POST /refresh → {id}`, `GET /batches/{id} → {status, remaining_seconds, commands[].{status,
error}}`, `GET /batches?limit=20 → {batches[].{id, author, status}}`; статусы пакета `queued|running|done|cancelled`.
- Produces: —
- [ ] **Step 1: Написать падающую статическую проверку**
В `tests/test_api.py` в `test_page_reads_hub_and_has_no_embedded_export` добавить строку:
```python
assert 'author: "viewer"' in html and "/batches/" in html
```
Run: `uv run pytest tests/test_api.py -q -k embedded_export`
Expected: 1 failed (`"/batches/" in html`)
- [ ] **Step 2: Разметка: кнопка и пункт меню**
1. В `.src` после кнопки `hubReload` вставить:
```html
<button type="button" class="link" id="hubRefresh" hidden title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">Обновить из Jira</button>
```
2. В меню «⋯» перед `<hr>`:
```html
<button type="button" id="logout">Сменить токен хаба</button>
```
3. Стиль недоступной кнопки-ссылки — после `.link:hover,…{…}`:
```css
.link:disabled{color:var(--muted);cursor:default;text-decoration:none}
```
- [ ] **Step 3: Обновление из Jira, возобновление опроса, импорт JSON, смена токена**
1. После обработчика `$("hubReload")…` вставить:
```js
// «Обновить из Jira»: пакет из одного refresh по базовой JQL от имени viewer; ход пакета — опросом раз в POLL_MS, затем снимок заново.
// Агент офлайн — пакет всё равно ставится и выполнится, когда агент выйдет на связь
async function refreshFromJira() {
if (polling !== null) return;
$("hubRefresh").disabled = true;
try {
const agent = await hubFetch("/agent");
const b = await hubFetch("/refresh", { method: "POST", body: JSON.stringify({ full: true, author: "viewer" }) });
watchBatch(b.id, agent && agent.online ? "" : "агент офлайн — пакет выполнится, когда он выйдет на связь; ");
} catch (e) { hubError(e); syncHubButtons(); }
}
async function watchBatch(id, warn = "") {
polling = id; syncHubButtons();
for (;;) {
let b;
try { b = await hubFetch(`/batches/${id}`); }
catch (e) {
if (e instanceof HubAuthError) { polling = null; hubError(e); return; }
say(`<span class="warn">пакет #${id}: ${esc(e.message)}, повтор через ${POLL_MS / 1000} с</span>`); await sleep(POLL_MS); continue;
}
if (b.status === "done" || b.status === "cancelled") {
polling = null;
const cmd = (b.commands || [])[0] || {};
const verdict = cmd.status === "failed" ? `<span class="warn">пакет #${id} не выполнен: ${esc(cmd.error || "ошибка агента")}</span>`
: b.status === "cancelled" || cmd.status === "cancelled" || cmd.status === "skipped" ? `<span class="warn">пакет #${id} отменён</span>`
: `пакет #${id} выполнен`;
await loadHub(); say(verdict); syncHubButtons(); return;
}
say(`${warn}пакет #${id} ${b.status === "queued" ? "в очереди" : "выполняется"}${b.remaining_seconds ? `, осталось ~${b.remaining_seconds} с` : ""}…`);
await sleep(POLL_MS);
}
}
// после перезагрузки страницы: незавершённый пакет доски показываем дальше, вторую загрузку не ставим
async function resumeWatch() {
try {
const list = await hubFetch("/batches?limit=20");
const b = (list.batches || []).find(x => x.author === "viewer" && (x.status === "queued" || x.status === "running"));
if (b && polling === null) watchBatch(b.id);
} catch (e) {}
}
$("hubRefresh").addEventListener("click", refreshFromJira);
$("logout").addEventListener("click", () => { TOKEN = ""; store.del(TOKEN_KEY); moreDd.close(); say(""); showLogin(""); });
```
2. `loadJson` разделить: тело от `if (Object.keys(r.layout).length)` до `drawLegend();` включительно перенести без
изменений в новую функцию `applyJsonSettings(r)`, а `loadJson` заменить на:
```js
// импорт JSON: раскладка и фильтры кладутся в хранилище доски. С хабом задачи и связи берутся из хаба, а не из файла;
// хаб не ответил — показываем снимок из файла
function loadJson(text, fileName) {
const r = fromJson(text, fileName);
applyJsonSettings(r);
const showFile = () => showModel(r.model, `${r.model.fileName} (из файла)`, `${r.model.fileName} — загружено из ${fileName}`);
if (!TOKEN) return showFile();
loadHub().then(st => { if (st === "down") showFile(); else if (st === "ok") say(`раскладка и фильтры из ${esc(fileName)} применены к данным хаба`); });
}
```
(`MODEL = r.model` из старого `loadJson` не переносится: модель ставит `showModel`.)
3. Запуск: строку `if (TOKEN) { showModel(MODEL, "Хаб"); loadHub(); } else showLogin("");` заменить на:
```js
if (TOKEN) { showModel(MODEL, "Хаб"); loadHub().then(st => { if (st === "ok") resumeWatch(); }); } else showLogin("");
```
4. В обработчике `loginForm` вызов `loadHub();` заменить на `loadHub().then(st => { if (st === "ok") resumeWatch(); });`.
- [ ] **Step 4: Статическая проверка и синтаксис**
Run: `uv run pytest tests/test_api.py -q -k "embedded_export or page_served"`
Expected: 2 passed
Run: (извлечение скрипта и `node --check` — как в Task 3, шаг 6)
Expected: `OK`
- [ ] **Step 5: Playwright-сценарий с учебным агентом**
Учебный агент — фоном: `uv run python $WS/fake_agent.py http://127.0.0.1:8791` (без `--once`). Страница
`http://127.0.0.1:8791/`, токен уже в `localStorage`.
1. «Обновить из Jira» → кнопка недоступна, в шапке «пакет #N выполняется…» (агент держит 4 с); затем «пакет #N
выполнен», «данные на» новое время, тема AURORA-1621 — следующий «(прогон k)»; кнопка снова доступна.
В хабе: `curl …/batches/N` — `author = "viewer"`, одна команда `refresh` с `params.jql` = базовая JQL.
2. Агент остановлен: «Обновить из Jira» → «агент офлайн — пакет выполнится…; пакет #N в очереди…». Перезагрузить
страницу → опрос возобновился сам (кнопка недоступна, «пакет #N в очереди…»). Запустить агента → «выполнен».
Примечание: «офлайн» хаб считает по `last_seen_at` старше `ONLINE_MINUTES`; если агент только что был на связи —
на шаге 2 «агент офлайн» не появится; тогда проверить текст, выставив в базе тестового хаба
`UPDATE agent_status SET last_seen_at = '2026-01-01T00:00:00+00:00'` (`sqlite3 $JIRA_HUB_HOME/hub.sqlite`).
3. Агент с `--fail`: «Обновить из Jira» → «пакет #N не выполнен: учебная ошибка: Jira ответила 503»; доска на месте.
4. Хаб остановлен посреди опроса (агент держит команду 4 с — остановить хаб в это время): в шапке «пакет #N: хаб не
отвечает, повтор через 3 с»; запустить хаб → опрос продолжается; агент доведёт или хаб довыдаст команду — пакет
завершается, доска перечитана.
5. Токен пересоздан посреди опроса: во время «выполняется» — `browser_evaluate localStorage.setItem(...)` не влияет
(токен в памяти); вместо этого в базе тестового хаба
`UPDATE setting SET value = json_set(value, '$.client', 'rotated') WHERE key = 'tokens'` → следующий опрос → форма
«Токен не подошёл…»; повторных запросов после этого нет (`browser_network_requests`). Ввести `rotated` → доска,
опрос пакета возобновился, если пакет ещё идёт.
6. Перенос раскладки: в старой странице (`file:///Users/andrey.epifancev/projects/awp-ca/admin/sprint-2-links.html`)
передвинуть AURORA-1620 и AURORA-1621 (если их нет во встроенной выгрузке — любые две карточки, затем в JSON заменить
их ключи на 1620/1621), свернуть дорожку, выбрать спринт; «⋯ → Экспорт JSON» (текст из поля — в `$WS/layout.json`).
В доске хаба загрузить `$WS/layout.json` → в шапке «Хаб · данные на …» (не «из файла»), «раскладка и фильтры из
layout.json применены к данным хаба»; карточки стоят по координатам JSON, дорожка свёрнута, спринт выбран; задачи —
из хаба (тема 1621 — «(прогон k)»).
7. Тот же JSON при остановленном хабе (страница уже открыта) → снимок из файла «… (из файла)»; хаб запустить,
«Перечитать» → данные хаба.
8. «⋯ → Сменить токен хаба» → форма входа без сообщения об ошибке, `localStorage["jira-hub-token"]` удалён.
9. Скриншоты: шапка во время опроса (светлая и тёмная тема), узкое окно 900 px — кнопки шапки переносятся, не
наезжают.
Отклонения — исправить в странице, повторить пункт; каждое исправление поведения — строка в ledger.
- [ ] **Step 6: Коммит**
```bash
git add src/jira_hub/web/links.html tests/test_api.py
git commit -m "feat(web): «Обновить из Jira», перенос раскладки импортом JSON, смена токена (#17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: документы и проверка на копии живой базы
**Files:**
- Modify: `README.md` (раздел «HTTP API», новый раздел «Доска связей»), `ARCHITECTURE.md` (модуль `api`, файл
`web/links.html`), `CLAUDE.md` (строка в «Команды»)
- Test: весь набор pytest; Playwright на копии живой базы
**Interfaces:**
- Consumes: всё из Task 1–4.
- Produces: —
- [ ] **Step 1: README**
В разделе «HTTP API» первую строку заменить на:
```markdown
Все маршруты, кроме страницы доски (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
ошибки `{error, detail}` (400/401/404/409/422).
```
В таблицу после строки `GET /issues/{key}`:
```markdown
| `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон |
| `GET /`, `GET /links` | — | страница доски связей (HTML без данных) |
```
Перед разделом «Контракт команд» — новый раздел:
```markdown
## Доска связей
`http://127.0.0.1:8790/` — доска «Спринт 2 линк» на данных зеркала: дорожки по эпикам, стрелки блокировок, режим
«по датам», критический путь, проблемы. При первом входе спрашивает клиентский токен (`uv run jira-hub tokens`) и
хранит его в браузере; `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои.
- «Перечитать» — снимок зеркала заново, без Jira; «Обновить из Jira» — пакет `refresh` по базовой JQL (автор
`viewer`), страница ждёт его и перечитывает доску; в шапке — время последнего полного обновления.
- Раскладка, дорожки и фильтры хранятся в браузере. Перенос из старой страницы: там «⋯ → Экспорт JSON», здесь «файл…»
→ этот JSON: применяются раскладка и фильтры, задачи остаются из хаба.
- «файл…» с CSV из фильтра Jira — снимок файла до «Перечитать» (запасной путь, если хаб недоступен).
- «⋯ → Сменить токен хаба» — после пересоздания токенов.
```
- [ ] **Step 2: ARCHITECTURE и CLAUDE.md**
`ARCHITECTURE.md`: в строке таблицы модуля `api` к назначению добавить `; страница доски GET /, /links (файл
web/links.html, без токена) и снимок GET /board`; после таблицы модулей абзац:
```markdown
`src/jira_hub/web/links.html` — страница доски связей: один HTML без зависимостей, хаб отдаёт его как есть. Данные
страница берёт из `GET /board` с клиентским токеном и переводит в модель доски сама (вид работы по меткам, имена
эпиков, логические типы связей); `mirror.board` отдаёт зеркало без преобразований — тот же формат будет читать
веб-приложение.
```
`CLAUDE.md` (корень репозитория, раздел «Команды»), после строки `uv run jira-hub serve …`:
```markdown
- Доска связей — `http://127.0.0.1:8790/` (страница `src/jira_hub/web/links.html`, вход по клиентскому токену).
```
- [ ] **Step 3: Весь набор зелёный**
Run: `uv run pytest -q`
Expected: 211 passed
- [ ] **Step 4: Проверка на копии живой базы**
```bash
mkdir -p $WS/hub-copy && chmod 700 $WS/hub-copy
sqlite3 "file:$HOME/.local/share/jira-hub/hub.sqlite?mode=ro" ".backup $WS/hub-copy/hub.sqlite"
sqlite3 $WS/hub-copy/hub.sqlite "DELETE FROM setting WHERE key = 'tokens';
UPDATE setting SET value = 'null' WHERE key = 'relay_dir';
UPDATE setting SET value = '0' WHERE key = 'auto_refresh_minutes';"
JIRA_HUB_HOME=$WS/hub-copy uv run jira-hub serve --host 127.0.0.1 --port 8792 # фоном
```
(токены пересоздаются при старте, реле и автообновление выключены — копия не трогает общую папку и живого агента).
Токен копии — `JIRA_HUB_HOME=$WS/hub-copy uv run jira-hub tokens`. В Playwright `http://127.0.0.1:8792/`:
1. Доска строится: в шапке «Хаб · данные на <время последнего полного прогона живого хаба>», число задач в статусе —
число не-эпиков зеркала; дорожки — эпики AURORA-1350…; связи `Cloners` нет.
2. Импорт JSON, выгруженного из старой страницы (`$WS/layout.json` из Task 4 или свежий экспорт), применяет раскладку.
3. Скриншот — для PR.
4. Остановить хаб копии, удалить `$WS/hub-copy`.
- [ ] **Step 5: Коммит**
```bash
git add README.md ARCHITECTURE.md CLAUDE.md
git commit -m "docs: доска связей хаба — README, ARCHITECTURE, CLAUDE.md (#17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
## После merge (не задача плана)
Живая проверка с настоящим агентом — по Migration Plan в design.md: `git pull` в `jira-hub`, перезапуск launchd,
вход по живому токену, «Обновить из Jira» (пакет `viewer` в хабе, агент выполнил), импорт JSON из старой страницы.