diff --git a/docs/superpowers/plans/2026-08-30-openflare-cordis-alignment.md b/docs/superpowers/plans/2026-08-30-openflare-cordis-alignment.md new file mode 100644 index 00000000..13084eeb --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-openflare-cordis-alignment.md @@ -0,0 +1,1268 @@ +# OpenFlare Cordis 对齐 Implementation Plan + +> **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:** 让 OpenFlare 控制面与 Wavelet Cordis 同构装配,删除全部平台副本,用 git merge 接入 Wavelet 上游,并保证金标准 v3.5.4 库可升级且测试全绿。 + +**Architecture:** 先在 Wavelet 补齐通用扩展点 W1–W9;再把 `core/pkg/plugins` 同步进 OpenFlare;将 `cmd` 改为 `newWaveletApp` + `server.New()` + `WithMigrationBaseline`;`server` 只留 `of_*` 业务;历史库经 Baseline stamp,不再执行 76 条混合链。对齐完成后再做一次 unrelated merge。 + +**Tech Stack:** Go 1.25、Gin、GORM、goose v3、Cobra、PostgreSQL/SQLite、可选 ClickHouse。 + +**规格来源:** `docs/superpowers/specs/2026-08-30-openflare-cordis-alignment-design.md` + +## Global Constraints + +- 禁止修改 `/Users/ryan/Code/Go/OpenFlare`(金标准,`9f79fb99` / v3.5.4)。 +- 禁止修改 `frontend/` 源码。 +- 禁止在 OpenFlare `server` 注册 `/api/health`、`/api/v1/user/self`、`/api/cap/*`。 +- 禁止把 OpenFlare / `of_` / 边缘节点写进 Wavelet。 +- 禁止 rebase / filter-repo / force-push 改写 OpenFlare 历史。 +- 禁止把 pow 抽到 `pkg/pow`;禁止第二套关系库 migrator。 +- Wavelet 工作在 `/Users/ryan/Code/Go/Wavelet`;OpenFlare 工作在 `/Users/ryan/Code/Go/OpenFlare-cordis`。 +- 执行期隔离工作区按 `using-git-worktrees` 在动手时创建,本计划不预先建。 + +--- + +## 文件结构 + +| 仓库 | 路径 | 职责 | +| :--- | :--- | :--- | +| Wavelet | `backend/core/extpoints/router.go`、`scoped_extpoints.go` | W1 HandleRaw / BasePath | +| Wavelet | `backend/core/contracts/captcha.go` | `CaptchaService` | +| Wavelet | `backend/core/contracts/config_public.go` | `PublicConfigProvider` | +| Wavelet | `backend/core/contracts/push.go` | `PushRegistry` + `PushEventMeta` | +| Wavelet | `backend/core/app.go`、`context.go` | `WithMigrationBaseline` | +| Wavelet | `backend/plugins/domain/cap/plugin.go` | Provide CaptchaService;挂 `/api/cap/*` | +| Wavelet | `backend/plugins/domain/user/plugin.go` | 消费 CaptchaService;`GET /self` | +| Wavelet | `backend/plugins/domain/system/plugin.go` | `GET /api/health`;PublicConfigProvider | +| Wavelet | `backend/plugins/domain/upload/plugin.go` | 补挂 `/my` 等用户路由 | +| Wavelet | `backend/plugins/domain/message_gateway/` | 实现 PushRegistry | +| Wavelet | `backend/plugins/drivers/driver_http/` | `RedirectTrailingSlash` | +| Wavelet | `backend/cmd/app.go` | gooseEngine:Baseline + advisory lock | +| OpenFlare | `backend/cmd/{app,api,all,worker,scheduler,root}.go` | 与 Wavelet 同构 + `server.New()` | +| OpenFlare | `backend/OpenFlare/plugins/server/plugin.go` | 只注册 `of_*` 业务 | +| OpenFlare | `backend/OpenFlare/plugins/server/stamp/stamp.go` | Baseline stamp 实现 | +| OpenFlare | `backend/OpenFlare/plugins/server/migrations/{postgres,sqlite}/00001_initial.sql` | 仅 `of_*` | +| OpenFlare | `.gitattributes` | `merge=ours` 规则 | + +--- + +### Task 1: 金标准 L1(只读,失败即停) + +**Files:** 无(只读 `/Users/ryan/Code/Go/OpenFlare`) + +**Interfaces:** +- Consumes: 金标准树 `9f79fb99` +- Produces: 确认 `go test ./...` 在金标准上为绿,作为后续门禁前提 + +- [ ] **Step 1: 确认 HEAD** + +```bash +git -C /Users/ryan/Code/Go/OpenFlare rev-parse HEAD +git -C /Users/ryan/Code/Go/OpenFlare status --short +``` + +Expected: `9f79fb99...`,工作区空。若不是,停下来问人,不要改金标准。 + +- [ ] **Step 2: 跑金标准测试** + +```bash +cd /Users/ryan/Code/Go/OpenFlare && go test ./... +``` + +Expected: exit 0。失败则本计划停止。 + +- [ ] **Step 3: 不提交** + +本任务无代码变更。 + +--- + +### Task 2: Wavelet W1 HandleRaw + +**Files:** +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/core/extpoints/router.go`(接口 + HandleRaw) +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/core/scoped_extpoints.go` +- Create: `/Users/ryan/Code/Go/Wavelet/backend/core/extpoints/router_raw_test.go` +- Test: 同上 + +**Interfaces:** +- Consumes: 无 +- Produces: `RouterExtension.HandleRaw(method, path string, handlers ...any) RouteDefinition`;`BasePath() string` + +参考实现已在 OpenFlare-cordis(不要发明第二套语义): +`/Users/ryan/Code/Go/OpenFlare-cordis/backend/core/extpoints/router.go` 的 `addRoute` / `HandleRaw` / `joinPathPreservingTrailing` / `ensureLeadingSlash`,以及 `scoped_extpoints.go` 的 `HandleRaw`。 + +- [ ] **Step 1: 在 Wavelet 写入失败测试** + +把下面文件写到 `/Users/ryan/Code/Go/Wavelet/backend/core/extpoints/router_raw_test.go`(与 OpenFlare 仓同文): + +```go +package extpoints + +import "testing" + +func TestHandleRawPreservesTrailingSlash(t *testing.T) { + r := &RouterRegistry{} + g := r.Group("/api/v1/nodes") + + if got := g.BasePath(); got != "/api/v1/nodes" { + t.Fatalf("BasePath() = %q, want %q", got, "/api/v1/nodes") + } + slashless := g.Handle("GET", "") + slashed := g.HandleRaw("GET", "/") + + if slashless.Path != "/api/v1/nodes" { + t.Errorf("Handle(\"\") path = %q, want %q", slashless.Path, "/api/v1/nodes") + } + if slashed.Path != "/api/v1/nodes/" { + t.Errorf("HandleRaw(\"/\") path = %q, want %q", slashed.Path, "/api/v1/nodes/") + } + if slashed.ID == slashless.ID { + t.Error("HandleRaw must allocate its own route ID") + } + if got := len(r.Routes()); got != 2 { + t.Errorf("registry routes = %d, want 2", got) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./core/extpoints/ -run TestHandleRaw -count=1 +``` + +Expected: FAIL(`HandleRaw` undefined 或 `BasePath` undefined)。 + +- [ ] **Step 3: 实现** + +在 `RouterExtension` 增加 `HandleRaw` 与 `BasePath`。`Handle` 继续 `cleanPath`。`HandleRaw` 走 `ensureLeadingSlash` / `joinPathPreservingTrailing`,**不**剥尾斜杠。`RouterRegistry` 抽出 `addRoute`。`scopedRouterExtension.HandleRaw` 与 `Handle` 一样登记 `OnDispose` → `UnregisterByID`。从 OpenFlare-cordis 对应文件拷语义,不要改行为。 + +- [ ] **Step 4: 测试通过** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./core/extpoints/ ./core/ -count=1 +``` + +Expected: PASS。 + +- [ ] **Step 5: 提交(Wavelet 仓)** + +```bash +cd /Users/ryan/Code/Go/Wavelet +git add backend/core/extpoints/router.go backend/core/extpoints/router_raw_test.go backend/core/scoped_extpoints.go +git commit -m "feat(core): add HandleRaw and BasePath for trailing-slash routes" +``` + +--- + +### Task 3: Wavelet W2 CaptchaService + `/api/cap/*` + user 门禁 + +**Files:** +- Create: `/Users/ryan/Code/Go/Wavelet/backend/core/contracts/captcha.go` +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/cap/plugin.go` +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/user/plugin.go` +- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/cap/plugin_captcha_contract_test.go` +- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/user/plugin_captcha_test.go` + +**Interfaces:** +- Consumes: 现有 `cap.VerifyMiddleware`、`Challenge`、`Redeem` +- Produces: + +```go +package contracts + +type CaptchaService interface { + VerifyMiddleware(scope string) any + ChallengeHandler() any + RedeemHandler() any +} +``` + +- [ ] **Step 1: 写契约测试(cap 插件 Apply 后能 Inject CaptchaService)** + +```go +package cap + +import ( + "context" + "testing" + + "Wavelet/core" + "Wavelet/core/contracts" +) + +func TestApplyProvidesCaptchaService(t *testing.T) { + ctx := core.NewContext(context.Background()) + if err := New().Apply(ctx); err != nil { + t.Fatal(err) + } + svc, err := core.Inject[contracts.CaptchaService](ctx) + if err != nil || svc == nil { + t.Fatalf("Inject CaptchaService: svc=%v err=%v", svc, err) + } + if svc.ChallengeHandler() == nil || svc.RedeemHandler() == nil { + t.Fatal("handlers must be non-nil") + } + if svc.VerifyMiddleware("login") == nil { + t.Fatal("VerifyMiddleware(login) must be non-nil") + } +} + +func TestApplyRegistersUnversionedCapRoutes(t *testing.T) { + ctx := core.NewContext(context.Background()) + if err := New().Apply(ctx); err != nil { + t.Fatal(err) + } + want := map[string]bool{ + "POST /api/v1/cap/challenge": false, + "POST /api/cap/challenge": false, + "POST /api/cap/redeem": false, + } + for _, rd := range ctx.Router().Routes() { + key := rd.Method + " " + rd.Path + if _, ok := want[key]; ok { + want[key] = true + } + } + for key, ok := range want { + if !ok { + t.Errorf("missing route %s", key) + } + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/cap/ -run TestApplyProvidesCaptchaService -count=1 +``` + +Expected: FAIL(CaptchaService 未定义或未 Provide)。 + +- [ ] **Step 3: 实现契约与 cap 插件** + +`captcha.go` 只含接口,无 OpenFlare 字样。 + +cap 内未导出适配: + +```go +type captchaService struct{} + +func (captchaService) VerifyMiddleware(scope string) any { + return VerifyMiddleware(GetDefaultManager(), scope) +} +func (captchaService) ChallengeHandler() any { return Challenge } +func (captchaService) RedeemHandler() any { return Redeem } +``` + +`Apply` 中:`core.Provide[contracts.CaptchaService](ctx, captchaService{})`。在现有 `/api/v1/cap` 组之外再: + +```go +legacy := ctx.Router().Group("/api/cap") +legacy.POST("/challenge", Challenge) +legacy.POST("/redeem", Redeem) +ctx.Router().RegisterWhitelist("/api/cap/challenge", "/api/cap/redeem") +``` + +原 `/api/v1/cap` 组保留。 + +- [ ] **Step 4: user 插件消费 CaptchaService** + +在 `user/plugin.go` 注册 login/register/send-email-code 时: + +```go +passThrough := gin.HandlerFunc(func(c *gin.Context) { c.Next() }) +loginCap, registerCap, emailCap := passThrough, passThrough, passThrough +if capSvc, err := core.Inject[contracts.CaptchaService](ctx); err == nil && capSvc != nil { + if mw, ok := capSvc.VerifyMiddleware("login").(gin.HandlerFunc); ok { + loginCap = mw + } + if mw, ok := capSvc.VerifyMiddleware("register").(gin.HandlerFunc); ok { + registerCap = mw + } + if mw, ok := capSvc.VerifyMiddleware("send_email_code").(gin.HandlerFunc); ok { + emailCap = mw + } +} +userGroup.POST("/login", loginCap, Login) +userGroup.POST("/register", registerCap, Register) +userGroup.POST("/send-email-code", emailCap, SendEmailCode) +``` + +无 CaptchaService 时行为与现在相同(裸路由)。user 的 `Inject()` 不要把 CaptchaService 变成硬依赖。 + +user 测试:Apply 且未 Provide captcha 时三条路由仍在;Provide 假 CaptchaService 后 login 的 handler 链长度大于 1。 + +- [ ] **Step 5: 跑测试** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/cap/ ./plugins/domain/user/ ./core/contracts/ -count=1 +``` + +Expected: PASS。 + +- [ ] **Step 6: 提交** + +```bash +cd /Users/ryan/Code/Go/Wavelet +git add backend/core/contracts/captcha.go backend/plugins/domain/cap backend/plugins/domain/user +git commit -m "feat(cap): expose CaptchaService and unversioned /api/cap routes" +``` + +--- + +### Task 4: Wavelet W3 PublicConfigProvider + +**Files:** +- Create: `/Users/ryan/Code/Go/Wavelet/backend/core/contracts/config_public.go` +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/system/plugin.go` +- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/system/public_config_test.go` + +**Interfaces:** +- Produces: + +```go +package contracts + +import "context" + +type PublicConfigProvider interface { + PublicConfig(ctx context.Context) (any, error) +} +``` + +- [ ] **Step 1: 失败测试** + +```go +package system + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "Wavelet/core" + "Wavelet/core/contracts" + + "github.com/gin-gonic/gin" +) + +type stubPublic struct{ payload any } + +func (s stubPublic) PublicConfig(context.Context) (any, error) { return s.payload, nil } + +func TestPublicConfigUsesProviderWhenPresent(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + core.Provide[contracts.PublicConfigProvider](ctx, stubPublic{payload: map[string]string{"k": "v"}}) + if err := New().Apply(ctx); err != nil { + t.Fatal(err) + } + var handler gin.HandlerFunc + for _, rd := range ctx.Router().Routes() { + if rd.Method == "GET" && rd.Path == "/api/v1/config/public" { + handler = rd.Handlers[0].(gin.HandlerFunc) + } + } + if handler == nil { + t.Fatal("public config route missing") + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/config/public", nil) + handler(c) + var body struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if string(body.Data) != `{"k":"v"}` && string(body.Data) != `{"k": "v"}` { + t.Fatalf("data = %s, want flat map", body.Data) + } +} +``` + +再加一个无 provider 的测试:响应 JSON 含 `"configs"` 与 `"app"`(保持 Wavelet 默认)。 + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/system/ -run TestPublicConfig -count=1 +``` + +Expected: FAIL。 + +- [ ] **Step 3: 实现** + +`GET /api/v1/config/public` handler 内: + +```go +if p, err := core.Inject[contracts.PublicConfigProvider](ctx); err == nil && p != nil { + data, err := p.PublicConfig(c.Request.Context()) + if err != nil { + logger.ErrorF(c.Request.Context(), "[System] public config provider failed: %v", err) + response.AbortInternal(c, "public config unavailable") + return + } + c.JSON(http.StatusOK, response.OK(data)) + return +} +// 原 {configs, app} 默认分支 +``` + +`Inject` 在请求时解析,这样 server 后挂 Provide 仍生效(若 Apply 顺序导致 Provide 晚于 system.Apply,改用 `core.When` 存到局部变量,或在 handler 里每次 `Inject`)。**必须在 handler 里 Inject**,不要在 `Apply` 时缓存成 nil。 + +- [ ] **Step 4: 测试通过并提交** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/system/ -count=1 +git -C /Users/ryan/Code/Go/Wavelet add backend/core/contracts/config_public.go backend/plugins/domain/system +git -C /Users/ryan/Code/Go/Wavelet commit -m "feat(system): allow PublicConfigProvider to replace public config payload" +``` + +--- + +### Task 5: Wavelet W4 RedirectTrailingSlash + +**Files:** +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/drivers/driver_http/config.go` +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/drivers/driver_http/engine.go` +- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/drivers/driver_http/engine_slash_test.go` + +**Interfaces:** +- Produces: `httpAppConfig.RedirectTrailingSlash bool`,tag `config:"redirect_trailing_slash" env:"APP_REDIRECT_TRAILING_SLASH" default:"true"` + +- [ ] **Step 1: 失败测试** + +```go +package driver_http + +import "testing" + +func TestBuildEngineDefaultRedirectsTrailingSlash(t *testing.T) { + eng, err := BuildEngineWithConfig(httpAppConfig{}, httpRedisConfig{}) + if err != nil { + t.Fatal(err) + } + if !eng.RedirectTrailingSlash { + t.Fatal("default RedirectTrailingSlash must be true") + } +} + +func TestBuildEngineCanDisableRedirectTrailingSlash(t *testing.T) { + eng, err := BuildEngineWithConfig(httpAppConfig{RedirectTrailingSlash: false}, httpRedisConfig{}) + if err != nil { + t.Fatal(err) + } + if eng.RedirectTrailingSlash { + t.Fatal("RedirectTrailingSlash must honor false") + } +} +``` + +注意:`httpAppConfig` 里 bool 零值是 false,与 default true 冲突。测试里「默认」必须走 `BuildEngineWithConfig` 在 **未显式设 false 且 Bind 之后** 的行为。实现上用 `*bool` 或单独 `redirectTrailingSlashSet`。推荐: + +```go +RedirectTrailingSlash *bool `config:"redirect_trailing_slash" env:"APP_REDIRECT_TRAILING_SLASH"` +``` + +`BuildEngineWithConfig`:`redirect := true`;若 `cfg.RedirectTrailingSlash != nil { redirect = *cfg.RedirectTrailingSlash }`。测试「默认」传 `httpAppConfig{}`(nil → true);「关闭」传 `ptr(false)`。 + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/drivers/driver_http/ -run TestBuildEngine -count=1 +``` + +Expected: FAIL。 + +- [ ] **Step 3: 在 `BuildEngineWithConfig` 里 `r.RedirectTrailingSlash = redirect`,默认 true。** + +- [ ] **Step 4: 测试通过并提交** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/drivers/driver_http/ -count=1 +git -C /Users/ryan/Code/Go/Wavelet add backend/plugins/drivers/driver_http +git -C /Users/ryan/Code/Go/Wavelet commit -m "feat(http): make trailing-slash redirect configurable" +``` + +--- + +### Task 6: Wavelet W5 upload 用户路由补挂 + +**Files:** +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/upload/plugin.go`(约 123–129 行) +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/upload/plugin_test.go`(若无路由测试则创建) + +**Interfaces:** +- Consumes: 已有 `handler.ListMyFiles`、`handler.UpdateMyFile`、`handler.DownloadFile`、`handler.BatchDownloadFiles` +- Produces: 用户组额外路径 `GET /api/v1/upload/my`、`PUT /api/v1/upload/:id`、`GET /api/v1/upload/download/:id`、`POST /api/v1/upload/download/batch` + +- [ ] **Step 1: 写失败测试**(Apply 后检查 Routes 包含上述四条,且仍含 `GET /api/v1/upload` 与 `POST /api/v1/upload/batch-download`) + +Apply 需要 AuthService / DB 等。若现有 `plugin_test.go` 已有假依赖,跟它走。没有则用 `core.Provide` 三个 stub:`contracts.DBService`、`contracts.StorageService`、`contracts.AuthService`(`RequireAuthMiddleware` 返回 `gin.HandlerFunc` pass-through)。 + +- [ ] **Step 2: 跑测试确认缺路由** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/upload/ -run TestUserUploadRoutes -count=1 +``` + +Expected: FAIL missing `/my`。 + +- [ ] **Step 3: 在 `uploadGroup` 增加** + +```go +uploadGroup.GET("/my", handler.ListMyFiles) +uploadGroup.PUT("/:id", handler.UpdateMyFile) +uploadGroup.GET("/download/:id", handler.DownloadFile) +uploadGroup.POST("/download/batch", handler.BatchDownloadFiles) +``` + +原 `GET ""`、`POST /batch-download` 不动。 + +- [ ] **Step 4: 测试通过并提交** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/upload/ -count=1 +git -C /Users/ryan/Code/Go/Wavelet add backend/plugins/domain/upload +git -C /Users/ryan/Code/Go/Wavelet commit -m "feat(upload): mount existing my/update/download routes on user API" +``` + +--- + +### Task 7: Wavelet W6 PushRegistry + +**Files:** +- Create: `/Users/ryan/Code/Go/Wavelet/backend/core/contracts/push.go` +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/message_gateway/plugin.go` +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/message_gateway/service/push.go`(适配器,不改注册语义) +- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/message_gateway/plugin_registry_test.go` + +**Interfaces:** +- Produces: + +```go +package contracts + +import "context" + +type PushNotificationTemplate struct { + Title string + Content string + Level string + Ext map[string]any +} + +type PushEventMeta struct { + Key string + Name string + Description string + DefaultTemplate PushNotificationTemplate +} + +type PushRegistry interface { + RegisterBuiltInEvent(meta PushEventMeta) + SyncEvents(ctx context.Context) error +} +``` + +- [ ] **Step 1: 失败测试** — Apply 后 `Inject[PushRegistry]` 成功;`RegisterBuiltInEvent` 一个 key 后再 `GetBuiltInEvents`(或 registry 再读)能看到该 key。 + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/message_gateway/ -run TestPushRegistry -count=1 +``` + +Expected: FAIL。 + +- [ ] **Step 3: 实现** — message_gateway 内部把 `PushEventMeta` 转成现有 `model.EventMetadata` 再调 `RegisterBuiltInEvent` / `SyncEvents`。`Apply` 里 `core.Provide[contracts.PushRegistry](ctx, ...)`。禁止在契约里出现 `openflare`。 + +- [ ] **Step 4: 测试通过并提交** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/message_gateway/ -count=1 +git -C /Users/ryan/Code/Go/Wavelet add backend/core/contracts/push.go backend/plugins/domain/message_gateway +git -C /Users/ryan/Code/Go/Wavelet commit -m "feat(message_gateway): expose PushRegistry contract" +``` + +--- + +### Task 8: Wavelet W7 + W8 health 与 `/user/self` + +**Files:** +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/system/plugin.go` +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/user/plugin.go` +- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/system/health_route_test.go` +- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/user/self_route_test.go` + +**Interfaces:** +- Produces: `GET /api/health` → `response.OKNil()`;`GET /api/v1/user/self` 使用 `loginMW` + 返回当前用户(与 profile 同源,handler 调 `AuthService.GetCurrentUser` 或现有 UserInfo 等价实现) + +- [ ] **Step 1: 失败测试** + +system:Apply 后 Routes 含 `GET /api/health`,用 httptest 调 handler,body 的 `error_msg` 为空且 `data` 为 `null`。`GET /healthz` 仍在。 + +user:Apply 后 Routes 含 `GET /api/v1/user/self`,且该路由的 middleware/handler 数量 ≥ login 保护的 `/profile`(必须带鉴权)。 + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/system/ ./plugins/domain/user/ -run 'TestHealth|TestSelf' -count=1 +``` + +Expected: FAIL missing routes。 + +- [ ] **Step 3: 实现** + +```go +ctx.Router().GET("/api/health", func(c *gin.Context) { + c.JSON(http.StatusOK, response.OKNil()) +}) +ctx.Router().RegisterWhitelist("/api/health") +``` + +`/healthz` 保持 `{status: ok}`,不要改。 + +user: + +```go +userGroup.GET("/self", loginMW, Self) +``` + +`Self` 用 `contracts.AuthService.GetCurrentUser`(已有 SetAuthService)写成与 `UpdateProfile` 同文件的小 handler,JSON `response.OK(user)`。 + +- [ ] **Step 4: 测试通过并提交** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/system/ ./plugins/domain/user/ -count=1 +git -C /Users/ryan/Code/Go/Wavelet add backend/plugins/domain/system backend/plugins/domain/user +git -C /Users/ryan/Code/Go/Wavelet commit -m "feat(platform): add GET /api/health and GET /api/v1/user/self" +``` + +--- + +### Task 9: Wavelet W9 MigrationBaseline + 锁 + +**Files:** +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/core/app.go` +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/core/context.go` +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/core/app_test.go` +- Modify: `/Users/ryan/Code/Go/Wavelet/backend/cmd/app.go`(`gooseEngine.Migrate`) + +**Interfaces:** +- Produces: + +```go +func WithMigrationBaseline(fn func(*Context) error) AppOption +func (c *Context) MigrationBaseline() func(*Context) error +``` + +语义:`Prepare` 把 App 上的 fn 拷到 root Context;`gooseEngine.Migrate` 在第一次成功 `CreateVersionTable` 之后、任何 `provider.Up` 之前调用 `ctx.MigrationBaseline()`(nil 则跳过)。引擎与选项名禁止产品名。 + +- [ ] **Step 1: 失败测试(core)** + +```go +func TestWithMigrationBaselineRunsBeforeEngineMigrate(t *testing.T) { + var order []string + engine := MigrationRunner(func(ctx *Context, _ []MigrationEntry) error { + order = append(order, "engine") + if ctx.MigrationBaseline() == nil { + t.Fatal("baseline must be visible on context inside Migrate") + } + return ctx.MigrationBaseline()(ctx) + }) + app := NewApp( + WithMigrationEngine(engine), + WithMigrationBaseline(func(*Context) error { + order = append(order, "baseline") + return nil + }), + ) + // 注册一个空迁移条目的测试插件,使 RunMigrations 不会因 entries==0 直接返回 + // ... +} +``` + +若现有测试插件难写:最小插件 `Name() string` + `Apply` 里 `ctx.Migrations().Register("t", someFS)`。也可用不走完整 Prepare、只测 `WithMigrationBaseline` 把 fn 放到 `app.ctx`:调用内部 `Prepare` 后 `app.Context().MigrationBaseline() != nil`。 + +另测:fn 返回 error 时 `RunMigrations` 返回该 error,且自定义 engine 的 `Migrate` **不被调用**——注意 spec 是表创建之后、Up 之前。core 层只负责把 fn 放到 Context;**调用点在 gooseEngine**。core 测试断言 Prepare 后 Context 上能取到 fn。cmd 测试断言顺序 `create-table → baseline → up`。 + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./core/ -run TestWithMigrationBaseline -count=1 +``` + +Expected: FAIL。 + +- [ ] **Step 3: 实现** + +`App` 加字段 `migrationBaseline func(*Context) error`。`WithMigrationBaseline` 写入。`Prepare` 成功装好 ctx 后:`a.ctx.setMigrationBaseline(a.migrationBaseline)`(未导出 setter)。 + +`gooseEngine.Migrate`:解析 DB 后,用与 `CreateVersionTable` 相同的 DDL **先建一次** `w_schema_versions`(已有 IF NOT EXISTS),然后: + +```go +if fn := ctx.MigrationBaseline(); fn != nil { + if err := fn(ctx); err != nil { + return fmt.Errorf("migration baseline: %w", err) + } +} +``` + +再进入现有 per-plugin `Up` 循环。 + +锁:Postgres 在 baseline 与 Up 外包 `pg_advisory_lock(0x77617665)`(任意稳定 int64,注释写明用途),`defer pg_advisory_unlock`。SQLite 不额外加锁(单写者)。 + +- [ ] **Step 4: 测试通过并提交** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./core/ ./cmd/ -count=1 +git -C /Users/ryan/Code/Go/Wavelet add backend/core backend/cmd/app.go +git -C /Users/ryan/Code/Go/Wavelet commit -m "feat(core): add WithMigrationBaseline hook before goose Up" +``` + +- [ ] **Step 5: Wavelet 全量测试** + +```bash +cd /Users/ryan/Code/Go/Wavelet/backend && go test ./... +``` + +Expected: PASS。失败先修,再进入 OpenFlare 任务。 + +--- + +### Task 10: 同步框架进 OpenFlare-cordis + +**Files:** +- Modify: `backend/{core,pkg,plugins}`(rsync) +- Modify: `backend/OpenFlare/upstream-patches.md`(清空条目) + +**Interfaces:** +- Consumes: Task 2–9 已进 Wavelet 工作树 +- Produces: OpenFlare `backend/{core,pkg,plugins}` 与 Wavelet 对应目录零差(排除 uploads/dist/data/*.db) + +- [ ] **Step 1: 同步** + +```bash +cd /Users/ryan/Code/Go/OpenFlare-cordis +scripts/sync-upstream.sh /Users/ryan/Code/Go/Wavelet/backend +scripts/sync-upstream.sh --check /Users/ryan/Code/Go/Wavelet/backend +``` + +Expected: `--check` 无 `>f`/`"$TMP/a.log" 2>&1 || true +sqlite3 "$TMP/a.db" "SELECT name,sql FROM sqlite_master WHERE type='table' ORDER BY name;" > /tmp/schema-A.sql +sqlite3 "$TMP/a.db" "SELECT version_id FROM goose_db_version ORDER BY version_id;" > /tmp/versions-A.txt || \ +sqlite3 "$TMP/a.db" "SELECT version FROM goose_db_version ORDER BY version;" > /tmp/versions-A.txt +``` + +与 `docs/superpowers/specs/baseline/` 对比。不一致则以金标准为准更新夹具。版本最大应为 `202608090003`。 + +- [ ] **Step 2: 提交夹具(仅有 diff 时)** + +```bash +git add docs/superpowers/specs/baseline +git commit -m "docs(cordis): refresh baseline fixtures from OpenFlare v3.5.4" +``` + +无 diff 则不提交。 + +--- + +### Task 12: OpenFlare cmd 同构 + server 只挂业务 + +**Files:** +- Create: `backend/cmd/app.go`(从 Wavelet `backend/cmd/app.go` 拷贝后改) +- Modify: `backend/cmd/{api.go,all.go,worker.go,scheduler.go,root.go,banner.go}` +- Delete: `backend/cmd/bootstrap.go`、`backend/cmd/http_server.go` +- Modify: `backend/OpenFlare/plugins/server/plugin.go` +- Modify: `backend/OpenFlare/plugins/server/openflare/apiutil/middleware.go` +- Modify: `backend/OpenFlare/plugins/server/router/v1/v1.go`、`openflare/*.go`(不再调 RegisterUserRoutes/RegisterAdminRoutes) +- Modify: `config.example.yaml`(`app.redirect_trailing_slash: false` 或与 W4 字段对齐) + +**Interfaces:** +- Consumes: Wavelet `newWaveletApp` 插件列表;`core.WithMigrationBaseline`(stamp 函数可先用 `func(*core.Context) error { return nil }` 占位,Task 14 替换成真 stamp) +- Produces: `func newOpenFlareApp(profile core.Profile, opts ...core.AppOption) *core.App` + +- [ ] **Step 1: 写失败测试 `backend/cmd/app_test.go`** + +```go +func TestNewOpenFlareAppRegistersServerAndWaveletUser(t *testing.T) { + app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t))) + if err := app.Prepare(); err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, p := range app.Plugins() { // 若 Plugins() 未导出,改为检查 ctx.Router 路径 + names[p.Name()] = true + } + for _, n := range []string{"user", "auth", "cap", "admin", "server"} { + if !names[n] { + t.Errorf("missing plugin %s", n) + } + } +} +``` + +若 `App` 没有导出插件列表,改为 `Prepare` 后从 `app.Context().Router().Routes()` 断言同时存在 `GET /api/v1/user/self`(Wavelet user)与一条 OpenFlare 业务路径(例如 `GET /api/v1/d/nodes` 或现网 zone 路径——以 `docs/superpowers/specs/baseline/routes-engine.txt` 里 `of_` 控制台路径为准)。**不得**再由 server 注册 `POST /api/cap/challenge` 作为「server 自己的」——这条应来自 cap 插件。 + +另测:`GET /api/health` 存在。 + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./cmd/ -run TestNewOpenFlareApp -count=1 +``` + +Expected: FAIL(没有 `newOpenFlareApp`)。 + +- [ ] **Step 3: 实现装配根** + +1. 复制 Wavelet `cmd/app.go` 为 OpenFlare `cmd/app.go`(保留 `gooseEngine`)。 +2. `newOpenFlareApp` = `newWaveletApp` 的 Use 列表,在 domain 之后、`driver_http` 之前插入 `ofserver.New()`。 +3. `core.NewApp` 增加 `core.WithMigrationBaseline(stamp.Legacy)`——此时 stamp 可返回 nil。 +4. `driver_http.New()` **不要** `WithEngine`。 +5. `api.go`/`all.go`/`worker.go`/`scheduler.go` 改为 `runProfileApp(...)`,与 Wavelet 逐字同一调用。 +6. `root.go` 改为 Wavelet 的 hostConfig + idgen.Init;去掉 `PreRun` migrator。 +7. 删除 `bootstrap.go`、`http_server.go`。 + +`server.Apply`: + +```go +func (p *Plugin) Apply(ctx *core.Context) error { + var auth contracts.AuthService + if err := core.Using[contracts.AuthService](ctx, func(s contracts.AuthService) { auth = s }); err != nil { + return err + } + ofrouter.RegisterV1Routes(ctx.Router().Group("/api/v1"), auth) + ofrouter.RegisterRoutes(ctx.Router().Group("/api/v1"), auth) + return nil +} +``` + +删除 `RegisterUserRoutes` / `RegisterAdminRoutes` / `RegisterRootRoutes` 对平台路径的调用。`apiutil.AdminMiddlewares` 改为接收 `contracts.AuthService`: + +```go +func AdminMiddlewares(auth contracts.AuthService) []any { + return []any{auth.RequireAuthMiddleware(), auth.RequireAdminMiddleware()} +} +``` + +updater 路由改挂在 OpenFlare 自己的 admin 组上(`/api/v1/admin/update*`),中间件用 `AdminMiddlewares(auth)`,不要 import 已删的 admin handler 包。 + +`config.example.yaml` 增加 `app.redirect_trailing_slash: false`(若 W4 把 key 放在 app 下;若放在独立 http 前缀则以实际 tag 为准)。DeclareConfig 在 driver_http,OpenFlare 只要 yaml 有该键。 + +- [ ] **Step 4: 测试通过** + +```bash +cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./cmd/ -count=1 +``` + +Expected: PASS。此时 `go test ./OpenFlare/...` 会因删除平台路由引用而红,下一任务修。 + +- [ ] **Step 5: 提交** + +```bash +git add backend/cmd backend/OpenFlare/plugins/server/plugin.go backend/OpenFlare/plugins/server/openflare/apiutil backend/OpenFlare/plugins/server/router config.example.yaml +git commit -m "feat(cmd): assemble control plane via Wavelet plugins plus server" +``` + +--- + +### Task 13: 删除平台副本并改 import + +**Files:** +- Delete: `backend/OpenFlare/plugins/server/{oauth,cap,user,upload,config,health,listener}` +- Delete: `backend/OpenFlare/plugins/server/pkg/{cap,push}` +- Delete: `backend/OpenFlare/plugins/server/admin/` 下除 `updater/` 外全部 +- Delete: `backend/OpenFlare/plugins/server/infra/`(config/persistence/task/objectstore/diskcache;CH 小函数先挪走再删) +- Delete: `backend/OpenFlare/plugins/server/model` 与 `repository` 中 `w_*` 实体(users、uploads、auth_source、system_configs、push_*、task_execution、templates、schedule、access_token、analytics/user_access_log) +- Modify: 所有仍 import 上述包的 `openflare/**`、`admin/updater`、测试 +- Modify: `platform/bootstrap` — 删除或缩成空;任务注册改到 `server.Apply` 的 `ctx.Task()` / `ctx.Schedule()` +- Create: `backend/OpenFlare/plugins/server/publicconfig/provider.go` + +**Interfaces:** +- Consumes: `contracts.AuthService`、`CaptchaService`(不要在 server 再挂 cap)、`PushRegistry`、`PublicConfigProvider`、`contracts.DBService`、`StorageService`、`TaskService` +- Produces: `publicconfig.Provider` 实现 `PublicConfigProvider`,`PublicConfig` 返回 `map[string]string`(visibility=1 的 key/value,与金标准 `GetPublicConfig` 相同) + +- [ ] **Step 1: 写失败测试 `plugin_parity_test.go` 改为子集断言** + +当前测试要求精确相等且只 `server.New().Apply`。改成: + +```go +func TestPluginRoutesContainGoldenBaseline(t *testing.T) { + app := newOpenFlareAppForTest(t) // 与 cmd 测试同一装配,可用 sqlite + WithConfigValues + if err := app.Prepare(); err != nil { + t.Fatal(err) + } + got := routeSet(app.Context()) + want := loadBaseline(t) + for k := range want { + if !got[k] { + t.Errorf("missing golden route %s", k) + } + } +} +``` + +`newOpenFlareAppForTest` 必须在 `cmd` 或 `server` 测试里能引用装配函数。若 `cmd` 测试不能 import 业务,把装配函数放 `cmd`,parity 测试移到 `backend/cmd/parity_test.go`。 + +先提交测试改动并确认:**在平台副本仍在、Apply 已不挂平台路由时**,缺 `POST /api/v1/user/login` 等会失败——等 Wavelet 插件挂上后应变绿。 + +- [ ] **Step 2: 跑 parity 看缺哪些金标准路径** + +```bash +cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./cmd/ -run TestPluginRoutesContainGoldenBaseline -count=1 +``` + +记下 missing 列表。缺 login/cap/health/self/upload/my 说明 Wavelet 插件没挂上;缺 `/api/v1/d/...` 说明 OpenFlare 业务没挂上。 + +- [ ] **Step 3: 按 missing 修 Apply / 契约中间件,然后删副本** + +替换规则(全文 grep): + +| 旧 | 新 | +| :--- | :--- | +| `oauth.LoginRequired()` | `auth.RequireAuthMiddleware().(gin.HandlerFunc)` | +| `admin.LoginAdminRequired()` | `auth.RequireAdminMiddleware().(gin.HandlerFunc)` | +| `db.DB(ctx)` | `contracts.DBService.GORM().WithContext(ctx)`(经 Inject/字段) | +| `config.Config.*` | `ctx.Config().Bind` / `String`/`Bool` | +| `push.RegisterBuiltInEvent` | `PushRegistry.RegisterBuiltInEvent` | +| `repository` 的 user/upload/w_* | 禁止;改走契约或删调用 | + +`server.Apply` 增加: + +```go +core.Provide[contracts.PublicConfigProvider](ctx, publicconfig.New(ctx)) +if pr, err := core.Inject[contracts.PushRegistry](ctx); err == nil { + for _, meta := range ofevents.All() { + pr.RegisterBuiltInEvent(meta) + } + _ = pr.SyncEvents(ctx.GoContext()) +} +ctx.Migrations().Register("server", ofMigrations) +``` + +openflare 异步任务:现 `infra/task/handlers` 里属于 OF 的 handler 迁到 `openflare/tasks`,在 `Apply` 里 `ctx.Task().Register`。平台任务(user:send_email_code 等)不要再注册。 + +删目录后 `go test ./OpenFlare/...` 以编译器为准修到绿。 + +- [ ] **Step 4: 测试** + +```bash +cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./cmd/ ./OpenFlare/... +``` + +Expected: PASS。`rg 'OpenFlare/plugins/server/(oauth|cap|user|upload|config|health)\\b' backend/OpenFlare` 无匹配。 + +- [ ] **Step 5: 提交** + +```bash +git add -A backend/OpenFlare backend/cmd +git commit -m "refactor(server): drop Wavelet-duplicated domains and use contracts" +``` + +--- + +### Task 14: stamp + `of_*` 压缩迁移 + 删除关系库 migrator + +**Files:** +- Create: `backend/OpenFlare/plugins/server/stamp/stamp.go` +- Create: `backend/OpenFlare/plugins/server/stamp/stamp_test.go` +- Create: `backend/OpenFlare/plugins/server/migrations/postgres/00001_initial.sql` +- Create: `backend/OpenFlare/plugins/server/migrations/sqlite/00001_initial.sql` +- Modify: `backend/cmd/app.go`(`WithMigrationBaseline(stamp.Legacy)`) +- Create: `backend/OpenFlare/plugins/server/chmigrate/chmigrate.go`(从旧 `migrator.MigrateClickHouse` 抽出,只处理 `of_node_*` 文件) +- Delete: `backend/OpenFlare/plugins/server/infra/persistence/migrator/`(关系库部分;CH sql 挪到 `chmigrate/goose/clickhouse`,去掉 `create_user_access_logs`) + +**Interfaces:** +- Consumes: `core.Context` + `contracts.DBService` +- Produces: `func Legacy(ctx *core.Context) error` + +`Legacy` 行为: + +1. Inject `DBService`;拿 `sql.DB`。 +2. 探测 `goose_db_version` 是否存在。不存在则 return nil(新装)。 +3. `SELECT COALESCE(MAX(version_id),0)` 或 goose 默认列名 `version`(金标准用 goose 默认表:列是 `version_id` 还是 `version` 以 Task 11 导出为准,代码按实表扫描 `information_schema`/`pragma`)。 +4. 插入 `w_schema_versions (plugin_id, version_id)`:`('openflare/legacy', 0)`、`('openflare/legacy', maxVer)`、`('server', 1)`,全部 `ON CONFLICT DO NOTHING`。 +5. 若 `maxVer >= 202607120002 && maxVer < 202607130001`,调用 `zone.ImportLegacyTx`。金标准 `202608090003` 跳过。 +6. 幂等:再调一次零 INSERT。 + +- [ ] **Step 1: 写失败测试(用 `t.TempDir()` sqlite)** + +建一张假 `goose_db_version`(版本 `202608090003`)和空的 `w_schema_versions`(用与 Wavelet 相同的 DDL)。调用 `Legacy`。断言三行 stamp 存在;再调一次行数不变。另测:无 `goose_db_version` 时不写 `openflare/legacy`。 + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./OpenFlare/plugins/server/stamp/ -count=1 +``` + +Expected: FAIL。 + +- [ ] **Step 3: 实现 stamp;cmd 传入 `WithMigrationBaseline(stamp.Legacy)`。** + +生成 `of_*` initial:从 Task 11 的 `$TMP/a.db`(或金标准跑出来的库)抽取: + +```bash +sqlite3 "$TMP/a.db" "SELECT sql FROM sqlite_master WHERE type='table' AND name LIKE 'of_%' ORDER BY name;" +``` + +写成 sqlite `00001_initial.sql`(每条 `CREATE TABLE IF NOT EXISTS`,加 `-- +goose Up`)。postgres 目录用同一批表,类型按现有 OF postgres 历史链的最终形态(对照金标准若只有 sqlite 夹具,postgres 从 `migrator/goose/postgres` 里 of_* 的最终 CREATE/ALTER 手工收敛成一张 IF NOT EXISTS,**禁止 DROP**)。双方言版本号都是 `00001`。 + +CH:复制 `goose/clickhouse` 到 `chmigrate`,删除用户访问日志那个文件。`server.Apply` 在 API profile 下调用 `chmigrate.Up`(保留旧错误日志行为)。 + +删除 `migrator.Migrate` 与 Cobra PreRun。76 条 sql **不再 embed**。可留在 git 里但不要 `go:embed`。 + +- [ ] **Step 4: 测试通过** + +```bash +cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./OpenFlare/plugins/server/stamp/ ./cmd/ -count=1 +``` + +Expected: PASS。 + +- [ ] **Step 5: 提交** + +```bash +git add backend/OpenFlare/plugins/server/stamp backend/OpenFlare/plugins/server/migrations backend/OpenFlare/plugins/server/chmigrate backend/cmd +git commit -m "feat(server): stamp legacy goose versions and own of_* migrations only" +``` + +--- + +### Task 15: L3 升级测试(金标准库 → Cordis) + +**Files:** +- Create: `backend/cmd/upgrade_from_golden_test.go` + +**Interfaces:** +- Consumes: 金标准二进制(只读调用)、`newOpenFlareApp` +- Produces: 自动化 A→C 升级断言 + +- [ ] **Step 1: 写失败测试** + +测试内: + +1. `t.TempDir()`。 +2. `exec.Command` 构建金标准:`go build -o $tmp/gold /Users/ryan/Code/Go/OpenFlare`(module 路径按金标准 `main.go`)。 +3. 设 `OF_DB_PATH`/`CONFIG` 指向 temp sqlite,跑金标准 `api` 数秒直到 `goose` 成功(看日志或轮询表 `of_nodes` 存在)。超时失败。 +4. 抽样 `INSERT` 一行可识别数据到 `of_zones` 或读 seed 行数。 +5. 对 **同一 db 文件** `newOpenFlareApp(ProfileAPI)` + `Prepare()`(不要 `Run` 以免占端口;若 Prepare 含迁移即足够)。 +6. 断言:`w_schema_versions` 有 `openflare/legacy` 与 `server`;`goose_db_version` max 仍为 `202608090003`;抽样行仍在;`of_*` 表列不丢。 +7. 第二次 `Prepare()` 不增加 `openflare/legacy` 行数。 + +Postgres:若 CI/本机有 `DATABASE`,用 `t.Skip` 以外的短测试连本机 PG;无 PG 则 sqlite 必跑,PG 用 `go test -tags pgupgrade` 可选。规格要求 Postgres 必做——测试读 `TEST_PG_DSN`,未设则 `t.Fatal` 在 `TestUpgradePostgresFromGolden` **除非** 文档写明开发机需提供。计划要求:sqlite 子测试无条件跑;Postgres 子测试在 `TEST_PG_DSN` 为空时 Skip,并在 Task 16 的人工清单里用 docker postgres 跑一遍(下面 Step 4)。 + +- [ ] **Step 2: 跑测试确认失败(stamp 未接好会红)** + +```bash +cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./cmd/ -run TestUpgradeFromGolden -count=1 -timeout 120s +``` + +- [ ] **Step 3: 修到 PASS** + +- [ ] **Step 4: 本地 Postgres** + +```bash +docker run -d --name of-pg-test -e POSTGRES_PASSWORD=test -p 5432:5432 postgres:16 +TEST_PG_DSN='postgres://postgres:test@127.0.0.1:5432/postgres?sslmode=disable' \ + go test ./cmd/ -run TestUpgradePostgresFromGolden -count=1 -timeout 180s +``` + +Expected: PASS。 + +- [ ] **Step 5: 提交** + +```bash +git add backend/cmd/upgrade_from_golden_test.go +git commit -m "test(cmd): upgrade sqlite/postgres from OpenFlare v3.5.4 golden" +``` + +--- + +### Task 16: L2 全量门禁 + +**Files:** 无新功能文件;修测试/swagger 直到绿 + +- [ ] **Step 1: Cordis 全测** + +```bash +cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./... +``` + +Expected: PASS。修到绿。 + +- [ ] **Step 2: swagger 超集** + +```bash +cd /Users/ryan/Code/Go/OpenFlare-cordis && make swagger +python3 - <<'PY' +import json +gold=json.load(open("/Users/ryan/Code/Go/OpenFlare/docs/swagger.json")) +new=json.load(open("docs/swagger.json")) +def ops(d): + s=set() + for p,v in d.get("paths",{}).items(): + for m in v: + if m in ("get","post","put","delete","patch","head","options"): + s.add(f"{m.upper()} {p}") + return s +missing=sorted(ops(gold)-ops(new)) +if missing: + raise SystemExit("missing: "+"\n".join(missing)) +print("ok", len(ops(gold)), "golden ops present; extra", len(ops(new)-ops(gold))) +PY +``` + +Expected: 无 missing。`GET /api/v1/config/public` 的成功响应仍是扁平 map(查 swagger schema / 手写测试打 handler)。 + +- [ ] **Step 3: 前端零 diff** + +```bash +git -C /Users/ryan/Code/Go/OpenFlare-cordis diff --name-only -- frontend/ +``` + +Expected: 空。 + +- [ ] **Step 4: 金标准树未被修改** + +```bash +git -C /Users/ryan/Code/Go/OpenFlare status --short +``` + +Expected: 空。 + +- [ ] **Step 5: 提交本任务产生的测试/swagger 修复**(若有) + +--- + +### Task 17: git merge 接入 Wavelet 上游 + +**Files:** +- Modify: `.gitattributes` +- Delete: `scripts/sync-upstream.sh`(接线成功后) +- Modify: `backend/OpenFlare/README.md`(同步方式改为 `git merge wavelet/main`) + +**Interfaces:** +- Consumes: Task 16 全绿;Wavelet 含 W1–W9 的 main/工作树 +- Produces: `git merge-base HEAD wavelet/main` 非空 + +- [ ] **Step 1: 写 `.gitattributes` 追加** + +``` +backend/OpenFlare/** merge=ours +frontend/** merge=ours +docs/changelog/** merge=ours +docs/superpowers/** merge=ours +``` + +先提交这一文件。 + +```bash +git add .gitattributes +git commit -m "chore(git): keep OpenFlare-owned paths on merge" +``` + +- [ ] **Step 2: 添加 remote 并第一次 merge** + +```bash +git remote add wavelet /Users/ryan/Code/Go/Wavelet 2>/dev/null || git remote set-url wavelet /Users/ryan/Code/Go/Wavelet +git fetch wavelet +git merge --allow-unrelated-histories wavelet/main +``` + +冲突:`backend/{core,pkg,plugins}` 以 Wavelet 为准;`frontend/`、`backend/OpenFlare/`、`docs/superpowers/`、`docs/changelog/` 保持 OpenFlare(`git checkout --ours`)。`backend/cmd` 三路解决:保留 `server.New()`、`WithMigrationBaseline`、agent/relay/flared。不要 rebase。 + +- [ ] **Step 3: 验证历史** + +```bash +git merge-base HEAD wavelet/main # 非空 +git log --first-parent -5 # 仍是 OpenFlare 提交叙事 +git diff --name-only -- frontend/ # 相对 merge 前应无意外 +cd backend && go test ./cmd/ ./OpenFlare/plugins/server/stamp/ -count=1 +``` + +- [ ] **Step 4: 删除 rsync 脚本并提交 merge** + +```bash +git rm scripts/sync-upstream.sh +git add backend +git commit --no-edit # 若 merge 已产生 commit 则再 chore 删脚本 +``` + +若 Step 2 的 merge 已提交,则: + +```bash +git rm scripts/sync-upstream.sh +git commit -m "chore(cordis): drop rsync sync after git upstream is connected" +``` + +--- + +## 自检(对照 spec) + +| Spec | Task | +| :--- | :--- | +| D1 契约冻结 / 不改前端 | 16 Step 2–3 | +| D2 删全部平台副本 | 13 | +| D3 stamp | 14、15 | +| D4 health/self/cap 在 Wavelet | 3、8;12 禁止 server 再挂 | +| D5 git merge | 17 | +| D6 不承担同类职责 | 12–14 | +| D7 金标准只读 | 1、16 Step 4 | +| D8 先对齐再 merge | 16 在 17 前 | +| W1–W9 | 2–9 | +| cmd 同构 | 12 | +| L1/L2/L3 | 1、16、15 | +| 不做 pow / 第二套 migrator / 改 agent CLI | 全局约束 | + +无 TBD。`CaptchaService` / `PublicConfigProvider` / `PushRegistry` / `WithMigrationBaseline` / `stamp.Legacy` 名称前后一致。