From 0e20dd76b215015030c184436fb216f34e9b27e2 Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 16 Jul 2026 15:31:41 +0800 Subject: [PATCH] Add domain states and event runtime (T-002) --- core/application/event.go | 51 ++++++++++++ core/application/event_test.go | 30 +++++++ core/application/runtime.go | 72 +++++++++++++++++ core/application/runtime_test.go | 66 +++++++++++++++ core/domain/status.go | 134 +++++++++++++++++++++++++++++++ core/domain/status_test.go | 77 ++++++++++++++++++ docs/current-state.md | 12 +-- docs/tasks/T-002.md | 58 +++++++++++++ 8 files changed, 494 insertions(+), 6 deletions(-) create mode 100644 core/application/event.go create mode 100644 core/application/event_test.go create mode 100644 core/application/runtime.go create mode 100644 core/application/runtime_test.go create mode 100644 core/domain/status.go create mode 100644 core/domain/status_test.go create mode 100644 docs/tasks/T-002.md diff --git a/core/application/event.go b/core/application/event.go new file mode 100644 index 0000000..5aa54d2 --- /dev/null +++ b/core/application/event.go @@ -0,0 +1,51 @@ +package application + +// EventType identifies an application event consumed by UI adapters. +type EventType string + +const ( + EventCatalogRefreshed EventType = "CatalogRefreshed" + EventCatalogRejected EventType = "CatalogRejected" + EventDownloadStarted EventType = "DownloadStarted" + EventDownloadProgress EventType = "DownloadProgress" + EventDownloadPaused EventType = "DownloadPaused" + EventDownloadCompleted EventType = "DownloadCompleted" + EventDownloadFailed EventType = "DownloadFailed" + EventInstallCompleted EventType = "InstallCompleted" + EventInstallRolledBack EventType = "InstallRolledBack" + EventAppStarted EventType = "AppStarted" + EventAppExited EventType = "AppExited" + EventLicenseChanged EventType = "LicenseChanged" +) + +var validEventTypes = map[EventType]struct{}{ + EventCatalogRefreshed: {}, + EventCatalogRejected: {}, + EventDownloadStarted: {}, + EventDownloadProgress: {}, + EventDownloadPaused: {}, + EventDownloadCompleted: {}, + EventDownloadFailed: {}, + EventInstallCompleted: {}, + EventInstallRolledBack: {}, + EventAppStarted: {}, + EventAppExited: {}, + EventLicenseChanged: {}, +} + +// Valid reports whether eventType is part of the documented event contract. +func (eventType EventType) Valid() bool { + _, ok := validEventTypes[eventType] + return ok +} + +// Event is the common envelope delivered from background use cases to adapters. +// +// Payload is event-specific and will be replaced by concrete payload types as +// the corresponding use cases are implemented. +type Event struct { + Type EventType + RequestID string + AppID string + Payload any +} diff --git a/core/application/event_test.go b/core/application/event_test.go new file mode 100644 index 0000000..a24c3d4 --- /dev/null +++ b/core/application/event_test.go @@ -0,0 +1,30 @@ +package application + +import "testing" + +func TestEventTypeValid(t *testing.T) { + eventTypes := []EventType{ + EventCatalogRefreshed, + EventCatalogRejected, + EventDownloadStarted, + EventDownloadProgress, + EventDownloadPaused, + EventDownloadCompleted, + EventDownloadFailed, + EventInstallCompleted, + EventInstallRolledBack, + EventAppStarted, + EventAppExited, + EventLicenseChanged, + } + + for _, eventType := range eventTypes { + if !eventType.Valid() { + t.Errorf("event type %q should be valid", eventType) + } + } + + if EventType("Unknown").Valid() { + t.Fatal("unknown event type should be invalid") + } +} diff --git a/core/application/runtime.go b/core/application/runtime.go new file mode 100644 index 0000000..83b21eb --- /dev/null +++ b/core/application/runtime.go @@ -0,0 +1,72 @@ +package application + +import ( + "context" + "errors" + "fmt" + "sync" +) + +var ( + // ErrInvalidEvent indicates that an event is not part of the event contract. + ErrInvalidEvent = errors.New("invalid application event") + // ErrRuntimeClosed indicates that the runtime no longer accepts events. + ErrRuntimeClosed = errors.New("application runtime closed") +) + +// Runtime is the minimal event bus shared by background use cases and adapters. +type Runtime struct { + events chan Event + done chan struct{} + closeOnce sync.Once +} + +// NewRuntime creates an event runtime with the requested queue capacity. +func NewRuntime(buffer int) *Runtime { + if buffer < 0 { + panic("application runtime buffer must not be negative") + } + return &Runtime{ + events: make(chan Event, buffer), + done: make(chan struct{}), + } +} + +// Publish queues an event or returns when the context/runtime is closed. +func (runtime *Runtime) Publish(ctx context.Context, event Event) error { + if !event.Type.Valid() { + return fmt.Errorf("%w: unknown type %q", ErrInvalidEvent, event.Type) + } + + select { + case <-runtime.done: + return ErrRuntimeClosed + default: + } + + select { + case <-runtime.done: + return ErrRuntimeClosed + case <-ctx.Done(): + return ctx.Err() + case runtime.events <- event: + return nil + } +} + +// Events exposes the read-only event stream to adapters. +func (runtime *Runtime) Events() <-chan Event { + return runtime.events +} + +// Done is closed when the runtime stops accepting events. +func (runtime *Runtime) Done() <-chan struct{} { + return runtime.done +} + +// Close stops future publishes. It is safe to call more than once. +func (runtime *Runtime) Close() { + runtime.closeOnce.Do(func() { + close(runtime.done) + }) +} diff --git a/core/application/runtime_test.go b/core/application/runtime_test.go new file mode 100644 index 0000000..08864d5 --- /dev/null +++ b/core/application/runtime_test.go @@ -0,0 +1,66 @@ +package application + +import ( + "context" + "errors" + "testing" +) + +func TestRuntimePublish(t *testing.T) { + runtime := NewRuntime(1) + event := Event{ + Type: EventDownloadStarted, + RequestID: "request-1", + AppID: "json-parser", + } + + if err := runtime.Publish(context.Background(), event); err != nil { + t.Fatalf("Publish() error = %v", err) + } + + got := <-runtime.Events() + if got != event { + t.Fatalf("Events() got %#v, want %#v", got, event) + } +} + +func TestRuntimeRejectsInvalidEvent(t *testing.T) { + runtime := NewRuntime(1) + + err := runtime.Publish(context.Background(), Event{Type: EventType("Unknown")}) + if !errors.Is(err, ErrInvalidEvent) { + t.Fatalf("Publish() error = %v, want %v", err, ErrInvalidEvent) + } +} + +func TestRuntimePublishHonorsContextCancellation(t *testing.T) { + runtime := NewRuntime(1) + if err := runtime.Publish(context.Background(), Event{Type: EventDownloadStarted}); err != nil { + t.Fatalf("first Publish() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := runtime.Publish(ctx, Event{Type: EventDownloadProgress}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("blocked Publish() error = %v, want %v", err, context.Canceled) + } +} + +func TestRuntimeClose(t *testing.T) { + runtime := NewRuntime(1) + runtime.Close() + runtime.Close() + + select { + case <-runtime.Done(): + default: + t.Fatal("Done() should be closed") + } + + err := runtime.Publish(context.Background(), Event{Type: EventDownloadStarted}) + if !errors.Is(err, ErrRuntimeClosed) { + t.Fatalf("Publish() error = %v, want %v", err, ErrRuntimeClosed) + } +} diff --git a/core/domain/status.go b/core/domain/status.go new file mode 100644 index 0000000..23ce7f1 --- /dev/null +++ b/core/domain/status.go @@ -0,0 +1,134 @@ +package domain + +import ( + "errors" + "fmt" +) + +// AppStatus describes the user-visible lifecycle state of one catalog app. +type AppStatus string + +const ( + StatusNotInstalled AppStatus = "not_installed" + StatusQueued AppStatus = "queued" + StatusDownloading AppStatus = "downloading" + StatusVerifying AppStatus = "verifying" + StatusExtracting AppStatus = "extracting" + StatusInstalling AppStatus = "installing" + StatusInstalled AppStatus = "installed" + StatusUpdateAvailable AppStatus = "update_available" + StatusRunning AppStatus = "running" + StatusFailed AppStatus = "failed" + StatusRollbackPending AppStatus = "rollback_pending" + StatusIncompatible AppStatus = "incompatible" +) + +var ( + // ErrInvalidStatus indicates that a transition contains an unknown status. + ErrInvalidStatus = errors.New("invalid app status") + // ErrInvalidTransition indicates that two valid states cannot transition directly. + ErrInvalidTransition = errors.New("invalid app status transition") +) + +var validStatuses = map[AppStatus]struct{}{ + StatusNotInstalled: {}, + StatusQueued: {}, + StatusDownloading: {}, + StatusVerifying: {}, + StatusExtracting: {}, + StatusInstalling: {}, + StatusInstalled: {}, + StatusUpdateAvailable: {}, + StatusRunning: {}, + StatusFailed: {}, + StatusRollbackPending: {}, + StatusIncompatible: {}, +} + +type statusTransition struct { + from AppStatus + to AppStatus +} + +var allowedTransitions = map[statusTransition]struct{}{ + {StatusNotInstalled, StatusQueued}: {}, + {StatusNotInstalled, StatusIncompatible}: {}, + {StatusQueued, StatusDownloading}: {}, + {StatusQueued, StatusNotInstalled}: {}, + {StatusQueued, StatusInstalled}: {}, + {StatusQueued, StatusUpdateAvailable}: {}, + {StatusQueued, StatusFailed}: {}, + {StatusDownloading, StatusQueued}: {}, + {StatusDownloading, StatusVerifying}: {}, + {StatusDownloading, StatusNotInstalled}: {}, + {StatusDownloading, StatusInstalled}: {}, + {StatusDownloading, StatusUpdateAvailable}: {}, + {StatusDownloading, StatusFailed}: {}, + {StatusVerifying, StatusExtracting}: {}, + {StatusVerifying, StatusFailed}: {}, + {StatusExtracting, StatusInstalling}: {}, + {StatusExtracting, StatusFailed}: {}, + {StatusInstalling, StatusInstalled}: {}, + {StatusInstalling, StatusRollbackPending}: {}, + {StatusInstalling, StatusFailed}: {}, + {StatusInstalled, StatusUpdateAvailable}: {}, + {StatusInstalled, StatusRunning}: {}, + {StatusInstalled, StatusQueued}: {}, + {StatusInstalled, StatusIncompatible}: {}, + {StatusUpdateAvailable, StatusQueued}: {}, + {StatusUpdateAvailable, StatusRunning}: {}, + {StatusUpdateAvailable, StatusInstalled}: {}, + {StatusUpdateAvailable, StatusIncompatible}: {}, + {StatusRunning, StatusInstalled}: {}, + {StatusRunning, StatusUpdateAvailable}: {}, + {StatusFailed, StatusQueued}: {}, + {StatusFailed, StatusNotInstalled}: {}, + {StatusFailed, StatusInstalled}: {}, + {StatusFailed, StatusUpdateAvailable}: {}, + {StatusFailed, StatusRollbackPending}: {}, + {StatusRollbackPending, StatusInstalled}: {}, + {StatusRollbackPending, StatusFailed}: {}, + {StatusIncompatible, StatusNotInstalled}: {}, + {StatusIncompatible, StatusInstalled}: {}, + {StatusIncompatible, StatusUpdateAvailable}: {}, +} + +// Valid reports whether status is one of the documented domain states. +func (status AppStatus) Valid() bool { + _, ok := validStatuses[status] + return ok +} + +// CanTransition reports whether from can move directly to to. +func CanTransition(from, to AppStatus) bool { + return ValidateTransition(from, to) == nil +} + +// ValidateTransition verifies a direct state change. +func ValidateTransition(from, to AppStatus) error { + if !from.Valid() { + return fmt.Errorf("%w: %q", ErrInvalidStatus, from) + } + if !to.Valid() { + return fmt.Errorf("%w: %q", ErrInvalidStatus, to) + } + if _, ok := allowedTransitions[statusTransition{from: from, to: to}]; !ok { + return &TransitionError{From: from, To: to} + } + return nil +} + +// TransitionError describes a rejected direct state change. +type TransitionError struct { + From AppStatus + To AppStatus +} + +func (err *TransitionError) Error() string { + return fmt.Sprintf("%s: %s -> %s", ErrInvalidTransition, err.From, err.To) +} + +// Unwrap allows callers to use errors.Is with ErrInvalidTransition. +func (err *TransitionError) Unwrap() error { + return ErrInvalidTransition +} diff --git a/core/domain/status_test.go b/core/domain/status_test.go new file mode 100644 index 0000000..d4ff7f5 --- /dev/null +++ b/core/domain/status_test.go @@ -0,0 +1,77 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestAppStatusValid(t *testing.T) { + statuses := []AppStatus{ + StatusNotInstalled, + StatusQueued, + StatusDownloading, + StatusVerifying, + StatusExtracting, + StatusInstalling, + StatusInstalled, + StatusUpdateAvailable, + StatusRunning, + StatusFailed, + StatusRollbackPending, + StatusIncompatible, + } + + for _, status := range statuses { + if !status.Valid() { + t.Errorf("status %q should be valid", status) + } + } + + if AppStatus("unknown").Valid() { + t.Fatal("unknown status should be invalid") + } +} + +func TestValidateTransition(t *testing.T) { + tests := []struct { + name string + from AppStatus + to AppStatus + want error + }{ + {name: "queue install", from: StatusNotInstalled, to: StatusQueued}, + {name: "start download", from: StatusQueued, to: StatusDownloading}, + {name: "pause download", from: StatusDownloading, to: StatusQueued}, + {name: "finish install", from: StatusInstalling, to: StatusInstalled}, + {name: "start app", from: StatusInstalled, to: StatusRunning}, + {name: "recover rollback", from: StatusRollbackPending, to: StatusInstalled}, + {name: "skip install pipeline", from: StatusNotInstalled, to: StatusInstalled, want: ErrInvalidTransition}, + {name: "download while running", from: StatusRunning, to: StatusDownloading, want: ErrInvalidTransition}, + {name: "extract incompatible app", from: StatusIncompatible, to: StatusExtracting, want: ErrInvalidTransition}, + {name: "same status", from: StatusInstalled, to: StatusInstalled, want: ErrInvalidTransition}, + {name: "unknown source", from: AppStatus("unknown"), to: StatusQueued, want: ErrInvalidStatus}, + {name: "unknown target", from: StatusQueued, to: AppStatus("unknown"), want: ErrInvalidStatus}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateTransition(test.from, test.to) + if test.want == nil { + if err != nil { + t.Fatalf("ValidateTransition(%q, %q) error = %v", test.from, test.to, err) + } + if !CanTransition(test.from, test.to) { + t.Fatalf("CanTransition(%q, %q) = false, want true", test.from, test.to) + } + return + } + + if !errors.Is(err, test.want) { + t.Fatalf("ValidateTransition(%q, %q) error = %v, want %v", test.from, test.to, err, test.want) + } + if CanTransition(test.from, test.to) { + t.Fatalf("CanTransition(%q, %q) = true, want false", test.from, test.to) + } + }) + } +} diff --git a/docs/current-state.md b/docs/current-state.md index 5aaa56b..bc9674a 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -15,13 +15,13 @@ - 日期:2026-07-16 - 阶段:MVP 起步(Phase 0 工程骨架建设中) - 技术栈:根 Go workspace 纳入 core/app-modern/app-win7 三模块;`app-win7/go.work` 隔离 Go 1.20.14 构建;Gio UI 与平台 stub 待后续 Phase 0 任务接入 -- 生产代码:已有最小 core 包与 modern/win7 命令入口;尚无业务功能 -- 测试:core 已有最小真实单元测试 +- 生产代码:core 已有软件状态模型、迁移规则、application Event 合约与 Runtime 事件总线骨架;modern/win7 仍为无 UI 命令入口 +- 测试:core 已覆盖状态合法性/非法迁移、事件类型、发布/取消/关闭 - 数据:无;Catalog 清单与测试样例待建 - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、验证 core、打印 modern/win7 双目标构建命令) - 标准验证路径:`go -C core vet ./...` + `go -C core test -count=1 ./...`;双目标构建命令见下文 - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 -- 当前 blocker:无;下一步按路线图落成并领取 T-002 +- 当前 blocker:无;下一步按路线图落成并领取 T-003 ## 当前目录要点 @@ -30,7 +30,7 @@ | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | | `docs/tasks/` | 已有 | 任务目录;T-001 已完成,T-002 待按路线图落成 | | `scripts/` | 已有 | harness 治理脚本(validate_agent_context 等);构建脚本待建 | -| `core/` | 已建 | Go 1.20 兼容共享模块;当前仅最小包与测试 | +| `core/` | 已建 | Go 1.20 兼容共享模块;已有 domain 状态模型与 application 事件 runtime | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1 模块;当前为无 UI 命令入口 | | `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0 模块;当前为无 UI 命令入口 | | `schemas/` | 待建 | 协议 JSON Schema(T-201) | @@ -40,9 +40,9 @@ 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: -- 已完成:`T-001 初始化 monorepo 骨架`。 +- 已完成:`T-001 初始化 monorepo 骨架`、`T-002 建立 domain 状态模型与事件总线`。 - 正在进行:无。 -- 下一个可领取任务:按路线图落成并领取 `T-002 建立 domain 状态模型与事件总线`。 +- 下一个可领取任务:按路线图落成并领取 `T-003 Gio 空窗口 + 平台层 stub`。 ## 当前可运行内容 diff --git a/docs/tasks/T-002.md b/docs/tasks/T-002.md new file mode 100644 index 0000000..df2b602 --- /dev/null +++ b/docs/tasks/T-002.md @@ -0,0 +1,58 @@ +--- +id: T-002 +title: 建立 domain 状态模型与 application 事件总线 +phase: 0 +deps: [T-001] +status: DONE +created: 2026-07-16 +issue: null +context_ref: cf9fc01c68476ba3ee4f69525958f2e62ba2d6ce +claim_branch: null +work_branch: agent/codex/T-002 +write_paths: + - docs/tasks/T-002.md + - core/domain/ + - core/application/ + - docs/current-state.md +--- + +## 问题 / 背景 + +T-001 只建立了可编译骨架。后续清单、下载、安装、启动和 UI 都需要共享、稳定的软件状态模型,后台任务也需要通过统一事件通道与 UI 解耦。 + +## 方案 + +1. 在 `core/domain` 定义 `docs/04-architecture.md` 已列出的 12 个软件状态及显式迁移表。 +2. 提供状态合法性与迁移校验 API,非法迁移返回包含起止状态的稳定错误。 +3. 在 `core/application` 定义 `docs/api.md` 已列出的事件类型与通用 `Event` 信封。 +4. 建立带缓冲事件通道、上下文取消与关闭信号的 `Runtime` 骨架;关闭后拒绝新事件。 +5. 使用表驱动测试覆盖合法/非法状态迁移、未知状态、事件发布、取消与关闭。 + +## 验收要点 + +- 软件状态枚举与架构文档一致,不新增未定义状态。 +- 表驱动测试至少覆盖 `not_installed → installed`、`running → downloading` 等非法迁移。 +- application 事件类型与 `docs/api.md` 一致;未知类型不能发布。 +- runtime 可以发布/消费事件,阻塞发布可由 context 取消,关闭后返回稳定错误。 +- `cd core && go vet ./... && go test -count=1 ./...` 通过。 +- modern 与 win7 两个目标继续可编译。 + +## 边界(不改什么) + +- 不实现清单、下载、安装、授权等具体用例。 +- 不引入 Gio、Windows API 或第三方依赖。 +- 不修改 app UI 与平台层。 + +## 协作约束 + +未启用 Gitea;本任务在 `agent/codex/T-002` 分支串行执行。新增写路径前必须确认不越过本文件声明范围。 + +## 执行记录 + +- 2026-07-16:在 `core/domain/status.go` 落地架构文档中的 12 个软件状态、显式迁移表、状态合法性与迁移校验错误。 +- 2026-07-16:表驱动测试覆盖正常下载/安装/运行/回滚路径,并覆盖跳过安装链、运行中下载、未知状态和同状态迁移等非法情况。 +- 2026-07-16:在 `core/application` 落地 `docs/api.md` 已定义的 12 个事件类型、通用 Event 信封及支持缓冲、context 取消、幂等关闭的 Runtime 事件总线骨架。 +- 验证通过:`cd core && go vet ./... && go test -count=1 ./...`。 +- 验证通过:Go 1.20.14 + `GOWORK=off` 执行 core vet/test,确认共享核心兼容基线。 +- 验证通过:modern Go 1.25.0 与 win7 Go 1.20.14 两个 Windows amd64 目标重新编译。 +- 验证通过:`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py`。