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

64 KiB
Raw Blame History

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, не коммитится):

"""Учебный агент для проверки страницы доски: берёт команды тестового хаба и отвечает загрузкой фикстуры.
Запуск (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()

Запуск тестового хаба (фоном) и заполнение зеркала одним полным прогоном:

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:

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:

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:

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:

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"),:

    ("GET", r"^/board$", h_board, "client"),
  • Step 8: Весь набор зелёный

Run: uv run pytest -q Expected: 209 passed

  • Step 9: Коммит
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: Скопировать страницу как есть и закоммитить отдельно

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; в конец файла:

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 сейчас:

<meta charset="utf-8">
<title>Спринт 2 линк</title>

Заменить на:

<!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 = …:

PAGE = Path(__file__).parent / "web" / "links.html"     # страница доски связей; читается на каждый запрос


class Raw(NamedTuple):
    """Не-JSON ответ обработчика (страница доски)."""
    content_type: str
    data: bytes

После h_board:

def h_page(hub, conn, m, q, body):
    return 200, Raw("text/html; charset=utf-8", PAGE.read_bytes())

В ROUTES первой строкой (область None — без токена):

    ("GET", r"^/(links)?$", h_page, None),

Docstring модуля дополнить: Страница доски (GET /, GET /links) — без токена: статический файл без данных.

В Handler._dispatch заменить self._auth(scope) на:

                if scope is not None:
                    self._auth(scope)

Handler._send заменить на:

    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: Коммит
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:

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 = "Нет в файле"; // служебные дорожки, всегда внизу заменить на:
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; период опроса пакета обновления, мс
  1. stubNode и addStubs заменить на:
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 не меняется.)

  1. В migrateStore строку сортировки заменить на names.sort((a, b) => size(b) - size(a));, а комментарий над функцией — «…переносим на общий ключ набор того файла, где расставлено больше всего карточек».
  2. В 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;.
  3. В emptyReason строку if (!MODEL.nodes.some(n => !n.stub)) return "В файле нет задач."; заменить на if (!MODEL.nodes.some(n => !n.stub)) return MODEL.emptyText || (MODEL.stubLane === NOT_IN_HUB ? "В зеркале хаба нет задач." : "В файле нет задач.");
  4. В renderStatus строку if (m.missing) notes.push(…); заменить на if (m.missing) notes.push(`упомянуты в связях, но в ${m.stubLane === NOT_IN_HUB ? "зеркале хаба" : "файле"} отсутствуют: ${m.missing}`);
  5. Защита пустой модели (хаб недоступен, идёт загрузка): в fillSprints сразу после строки const box = $("sprint");
  if (!MODEL.nodes.length) { box.innerHTML = ""; S.sprints = new Set(); sprintDd = null; return; }

и в fillWho сразу после строки const box = $("who"), { total, items } = whoItems();

  if (!MODEL.nodes.length) { box.innerHTML = ""; S.who = new Set(); whoDd = null; return; }
  • Step 4: Разметка шапки и формы входа, стили
  1. Строку <span class="src">…заменить…</span> (в .top) заменить на:
    <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>
  1. Перед <div class="export" id="exportBox" hidden> вставить:
<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>
  1. После строки .empty{…} в стилях:
.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) вставить:
// снимок хаба (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 };
}
  1. В разделе 7 перед function loadCsv вставить:
// --- хаб: вход по токену, снимок доски. Токен — в 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(); });
  1. loadCsv заменить на:
function loadCsv(text, fileName) {
  showModel(fromJiraCsv(text, fileName), `${fileName} (из файла)`, `${fileName} — снимок из файла; «Перечитать» вернёт данные хаба`);
}
  1. В loadJson последние две строки ($("fileName").textContent = …(из JSON)… и fillSprints(); fillWho(); refresh();) заменить на:
  showModel(MODEL, `${MODEL.fileName} (из файла)`, `${MODEL.fileName} — загружено из ${fileName}`);
  1. Комментарий // запуск: настройки из хранилища, затем встроенный пример заменить на // запуск: настройки из хранилища, затем данные хаба (без токена — форма входа), а последнюю строку loadCsv(DEFAULT_CSV, DEFAULT_NAME); — на:
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: Коммит
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 добавить строку:

    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 вставить:
<button type="button" class="link" id="hubRefresh" hidden title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">Обновить из Jira</button>
  1. В меню «⋯» перед <hr>:
        <button type="button" id="logout">Сменить токен хаба</button>
  1. Стиль недоступной кнопки-ссылки — после .link:hover,…{…}:
.link:disabled{color:var(--muted);cursor:default;text-decoration:none}
  • Step 3: Обновление из Jira, возобновление опроса, импорт JSON, смена токена
  1. После обработчика $("hubReload")… вставить:
// «Обновить из 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(""); });
  1. loadJson разделить: тело от if (Object.keys(r.layout).length) до drawLegend(); включительно перенести без изменений в новую функцию applyJsonSettings(r), а loadJson заменить на:
// импорт 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.)

  1. Запуск: строку if (TOKEN) { showModel(MODEL, "Хаб"); loadHub(); } else showLogin(""); заменить на:
if (TOKEN) { showModel(MODEL, "Хаб"); loadHub().then(st => { if (st === "ok") resumeWatch(); }); } else showLogin("");
  1. В обработчике 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: Коммит
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» первую строку заменить на:

Все маршруты, кроме страницы доски (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
ошибки `{error, detail}` (400/401/404/409/422).

В таблицу после строки GET /issues/{key}:

| `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон |
| `GET /`, `GET /links` | — | страница доски связей (HTML без данных) |

Перед разделом «Контракт команд» — новый раздел:

## Доска связей

`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; после таблицы модулей абзац:

`src/jira_hub/web/links.html` — страница доски связей: один HTML без зависимостей, хаб отдаёт его как есть. Данные
страница берёт из `GET /board` с клиентским токеном и переводит в модель доски сама (вид работы по меткам, имена
эпиков, логические типы связей); `mirror.board` отдаёт зеркало без преобразований — тот же формат будет читать
веб-приложение.

CLAUDE.md (корень репозитория, раздел «Команды»), после строки uv run jira-hub serve …:

- Доска связей — `http://127.0.0.1:8790/` (страница `src/jira_hub/web/links.html`, вход по клиентскому токену).
  • Step 3: Весь набор зелёный

Run: uv run pytest -q Expected: 211 passed

  • Step 4: Проверка на копии живой базы
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: Коммит
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 из старой страницы.