Compare commits

...
5 Commits
Author SHA1 Message Date
weichen 528e3a4c1f Merge pull request 'Require the caller's conversation when creating a surface' (#239) from fix/surface-conversation-required into main
governance / governance (push) Successful in 13s
ci / vet-and-test (push) Successful in 1m13s
2026-10-21 15:05:03 +00:00
agent-exec-transportandCursor 954e744d6d Require the caller's conversation when creating a surface
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m14s
POST /api/surfaces with an empty surfaceId now requires a conversationId
that belongs to the caller: empty returns 400, missing or another owner's
conversation returns 404. Revisions keep the previous behavior. Without
this check a direct API caller could create a project-less surface and
skip the project lifecycle gate on actions.

Why: an empty conversationId produced surfaces with no project ownership.
Evidence: agent-exec unit backend-surface-submit-conversation-required (pi, attempt 1 after authorized rerun, terminal=success); go test ./internal/server/... ./internal/surfacesvc/... .
Impact: HTTP create-surface validation only.
Refs: Dev task fix/svc-superadmin-envfile-surface-conv.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 15:02:15 +00:00
weichen 01f4c47a2b Merge pull request 'Backfill and archive three merged task cards (#212, #226, #237)' (#238) from fix/close-merged-task-cards into main
governance / governance (push) Successful in 13s
ci / vet-and-test (push) Successful in 1m14s
2026-10-21 12:03:12 +00:00
weichen a89a04f803 Backfill and archive three merged task cards
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m17s
Why: the three cards whose PRs are merged (Backend #212, #226, #237) were
still sitting at .task/ top level, which keeps the main checkout reporting
card debt. Only the status line and the merge fact were added; the historical
record inside each card is untouched.

Evidence: Gitea API for PRs 212/226/237 all report state=closed merged=true
(merge commits 56a7f9f440, 508288fbed, f084d10aa5); triage receipt
/tmp/be-triage-cardclose-20261021.json (verdict NEW).

Impact: .task bookkeeping only; no code change, no submodule pin change.
Refs: fix/close-merged-task-cards
2026-10-21 11:42:28 +00:00
weichen 57421f6683 chore(task): start fix/close-merged-task-cards 2026-10-21 11:42:07 +00:00
6 changed files with 161 additions and 8 deletions
@@ -1,7 +1,7 @@
# feature/regulations-kb-ext20 任务卡
任务主记录:本文件随 PR 描述维护。
状态:已完成(合入前闭合);owner:weichen。PR:https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/212
状态:已完成(合入前闭合);owner:weichen。已合入 (merge 56a7f9f440);本卡随合入闭合,归档时移入 .task/archive/。
## 基线与授权
@@ -1,7 +1,7 @@
# fix/ci-pg-flaky-tests 任务卡
任务主记录:本文件随 Backend PR 描述维护;阻塞来源为 Dev PR #651。
状态:已完成(合入前闭合);owner:weichen
状态:已完成(合入前闭合);owner:weichen。已合入 (PR #226,merge 508288fbed);本卡随合入闭合,归档时移入 .task/archive/。
## 基线与授权
@@ -1,7 +1,7 @@
# fix/resolve-hitl-run-status-ordering 任务卡
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
状态:进行中;owner:weichen
状态:已完成(合入前闭合)。owner:weichen。已合入 PR #237(merge f084d10aa5);本卡随合入闭合,归档时移入 .task/archive/。
## 基线与授权
+68
View File
@@ -0,0 +1,68 @@
# fix/close-merged-task-cards 任务卡
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
状态:进行中;owner:weichen
## 基线与授权
- 权威仓:ssh://git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Backend.git;root_target:main;immediate_target:main
- branch_kind:standalone;initiative_id:
- R0(initial_main_sha):f084d10aa5ea2e4ad2ab4e6eb1268d3e83c6e991;工作区:/home/weichen/projects/Agent-System-Backend-wt-fix-close-merged-task-cards(worktree);分支:fix/close-merged-task-cards
- 父任务信息(仅子任务填写):无
- accepting_requirements:false
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=no accepting initiative;triage 收据=/tmp/be-triage-cardclose-20261021.json;receipt_sha256=155aba399116f6e47f74df3198f749947a20d5974a2f130eb671cd55ce9cad73;requirement_sha256=a40da80ed0cd7d0bfa59ad4998f17307131c5149c3f9ee3c0575a7a654b1f2de
- 授权与交付终点:把三张已合入但仍留在 `.task/` 顶层的任务卡回填合入事实并移入 `.task/archive/`,消除主检出的卡片欠账。终点为 PR 合入 main。不得改任何生产代码、不得改卡内历史记录内容(只补合入事实与状态)、不得动他人工作区。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标:Backend 主检出的 `.task/` 顶层留有三张 PR 已合并但未归档的任务卡,构成 precheck 的卡片欠账:`feature-regulations-kb-ext20`(PR #212,merge `56a7f9f440`)、`fix-ci-pg-flaky-tests`(PR #226,merge `508288fbed`)、`fix/resolve-hitl-run-status-ordering`(PR #237,merge `f084d10aa5`)。三张卡的合入事实来自 Gitea API(`state=closed, merged=true`)。本任务只做两件事:把状态行补成「已完成(合入前闭合)+ 已合入 PR/merge」并附归档注记,然后把三张卡 `git mv` 到 `.task/archive/`。
非目标:不改任何生产代码或测试;不改卡内的需求、实施、验证、Review 等历史记录,只补状态行与合入事实;不动 `wt-pal-merge` 工作区(他人 PR #237 之外的进行中任务);不改任何子模块 pin。
## Agent 实施收据
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
## 复用材料(task-start 自动检索结果摘录)
- 相关近期提交:<git log -- <paths> 命中;无则写无>。
- 相关经验条目:<检索命中;无则写无>。
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | <R0> | <HEAD> | <日期时间> | <一致/已同步> |
| 验收前 | <填写> | <填写> | <填写> | <填写> |
字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。
## 交付调度(有多个就绪任务时)
- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。
- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。
- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。
- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。
定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
## 结果与验证(完成后填写)
- 结果:<交付了什么;未发生写无>。
- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。
- 影响与剩余项:<影响面、遗留风险、后续任务>。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
## 回收(task-finish)
- 合入核对:<分支已包含于目标分支的核对输出>。
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
+12
View File
@@ -210,6 +210,18 @@ func (s *Server) submitSurface(w http.ResponseWriter, r *http.Request) {
http.Error(w, "pluginId and spec required", http.StatusBadRequest)
return
}
// 新建页面必须落在调用者自己的会话上:会话表反查项目归属是唯一来源,
// 空或越权会话会让页面绕过项目生命周期检查。缺失与他人会话同回 404。
if body.SurfaceID == "" {
if body.ConversationID == "" {
http.Error(w, "conversationId required", http.StatusBadRequest)
return
}
if _, err := s.st.GetConversation(s.owner(r), body.ConversationID); err != nil {
http.Error(w, "conversation not found", http.StatusNotFound)
return
}
}
snap, err := s.Surfaces.SubmitAccountChecked(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{
SurfaceID: core.ID(body.SurfaceID), ConversationID: core.ID(body.ConversationID),
BaseRevision: body.BaseRevision, ObservedEpoch: body.ObservedEpoch,
+78 -5
View File
@@ -12,9 +12,26 @@ import (
"net/http/httptest"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
core "agent-system-core"
)
// surfaceConversationFixture 建调用者自有项目与会话:页面新建必须挂在真实
// 会话上(项目归属由会话表反查),占位 ID 走不通端点。
func surfaceConversationFixture(t *testing.T, st *store.Store, owner string) core.Conversation {
t.Helper()
project, err := st.CreateProject(owner, "surface fixture")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation(owner, project.ID, "surface fixture", "")
if err != nil {
t.Fatal(err)
}
return conv
}
func TestSurfaceDirectAPIGuards(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
@@ -24,6 +41,7 @@ func TestSurfaceDirectAPIGuards(t *testing.T) {
}
svc := surfacesvc.New(st, nil)
srv.Surfaces = svc
convID := surfaceConversationFixture(t, st, "alice").ID
post := func(path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
@@ -43,7 +61,7 @@ func TestSurfaceDirectAPIGuards(t *testing.T) {
spec := `"spec":{"title":"t","nodes":[{"type":"markdown","id":"n1","markdown":"x"}]}`
// 未启用:提交被拒(403),提示能力存在但插件未启用
if rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"c1",`+spec+`}`); rec.Code != http.StatusForbidden {
if rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"`+convID+`",`+spec+`}`); rec.Code != http.StatusForbidden {
t.Fatalf("disabled submit should 403, got %d: %s", rec.Code, rec.Body.String())
}
// 启用(activation 端点走服务层,epoch=1)
@@ -51,7 +69,7 @@ func TestSurfaceDirectAPIGuards(t *testing.T) {
t.Fatalf("enable failed: %d %s", rec.Code, rec.Body.String())
}
// 提交成功 → 201
rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"c1",`+spec+`}`)
rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"`+convID+`",`+spec+`}`)
if rec.Code != http.StatusCreated {
t.Fatalf("enabled submit should 201, got %d: %s", rec.Code, rec.Body.String())
}
@@ -67,14 +85,14 @@ func TestSurfaceDirectAPIGuards(t *testing.T) {
if rec := get("/api/surfaces/" + snap.Identity.SurfaceID); rec.Code != http.StatusOK {
t.Fatalf("get failed: %d", rec.Code)
}
if rec := get("/api/conversations/c1/surfaces"); rec.Code != http.StatusOK {
if rec := get("/api/conversations/" + convID + "/surfaces"); rec.Code != http.StatusOK {
t.Fatalf("list failed: %d", rec.Code)
}
// 禁用后:提交 403;读取仍 200(历史保留)
if rec := post("/api/plugins/genui/activation", `{"enabled":false}`); rec.Code != http.StatusOK {
t.Fatalf("disable failed: %d", rec.Code)
}
if rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"c1",`+spec+`}`); rec.Code != http.StatusForbidden {
if rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"`+convID+`",`+spec+`}`); rec.Code != http.StatusForbidden {
t.Fatalf("post-disable submit should 403, got %d", rec.Code)
}
if rec := get("/api/surfaces/" + snap.Identity.SurfaceID); rec.Code != http.StatusOK {
@@ -100,11 +118,12 @@ func TestSurfaceActionEndpoint(t *testing.T) {
return surfacesvc.ActionOutcome{State: "committed", Receipt: map[string]any{"ok": true}}, nil
})
srv.Surfaces = svc
convID := surfaceConversationFixture(t, st, "alice").ID
ctx := context.Background()
svc.SetActivation(ctx, "alice", "generative-ui", true)
spec := `"spec":{"title":"表单页","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"提交"}]}]}`
post := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/surfaces", bytes.NewBufferString(`{"pluginId":"generative-ui","conversationId":"c1",`+spec+`}`))
req := httptest.NewRequest(http.MethodPost, "/api/surfaces", bytes.NewBufferString(`{"pluginId":"generative-ui","conversationId":"`+convID+`",`+spec+`}`))
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
@@ -147,3 +166,57 @@ func TestSurfaceActionEndpoint(t *testing.T) {
}
_ = fmt.Sprint
}
// 新建页面必须绑定调用者自有会话:会话表是项目归属的唯一来源,空会话会让页面
// 绕过项目生命周期检查(U6)。空 → 400;缺失与他人会话同回 404(不泄露存在性);
// 自有会话 → 201 且身份带上该会话的项目。
func TestSurfaceSubmitConversationRequired(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
svc := surfacesvc.New(st, nil)
srv.Surfaces = svc
if _, err := svc.SetActivation(context.Background(), "alice", "genui", true); err != nil {
t.Fatal(err)
}
conv := surfaceConversationFixture(t, st, "alice")
foreign := surfaceConversationFixture(t, st, "bob")
spec := `"spec":{"title":"t","nodes":[{"type":"markdown","id":"n1","markdown":"x"}]}`
post := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/surfaces", bytes.NewBufferString(body))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
if rec := post(`{"pluginId":"genui",` + spec + `}`); rec.Code != http.StatusBadRequest {
t.Fatalf("empty conversationId should 400, got %d: %s", rec.Code, rec.Body.String())
}
if rec := post(`{"pluginId":"genui","conversationId":"cnv_missing",` + spec + `}`); rec.Code != http.StatusNotFound {
t.Fatalf("unknown conversationId should 404, got %d: %s", rec.Code, rec.Body.String())
}
if rec := post(`{"pluginId":"genui","conversationId":"` + foreign.ID + `",` + spec + `}`); rec.Code != http.StatusNotFound {
t.Fatalf("foreign conversationId should 404, got %d: %s", rec.Code, rec.Body.String())
}
rec := post(`{"pluginId":"genui","conversationId":"` + conv.ID + `",` + spec + `}`)
if rec.Code != http.StatusCreated {
t.Fatalf("caller conversation submit should 201, got %d: %s", rec.Code, rec.Body.String())
}
var snap struct {
Identity struct {
ConversationID string `json:"conversationId"`
ProjectID string `json:"projectId"`
} `json:"identity"`
}
readJSON(t, rec.Body.Bytes(), &snap)
if snap.Identity.ConversationID != conv.ID {
t.Fatalf("conversationId should be %q, got %q", conv.ID, snap.Identity.ConversationID)
}
if snap.Identity.ProjectID != conv.ProjectID {
t.Fatalf("projectId should come from conversation: want %q, got %q", conv.ProjectID, snap.Identity.ProjectID)
}
}