Add project-level surface aggregation and task-phase guidance (U6) #163
@@ -28,6 +28,7 @@ const (
|
||||
// Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带,
|
||||
// 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。
|
||||
const Catalog = `何时用本工具:用户要求"整理成一页 / 做个对比 / 出一页报告 / 看板 / 时间线"这类**页面化交付**时,优先调用本工具提交声明式页面,而不是写文件工件——页面会在用户预览区直接打开并可继续修订。纯文本问答、简短列表、代码交付保持原方式,不强行出页。
|
||||
任务工作页组织(用户在任务/项目工作区查看时):按任务阶段决定页面结构——需求阶段突出待澄清项与决策点(form/kv 优先);执行阶段突出步骤、进度与产物(steps/timeline/table 优先);验收阶段突出验证结果与缺项(kv+markdown 结论优先)。任务完成状态、步骤勾选等**事实字段只能来自对话上下文中的真实任务记录**(计划/待办/验证结果),你不能凭页面渲染改变任务状态——只做事实的组织与呈现。
|
||||
|
||||
可用节点类型(16 种;字段必须严格匹配,禁止目录外类型/字段):
|
||||
section{title?,children?};list{items:string[]};kv{kv:[[k,v]]};
|
||||
|
||||
@@ -319,3 +319,17 @@ func (s *Server) writeSurfaceError(w http.ResponseWriter, err error) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// listProjectSurfaces 项目级跨会话页面聚合(U6/R8):owner 授权作用域。
|
||||
func (s *Server) listProjectSurfaces(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Surfaces == nil {
|
||||
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
metas, err := s.Surfaces.ListByProject(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
|
||||
if err != nil {
|
||||
s.writeSurfaceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, orEmptySlice(metas))
|
||||
}
|
||||
|
||||
@@ -321,6 +321,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/surfaces/{id}", s.getSurface)
|
||||
mux.HandleFunc("POST /api/surfaces/{id}/actions", s.submitSurfaceAction)
|
||||
mux.HandleFunc("GET /api/conversations/{id}/surfaces", s.listSurfaces)
|
||||
mux.HandleFunc("GET /api/projects/{id}/surfaces", s.listProjectSurfaces)
|
||||
mux.HandleFunc("POST /api/plugins/reload", s.reloadPlugins)
|
||||
mux.HandleFunc("GET /api/plugins/settings-schema", s.pluginSettingsSchema)
|
||||
mux.HandleFunc("GET /api/registry", s.registry)
|
||||
|
||||
@@ -295,3 +295,33 @@ func (s *Store) ListSurfaceEventsPending(limit int) ([]SurfaceRevisionRow, error
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListSurfacesByProject 项目级跨会话聚合(U6/R8):按 owner 授权作用域列出项目下
|
||||
// 全部页面(按 updated_at 新→旧);owner 列在 surfaces 表上,跨 owner 天然隔离。
|
||||
func (s *Store) ListSurfacesByProject(owner, projectID string, limit int) ([]core.SurfaceMeta, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.Query(s.q(`SELECT s.surface_id, s2.spec, s.plugin_id, s.latest_revision, s.conversation_id, s.updated_at
|
||||
FROM surfaces s
|
||||
JOIN surface_revisions s2 ON s2.surface_id=s.surface_id AND s2.revision=s.latest_revision
|
||||
WHERE s.owner=? AND s.project_id=? ORDER BY s.updated_at DESC LIMIT ?`),
|
||||
owner, projectID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []core.SurfaceMeta
|
||||
for rows.Next() {
|
||||
var m core.SurfaceMeta
|
||||
var specJSON string
|
||||
if err := rows.Scan(&m.SurfaceID, &specJSON, &m.PluginID, &m.Revision, &m.ConversationID, &m.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var spec core.SurfaceSpec
|
||||
_ = json.Unmarshal([]byte(specJSON), &spec)
|
||||
m.Title = spec.Title
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -16,10 +16,42 @@ import (
|
||||
)
|
||||
|
||||
type actionFixture struct {
|
||||
svc *Service
|
||||
st *store.Store
|
||||
surface core.SurfaceIdentity
|
||||
rev int
|
||||
svc *Service
|
||||
st *store.Store
|
||||
surface core.SurfaceIdentity
|
||||
rev int
|
||||
projectID string
|
||||
}
|
||||
|
||||
func newActionFixtureWithConversation(t *testing.T, projectID string) *actionFixture {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
svc := New(st, nil)
|
||||
ctx := context.Background()
|
||||
if _, err := svc.SetActivation(ctx, "alice", "generative-ui", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pj, err := st.CreateProject("alice", "U6测试项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectID = string(pj.ID)
|
||||
if _, err := st.DB().Exec(`INSERT INTO conversations(id,project_id,owner,title,created_at,updated_at) VALUES(?,?,?,'U6会话',datetime('now'),datetime('now'))`,
|
||||
"c1", projectID, "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap, err := svc.Submit(ctx, "alice", "generative-ui", SubmitRequest{
|
||||
ConversationID: "c1",
|
||||
Spec: json.RawMessage(`{"title":"需求表","nodes":[{"type":"form","id":"f1","fields":[{"key":"years","label":"年限","type":"select","options":["3","5"]}]},{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"提交"}]}]}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &actionFixture{svc: svc, st: st, surface: snap.Identity, rev: snap.Revision, projectID: projectID}
|
||||
}
|
||||
|
||||
func newActionFixture(t *testing.T) *actionFixture {
|
||||
@@ -152,3 +184,38 @@ func TestDangerHighGoesThroughConfirmCard(t *testing.T) {
|
||||
t.Fatalf("replay should return awaiting_confirm, got %+v err=%v", res2, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAggregationOwnerIsolation(t *testing.T) {
|
||||
// U6/R8 验收:项目级跨会话聚合按 owner 隔离(跨 owner 不可见);
|
||||
// 禁用不阻断读取(历史页面保留)。
|
||||
f := newActionFixtureWithConversation(t, "prj_u6")
|
||||
ctx := context.Background()
|
||||
snap2, err := f.svc.Submit(ctx, "alice", "generative-ui", SubmitRequest{
|
||||
ConversationID: "c1",
|
||||
Spec: json.RawMessage(`{"title":"执行进度页","nodes":[{"type":"steps","id":"s1","steps":{"current":1,"steps":[{"label":"需求冻结","status":"done"},{"label":"实施","status":"active"}]}}]}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metas, err := f.svc.ListByProject(ctx, "alice", core.ID(f.projectID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metas) != 2 {
|
||||
t.Fatalf("alice should see her project surfaces, got %+v", metas)
|
||||
}
|
||||
// 跨 owner 隔离:bob 看不到 alice 的项目页面
|
||||
bobMetas, err := f.svc.ListByProject(ctx, "bob", core.ID(f.projectID))
|
||||
if err != nil || len(bobMetas) != 0 {
|
||||
t.Fatalf("bob must see zero surfaces, got %+v err=%v", bobMetas, err)
|
||||
}
|
||||
// 禁用后读取保留
|
||||
if _, err := f.svc.SetActivation(ctx, "alice", "generative-ui", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := f.svc.ListByProject(ctx, "alice", core.ID(f.projectID))
|
||||
if err != nil || len(after) != 2 {
|
||||
t.Fatalf("post-disable read must retain history, got %+v err=%v", after, err)
|
||||
}
|
||||
_ = snap2
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ func (e *ErrInvalidSpec) Error() string { return "surface: invalid spec: " + fmt
|
||||
// ObservedEpoch>0 时校验与当前 epoch 一致,拦截「禁用后迟到的在途结果」。
|
||||
type SubmitRequest struct {
|
||||
SurfaceID core.ID
|
||||
ProjectID core.ID
|
||||
ConversationID core.ID
|
||||
SourceRunID core.ID
|
||||
BaseRevision int
|
||||
@@ -87,9 +88,16 @@ func (s *Service) Submit(ctx context.Context, owner, pluginID string, req Submit
|
||||
if surfaceID == "" {
|
||||
surfaceID = core.ID(store.NewID("surf"))
|
||||
}
|
||||
projectID := req.ProjectID
|
||||
if projectID == "" && req.ConversationID != "" {
|
||||
// U6:项目归属从会话权威反查(模型不能自报 project;会话表是唯一来源)。
|
||||
if conv, err := s.st.GetConversation(owner, string(req.ConversationID)); err == nil {
|
||||
projectID = conv.ProjectID
|
||||
}
|
||||
}
|
||||
snap := core.SurfaceSnapshot{
|
||||
Identity: core.SurfaceIdentity{
|
||||
SurfaceID: surfaceID, Owner: owner, ConversationID: req.ConversationID,
|
||||
SurfaceID: surfaceID, Owner: owner, ProjectID: projectID, ConversationID: req.ConversationID,
|
||||
SourceRunID: req.SourceRunID, PluginID: pluginID,
|
||||
},
|
||||
Compat: core.SurfaceCompat{SchemaVersion: core.SurfaceSchemaVersion, CatalogVersion: core.SurfaceCatalogVersion},
|
||||
@@ -335,3 +343,10 @@ func orEmptyJSON(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ListByProject 项目级跨会话聚合(U6/R8):owner 授权作用域内列出项目下全部
|
||||
// 页面元数据。授权口径与项目工件/卡片一致——项目属 owner,跨 owner 隔离在
|
||||
// 存储层完成;禁用不阻断读取(历史页面保留,KTD7)。
|
||||
func (s *Service) ListByProject(ctx context.Context, owner string, projectID core.ID) ([]core.SurfaceMeta, error) {
|
||||
return s.st.ListSurfacesByProject(owner, string(projectID), 50)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user