package gateway import ( "context" "errors" "net/http" "strconv" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" ) // 本文件承载记忆整理**应用路径**的管理面(切片 TIDY-1;design/23 §7/§8/§11)。 // 动因是主人的实测口径:整理只产候选、两天一条没应用(77 条全 pending),而现有 // `POST …/tidy/dry-run` 是**只读试算**,改不了任何东西。 // // 端点分工(DTO 在 admin_memory_tidy_apply_dto.go,手动真跑在 // admin_memory_tidy_run.go —— 拆开只为守 AGENTS §10 的文件行数上限): // // GET /api/v1/admin/memories/candidates 候选列表(含 id/runId/证据) // POST /api/v1/admin/memories/candidates/{id}/apply 按候选 id 应用(写记忆) // POST /api/v1/admin/memories/candidates/apply?runId=&limit= 批量应用(按 run / 全部待定) // POST /api/v1/admin/memories/candidates/{id}/revert 回滚一条已应用候选 // POST /api/v1/admin/memories/tidy/run 手动**真跑**一轮(写候选) // // 口径: // // 1. **门同族**:全部走 `writeAuth`(owner|admin),与同族的 `GET /admin/memories`、 // `POST …/tidy/dry-run`、`POST …/memories/{id}/revert` 同档 —— 候选正文含隐私, // design/23 §11 的既有取向是「含隐私正文的读侧不给 viewer」。⚠️ §11 表格把 // candidates 写回滚标为 owner-only:那只需把 routes.go 里对应行的 `writeAuth` // 换成 `ownerAuth`(一行一处,本文件不用改)。 // 2. **写动作落审计**:动作名 `memory.tidy.apply` / `memory.tidy.revert`(design/23 // §8 的 `memory.tidy.*` 族),who 来自请求身份;审计只带计数/动作,**不带记忆正文**。 // `POST …/tidy/run` 的审计由**领域侧**(TidyService 的 Audit 出口,design §8 的 // `memory.tidy.run`,result 里带 trigger=manual + run_id + 计数)写,本文件不重复记 // —— 一次手动点击只该有一条 memory.tidy.run。 // 3. **诚实失败**:未接线 ⇒ 500 `tidy_unavailable`;候选不存在 ⇒ 404; // runId 查不到 ⇒ 404 `tidy_run_not_found`;配置关闭 ⇒ 409 `tidy_disabled`; // 超时 ⇒ 503 `tidy_timeout`。**绝不**把「没做」回成一份空结果。 // 4. **不写第二套语义**:应用/回滚的真实动作全在 `memory.Store` 里(候选状态条件更新 // 是幂等闸),端点只做鉴权、取参、投影与审计。 // 5. **不加任何上限**:批量上限由领域侧(memory.DefaultTidyApplyBatchLimit 等代码 // 默认值)决定,端点只解析 `limit` 并交给领域侧钳位(`.env` 冻结,不新增键)。 // MemoryTidyRunner 是记忆整理「真跑」的注入出口(切片 TIDY-1)。组合根注入的 // 就是低峰心跳跑的那个 `*memory.TidyService`(与试算同一份配置、同一套规则)。 // // 它与 `MemoryTidyDryRunner` 分开两个接口是**刻意**的:试算只读、真跑写候选 + 留痕, // 两者可分别接线;未实现本接口的替身(例如只做试算的测试桩)得到的是诚实的 // 「真跑未接线」,而不是一条静默成功的假回执。 type MemoryTidyRunner interface { // ManualRun 立即跑一轮**真跑**(写候选 + 写运行留痕 + 审计)。配置关闭时 // 返回 memory.ErrTidyDisabled。 ManualRun(ctx context.Context) (memory.TidyStats, error) } // tidyApplyStore 是整理候选写面的窄视图,由 `*memory.Store` 实现。未实现它的 // `Deps.Memories` 让本组端点回 500 `tidy_unavailable`(「没接线」与「没有候选」 // 必须能分辨,与同族的 ingestReviewStore 同一手法)。 type tidyApplyStore interface { ListTidyCandidates(ctx context.Context, status string, limit int) ([]memory.TidyCandidate, error) ApplyTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error) ApplyTidyCandidates(ctx context.Context, runID string, opts memory.TidyApplyOptions) (memory.TidyApplyBatch, error) RevertTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error) } const ( // tidyUnavailableCode 是「整理写面未接线」的线上错误码(与试算组的 // tidy_unavailable 同名同义:它们背后是同一件事)。 tidyUnavailableCode = "tidy_unavailable" // tidyApplyAction 是应用动作的审计动作名(design/23 §8)。 tidyApplyAction = "memory.tidy.apply" // tidyRevertAction 是候选回滚动作的审计动作名。它与 D3 的 // `memory.review.revert`(按**记忆 id**撤销一次软合并)**不同**:本条按**候选 id** // 回滚,并把候选退回 pending,因此不复用。 tidyRevertAction = "memory.tidy.revert" // tidyRunTimeout 是手动真跑的墙钟上限。真跑要写候选 + 留痕,比试算多几次写入; // 上限存在的意义是「不让卡住的请求占着连接」,超时**如实报错**(503 tidy_timeout)。 tidyRunTimeout = 5 * time.Minute ) // adminTidyCandidatesList 处理 GET /api/v1/admin/memories/candidates。 // 只读、不审计(与同族只读管理端点同一档)。 func (s *Server) adminTidyCandidatesList(w http.ResponseWriter, r *http.Request) { store := s.tidyCandidates() if store == nil { writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线")) return } // limit 复用同族读侧那一个解析器(默认 100 / 钳到 500 / 非法 400)—— // 本切片不复制第三份 limit 解析(AGENTS §10「三次复制必须抽取」,此处是第二次)。 limit, ok := parseMemoryReviewLimit(r) if !ok { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit")) return } cands, err := store.ListTidyCandidates(r.Context(), r.URL.Query().Get("status"), limit) if err != nil { s.log.Error("list tidy candidates", "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_candidates_failed", "读取整理候选失败")) return } out := AdminTidyCandidatesResponse{ Candidates: make([]AdminTidyCandidate, 0, len(cands)), Count: len(cands), } for _, c := range cands { out.Candidates = append(out.Candidates, adminTidyCandidate(c)) } writeJSON(w, http.StatusOK, out) } // adminTidyCandidateApply 处理 POST /api/v1/admin/memories/candidates/{id}/apply: // 应用**一条**候选(唯一真正改记忆的粒度之一)。三种结果都如实作答: // 不存在 ⇒ 404;已决定/不可应用 ⇒ 200 + `status:skipped`(幂等不是错误); // 真的改了 ⇒ 200 + `status:applied` + `affectedIds`。 func (s *Server) adminTidyCandidateApply(w http.ResponseWriter, r *http.Request) { store := s.tidyCandidates() if store == nil { s.auditClaims(r, tidyApplyAction, "error") writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线")) return } id := r.PathValue("id") if id == "" { s.auditClaims(r, tidyApplyAction, "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "candidate id is required")) return } res, err := store.ApplyTidyCandidate(r.Context(), id, memory.TidyApplyOptions{Actor: s.tidyActor(r)}) if err != nil { s.auditClaims(r, tidyApplyAction, "error") if errors.Is(err, memory.ErrTidyCandidateNotFound) { writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "整理候选 %q 不存在", id)) return } s.log.Error("apply tidy candidate", "err", err, "candidate", id) writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_apply_failed", "应用整理候选失败")) return } s.auditClaims(r, tidyApplyAction, tidyApplyAuditResult(res)) writeJSON(w, http.StatusOK, adminTidyApplyResult(res)) } // adminTidyCandidatesApplyBatch 处理 POST /api/v1/admin/memories/candidates/apply: // **批量**应用。`runId` 给出时只应用该轮产出的候选(按 run);省略时应用全部待定 // (「全部待定应用」这一档)。`limit` 省略 ⇒ 领域侧默认值。 // // 逐条结果都在 `results` 里(失败隔离),超限如实回报 `truncated` + `total`。 func (s *Server) adminTidyCandidatesApplyBatch(w http.ResponseWriter, r *http.Request) { store := s.tidyCandidates() if store == nil { s.auditClaims(r, tidyApplyAction, "error") writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线")) return } limit, ok := parseTidyApplyLimit(r) if !ok { s.auditClaims(r, tidyApplyAction, "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit")) return } runID := r.URL.Query().Get("runId") batch, err := store.ApplyTidyCandidates(r.Context(), runID, memory.TidyApplyOptions{ Actor: s.tidyActor(r), Limit: limit, }) if err != nil { s.auditClaims(r, tidyApplyAction, "error") if errors.Is(err, memory.ErrTidyRunRecordNotFound) { writeError(w, apperr.Wrap(apperr.ErrNotFound, "tidy_run_not_found", "整理运行 %q 不存在", runID)) return } s.log.Error("apply tidy candidates", "err", err, "run_id", runID) writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_apply_failed", "批量应用整理候选失败")) return } s.auditClaims(r, tidyApplyAction, tidyBatchAuditResult(batch)) writeJSON(w, http.StatusOK, adminTidyApplyBatch(batch)) } // adminTidyCandidateRevert 处理 POST /api/v1/admin/memories/candidates/{id}/revert: // 回滚一条已应用候选(记忆行复原 + 候选退回 pending,行永不物理删除,design §8)。 func (s *Server) adminTidyCandidateRevert(w http.ResponseWriter, r *http.Request) { store := s.tidyCandidates() if store == nil { s.auditClaims(r, tidyRevertAction, "error") writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线")) return } id := r.PathValue("id") if id == "" { s.auditClaims(r, tidyRevertAction, "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "candidate id is required")) return } res, err := store.RevertTidyCandidate(r.Context(), id, memory.TidyApplyOptions{Actor: s.tidyActor(r)}) if err != nil { s.auditClaims(r, tidyRevertAction, "error") if errors.Is(err, memory.ErrTidyCandidateNotFound) { writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "整理候选 %q 不存在", id)) return } s.log.Error("revert tidy candidate", "err", err, "candidate", id) writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_revert_failed", "回滚整理候选失败")) return } s.auditClaims(r, tidyRevertAction, tidyApplyAuditResult(res)) writeJSON(w, http.StatusOK, adminTidyApplyResult(res)) } // tidyCandidates 返回当前接入的整理候选写面;`Memories` 未实现该视图时为 nil。 func (s *Server) tidyCandidates() tidyApplyStore { if s.memories == nil { return nil } store, ok := s.memories.(tidyApplyStore) if !ok { return nil } return store } // tidyActor 返回决定者的审计标签(落 memory_tidy_candidates.decided_by)。 // 与 auditClaimsAccount 的 who 同口径,使面板「谁批的」与审计链能对上。 func (s *Server) tidyActor(r *http.Request) string { if claims, ok := auth.ClaimsFromContext(r.Context()); ok { return claims.Sub + ":" + string(claims.Role) } return "anonymous" } // parseTidyApplyLimit 解析批量的 limit:缺省/空 ⇒ 0(交给领域侧默认值), // 非整数或负数 ⇒ ok=false(调用方回 400)。上限由**领域侧**钳位,端点不复制那个常量。 func parseTidyApplyLimit(r *http.Request) (int, bool) { raw := r.URL.Query().Get("limit") if raw == "" { return 0, true } v, err := strconv.Atoi(raw) if err != nil || v < 0 { return 0, false } return v, true } // adminTidyCandidate 投影一条落库候选。SourceIDs 兜成非 null 数组; // 时间为 UTC RFC3339(与同族管理 DTO 同一口径)。 func adminTidyCandidate(c memory.TidyCandidate) AdminTidyCandidate { ids := c.SourceIDs if ids == nil { ids = []string{} } return AdminTidyCandidate{ ID: c.ID, Action: string(c.Action), Status: c.Status, SourceIDs: ids, TargetID: c.TargetID, PersonaID: c.PersonaID, Scope: string(c.Scope), ProjectID: c.ProjectID, Kind: string(c.Kind), ProposedContent: c.ProposedContent, MergedContent: c.MergedContent, Reason: c.Reason, RuleVersion: c.RuleVersion, RunID: c.RunID, ScoreMin: c.ScoreMin, ScoreMax: c.ScoreMax, CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339), Evidence: c.Evidence, } } // adminTidyApplyResult 投影一条应用/回滚结果。 func adminTidyApplyResult(res memory.TidyApplyResult) AdminTidyApplyResult { ids := res.AffectedIDs if ids == nil { ids = []string{} } return AdminTidyApplyResult{ CandidateID: res.CandidateID, Action: string(res.Action), Status: string(res.Status), Reason: res.Reason, TargetID: res.TargetID, AffectedIDs: ids, } } // adminTidyApplyBatch 投影批量回执(results 恒为非 null 数组)。 func adminTidyApplyBatch(batch memory.TidyApplyBatch) AdminTidyApplyBatchResponse { out := AdminTidyApplyBatchResponse{ RunID: batch.RunID, Limit: batch.Limit, Total: batch.Total, Truncated: batch.Truncated, Applied: batch.Applied, Skipped: batch.Skipped, Failed: batch.Failed, Merged: batch.Merged, Archived: batch.Archived, Reclassified: batch.Reclassified, Results: make([]AdminTidyApplyResult, 0, len(batch.Results)), } for _, res := range batch.Results { out.Results = append(out.Results, adminTidyApplyResult(res)) } return out } // tidyApplyAuditResult 渲染单条应用/回滚的审计 result。它**以 ok 开头**(与 // 同族「result=ok/error」的既有口径一致,面板按前缀就能分桶),其后才挂动作与 // 结果类别、受影响行数 —— 绝不带记忆正文或候选正文(design §8)。 func tidyApplyAuditResult(res memory.TidyApplyResult) string { return "ok action=" + string(res.Action) + " status=" + string(res.Status) + " reason=" + res.Reason + " affected=" + strconv.Itoa(len(res.AffectedIDs)) } // tidyBatchAuditResult 渲染批量应用的审计 result(同样以 ok 开头):带真总数与 // 超限标记,因此审计能回答「这一轮到底批了多少、有没有被上限截住」。 func tidyBatchAuditResult(batch memory.TidyApplyBatch) string { return "ok run_id=" + batch.RunID + " limit=" + strconv.Itoa(batch.Limit) + " total=" + strconv.Itoa(batch.Total) + " truncated=" + strconv.FormatBool(batch.Truncated) + " applied=" + strconv.Itoa(batch.Applied) + " skipped=" + strconv.Itoa(batch.Skipped) + " failed=" + strconv.Itoa(batch.Failed) }