feat(events): EventBus + domain-модель статусов

- internal/model: единый источник статусов задачи (Status, TraceStatus,
  машина переходов), без зависимости от storage.
- internal/storage: совместимый мост (type Status = model.Status,
  re-export констант) — внешний код не меняется.
- internal/events: шина событий (fan-out, блокирующий Publish с гарантией
  порядка), события задач/трейсов, отдельная логовая шина + LogWriter,
  Publisher/NilPublisher для внедрения в Core/Worker.
- docs/ui-spec.md: спецификация десктопного UI (Fyne).
This commit is contained in:
ki.sagidullin
2026-08-20 01:02:31 +05:00
parent 408d137747
commit 1f7ab9a67e
10 changed files with 764 additions and 54 deletions

77
internal/model/status.go Normal file
View File

@@ -0,0 +1,77 @@
// Package model — доменные типы и контракты Ratatoskr.
//
// Нижний слой архитектуры: не зависит ни от БД (storage), ни от UI (events).
// Хранит семантику предметной области (статусы задач, машина переходов),
// чтобы storage и events зависели только от этой модели, а не друг от друга.
package model
// Status — статус задачи (state machine).
type Status string
const (
StatusDraft Status = "draft" // только что создана
StatusCollecting Status = "collecting" // аналитик собирает детали
StatusReady Status = "ready" // черновик готов, ждёт одобрения пользователя
StatusApproved Status = "approved" // пользователь одобрил («создавай») — воркер берёт в работу
StatusRunning Status = "running" // opencode работает
StatusSuccess Status = "success" // задача выполнена
StatusFailed Status = "failed" // ошибка выполнения
StatusTimeout Status = "timeout" // таймаут opencode
StatusCancelled Status = "cancelled" // отменена пользователем
StatusAborted Status = "aborted" // сбой сбора, черновик выброшен
StatusClosed Status = "closed" // закрыта вручную
)
// AllStatuses — все возможные статусы для валидации.
var AllStatuses = []Status{
StatusDraft, StatusCollecting, StatusReady, StatusApproved,
StatusRunning, StatusSuccess, StatusFailed, StatusTimeout,
StatusCancelled, StatusAborted, StatusClosed,
}
// validTransitions задаёт разрешённые переходы статусов.
var validTransitions = map[Status][]Status{
StatusDraft: {StatusCollecting, StatusCancelled, StatusAborted},
StatusCollecting: {StatusReady, StatusDraft, StatusCancelled, StatusAborted},
// ready — черновик готов: «создавай» → approved, либо правка/отмена/закрытие.
StatusReady: {StatusApproved, StatusCancelled, StatusAborted, StatusClosed, StatusCollecting},
// approved — финальное одобрение: воркер берёт в running, либо отмена/сбой/закрытие.
StatusApproved: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed},
StatusRunning: {StatusSuccess, StatusFailed, StatusTimeout, StatusCancelled},
StatusSuccess: {StatusClosed},
StatusFailed: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
StatusTimeout: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
StatusCancelled: {StatusClosed},
StatusAborted: {StatusClosed},
StatusClosed: {}, // терминальный
}
// IsValidTransition проверяет, допустим ли переход from → to.
func IsValidTransition(from, to Status) bool {
allowed, ok := validTransitions[from]
if !ok {
return false
}
for _, s := range allowed {
if s == to {
return true
}
}
return false
}
// IsTerminal возвращает true, если статус терминальный.
func IsTerminal(s Status) bool {
return s == StatusSuccess || s == StatusCancelled ||
s == StatusAborted || s == StatusClosed
}
// TraceStatus — статус трассировки субагента.
type TraceStatus string
const (
TraceRunning TraceStatus = "running"
TraceSuccess TraceStatus = "success"
TraceFailed TraceStatus = "failed"
TraceTimeout TraceStatus = "timeout"
)

View File

@@ -0,0 +1,50 @@
package model
import "testing"
func TestValidTransitions(t *testing.T) {
cases := []struct {
from, to Status
want bool
}{
{StatusDraft, StatusCollecting, true},
{StatusDraft, StatusRunning, false},
{StatusReady, StatusApproved, true},
{StatusApproved, StatusRunning, true},
{StatusRunning, StatusSuccess, true},
{StatusRunning, StatusClosed, false},
{StatusSuccess, StatusClosed, true},
{StatusClosed, StatusDraft, false},
}
for _, c := range cases {
if got := IsValidTransition(c.from, c.to); got != c.want {
t.Errorf("IsValidTransition(%q, %q) = %v, want %v", c.from, c.to, got, c.want)
}
}
}
func TestIsTerminal(t *testing.T) {
terminal := []Status{StatusSuccess, StatusCancelled, StatusAborted, StatusClosed}
for _, s := range terminal {
if !IsTerminal(s) {
t.Errorf("IsTerminal(%q) = false, want true", s)
}
}
nonTerminal := []Status{StatusDraft, StatusReady, StatusRunning, StatusCollecting, StatusApproved, StatusFailed, StatusTimeout}
for _, s := range nonTerminal {
if IsTerminal(s) {
t.Errorf("IsTerminal(%q) = true, want false", s)
}
}
}
func TestAllStatusesCoverage(t *testing.T) {
seen := make(map[Status]bool)
for _, s := range AllStatuses {
seen[s] = true
}
if len(seen) != 11 {
t.Fatalf("AllStatuses has %d unique statuses, want 11", len(seen))
}
}