web-ui: карточка и страница обновляются, пока задачу может двигать фон
- условие самообновления — доменный предикат store.State.IsObservable() вместо фазы catched; один поллер на поверхность, интервалы 5 с и 15 с - отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели вместо 404/500, который htmx не свопит - заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел «Живой поллинг» в конвенции веб-UI
This commit is contained in:
@@ -64,6 +64,26 @@ func (s State) IsTerminal() bool {
|
||||
return slices.Contains(terminalStates, s)
|
||||
}
|
||||
|
||||
// selfHealingStates — терминальные состояния, которые фон возвращает в поток
|
||||
// САМ, без человека: failed (по восстановимым кодам — см. ListRecoverable) и
|
||||
// состояния рассинхрона, которые сверка переоценивает по реальности (см.
|
||||
// reconcileDesync). done в перечень не входит сознательно: его переоценка
|
||||
// означает удаление файлов мимо сервиса — событие редкое, а разложенных задач в
|
||||
// списке больше всех, и наблюдать за каждой дороже, чем показать новое
|
||||
// состояние при следующем заходе.
|
||||
var selfHealingStates = []State{
|
||||
StateFailed, StateTargetMissing, StateOrphaned,
|
||||
}
|
||||
|
||||
// IsObservable сообщает, может ли состояние задачи измениться без участия
|
||||
// человека: любое нетерминальное плюс терминальные из selfHealingStates. На
|
||||
// этом предикате стоит самообновление веб-UI: поверхность обновляет себя, пока
|
||||
// задача наблюдаема, и замолкает, когда двигать её может только человек (см.
|
||||
// openspec/specs/web-ui, «Самообновление живой задачи»).
|
||||
func (s State) IsObservable() bool {
|
||||
return !s.IsTerminal() || slices.Contains(selfHealingStates, s)
|
||||
}
|
||||
|
||||
// allowedTransitions — декларативный граф легальных переходов машины состояний
|
||||
// (from → множество допустимых to). Единственный источник истины о легальности
|
||||
// рёбер: покрывает все переходы, которые worker выполняет по всем capability
|
||||
|
||||
@@ -653,3 +653,33 @@ func TestListAndByState(t *testing.T) {
|
||||
t.Fatalf("ListDownloadsByState(downloading) = %v", dl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsObservable: наблюдаемость — «состояние ещё может измениться без
|
||||
// человека». Нетерминальные наблюдаемы все; из терминальных — те, которые фон
|
||||
// возвращает в поток сам (failed по восстановимым кодам, target_missing и
|
||||
// orphaned переоценивает сверка). done в перечень не входит сознательно: его
|
||||
// переоценка означает удаление файлов мимо сервиса.
|
||||
func TestIsObservable(t *testing.T) {
|
||||
observable := []State{
|
||||
StateCatched, StateDownloading, StateCompleted, StateRecognizing,
|
||||
StateReview, StateLinking, StateDeferred, StateStuck,
|
||||
StateFailed, StateTargetMissing, StateOrphaned,
|
||||
}
|
||||
silent := []State{StateDone, StateCancelled, StateReverted, StateDeleted}
|
||||
|
||||
for _, s := range observable {
|
||||
if !s.IsObservable() {
|
||||
t.Errorf("%s: IsObservable=false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range silent {
|
||||
if s.IsObservable() {
|
||||
t.Errorf("%s: IsObservable=true, want false", s)
|
||||
}
|
||||
}
|
||||
// Наблюдаемое множество не сводится к нетерминальному — иначе предикат был
|
||||
// бы лишним, а карточка упавшей задачи замирала бы навсегда.
|
||||
if !StateFailed.IsTerminal() || !StateFailed.IsObservable() {
|
||||
t.Error("failed должно быть терминальным и при этом наблюдаемым")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user