merge: candidate free-collab claim-notify + free-lane into local_dev

This commit is contained in:
weichen
2026-09-24 11:51:50 +00:00
80 changed files with 3718 additions and 248 deletions
@@ -0,0 +1,22 @@
# 自由协作 + 认领提醒 — 任务记录
- **task_id**: free-collab-claim-notify-2026-09-24
- **status**: in_progress
- **owner**: Cursor
- **mutex**: notification+supplementimpact+events+docs
- **companion_task**: non-author-member-access-2026-09-24 (server+docgov+web)
- **base_dev_sha**: ebb36fb85c80b83e895b29df5d9056c68f5efe31
- **design**: [../system-design/2026-09-24-free-collab-claim-notify-design.md](../system-design/2026-09-24-free-collab-claim-notify-design.md)
- **write_scope**: internal/{notification,events,app,bizdev,coordinator,traceimpact,supplementimpact}, deploy/postgres, .doc
- **review_state**: pending
- **integration_state**: pending
- **deployment**: not_started
- **cleanup**: pending
- **remote_sync**: not_requested
## 批次
- A 自由协作通道(companion worktree)
- B 认领补齐 + 互斥加固(companion)
- C 提醒机制 + 收件箱
- D 补充影响批次 + 确认卡 + 分流执行
@@ -0,0 +1,58 @@
# 自由协作通道 + 认领态分流 + 文档变更提醒机制
**status**: in_progress
**task_id**: free-collab-claim-notify-2026-09-24
**companion**: non-author-member-access-2026-09-24(持有 server/docgov/web 写租约)
**base_dev_sha**: ebb36fb85c80b83e895b29df5d9056c68f5efe31
**授权**: 用户确认方案后实施(会话 2026-09-24)
## 1. 目标
1. 任意项目成员随时自由对话、上传、总结(自由协作通道)
2. 补充材料可按认领机制更新涉及文档(确认卡勾选)
3. 已认领须编制负责人确认并由负责人发起修改;未认领只发摘要提醒
4. 单文档同时只被一个任务修改;冲突提示且不执行
## 2. 认领语义(不变)
- 非模板(`is_template_only=false`)+ 编制负责人查看或编辑 → `author_claimed=true`
- 模板态查看不算认领
- 已认领 → `RequiresProposal`;未认领 → `AllowedDirectEdit`
- 补齐:正文读取成功后自动 `RecordDocumentViewed`(author);claim/proposal 路由补项目鉴权
## 3. 自由协作通道
- 准入 = 项目成员(`RequireProjectAccess`),不查阶段/duty/实例状态
- 适用:chat/help/query/impact.analyze/supplement/上传/总结
- 受控文档写动作仍走 duty+阶段+认领
- `allowActiveStageChatAccess` 仅约束读取他人阶段对话/受控正文,不约束发起自由对话
## 4. 补充 → 影响 → 确认卡 → 分流
- `project.fact.supplement` 成功后 → `traceimpact.Analyze` → `supplement_impact_batches/items`
- 卡片 `SUPPLEMENT_IMPACT_CONFIRM`;决策 API 用 nonce+digest+revision CAS(不走 intentflow plan tier)
- 未认领:直改 + info 提醒;已认领:提案 + action_required;busy:skipped_busy 不回滚
- 批准后由负责人身份 `ForceDirectEdit` + `MarkProposalApplied`
## 5. 提醒聚合
- `notification_digests`:`(user_id, digest_kind, doc_id, window_date)` UNIQUE,ON CONFLICT DO UPDATE
- `notifications.severity` ∈ {action_required, info}
- 新事件 `document.proposal_required`
- 文案:见实施计划 §4.5(单次/同日多次/多文档扇出)
## 6. 互斥加固
- `persistLeaseLocked` 条件更新;heartbeat/release holder 归属鉴权
- fencing token 接入 HTTP;`LeaseHolderDisplay` 可读文案
## 7. 迁移
- 00068 notifications digest_id + severity
- 00069 notification_digests
- 00070 supplement_impact_batches + items
## 8. 红线
- 不放宽 is_locked / duty 写鉴权;不升 MULTI_INTENT_PLAN_TIER;不物理删 record 表
- 索引缺失不得显示为无影响
+9
View File
@@ -7,6 +7,15 @@
## 📋 报告清单
### 0b. 自由协作与认领提醒(2026-09-24)
| 项 | 链接 |
|-----|------|
| 主设计 | [2026-09-24-free-collab-claim-notify-design.md](2026-09-24-free-collab-claim-notify-design.md) |
| 任务 | [../ops/short-term/2026-09-24-free-collab-claim-notify.md](../ops/short-term/2026-09-24-free-collab-claim-notify.md) |
| 一句话总结 | 自由协作通道 + 认领态分流更新 + 通知 digest 聚合 |
### 0. T20 加固设计(2026-09-22)
| 项 | 链接 |
+27
View File
@@ -39,6 +39,7 @@ imports:
- kb
- materialindex
- maturity
- notification
- projectcontext
- projectworkflow
- quote
@@ -114,6 +115,7 @@ imports:
- projectinstance
- projectworkflow
- storage
- supplementimpact
- sysadmin
- taskrun
- traceimpact
@@ -257,6 +259,7 @@ imports:
- workflowdef
- workflowscope
- workspace
- supplementimpact
squad:
- agent
- config
@@ -307,6 +310,10 @@ imports:
sysadmin:
- config
traceimpact: []
supplementimpact:
- docgov
- notification
- traceimpact
table_owners:
document_records:
@@ -375,6 +382,12 @@ table_owners:
- app/docproject
notifications:
- notification
notification_digests:
- notification
supplement_impact_batches:
- supplementimpact
supplement_impact_items:
- supplementimpact
stage_advance_requests:
- notification
final_artifacts:
@@ -466,6 +479,16 @@ event_producers:
intent.plan.completed:
- app/intentflow
- events
document.proposal_required:
- docgov
- bizdev
- app/supplementimpact
- events
document.unclaimed_edit_applied:
- docgov
- bizdev
- app/supplementimpact
- events
event_consumers:
outbox.notify:
@@ -499,6 +522,10 @@ event_consumers:
intent.plan.completed:
- projection
- notify
document.proposal_required:
- notify
document.unclaimed_edit_applied:
- notify
unwrap_owners:
- docgov
@@ -0,0 +1,16 @@
-- +goose Up
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS digest_id BIGINT,
ADD COLUMN IF NOT EXISTS severity TEXT NOT NULL DEFAULT 'info';
DO $$ BEGIN
ALTER TABLE notifications
ADD CONSTRAINT chk_notification_severity CHECK (severity IN ('action_required', 'info'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_notifications_digest_id ON notifications (digest_id) WHERE digest_id IS NOT NULL;
-- +goose Down
-- record retention: do not drop columns
SELECT 1;
@@ -0,0 +1,25 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS notification_digests (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
digest_kind TEXT NOT NULL,
doc_id TEXT NOT NULL DEFAULT '',
window_date DATE NOT NULL,
occurrence_count INT NOT NULL DEFAULT 1,
last_actor_id BIGINT,
last_actor_name TEXT NOT NULL DEFAULT '',
last_at TIMESTAMPTZ NOT NULL DEFAULT now(),
item_summaries JSONB NOT NULL DEFAULT '[]'::jsonb,
truncated_count INT NOT NULL DEFAULT 0,
notification_id BIGINT REFERENCES notifications(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_notification_digest_window UNIQUE (user_id, digest_kind, doc_id, window_date)
);
CREATE INDEX IF NOT EXISTS idx_notification_digests_user_kind
ON notification_digests (user_id, digest_kind, window_date DESC);
-- +goose Down
-- record retention: do not drop table
SELECT 1;
@@ -0,0 +1,49 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS supplement_impact_batches (
batch_id TEXT PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
session_id TEXT NOT NULL DEFAULT '',
initiator_user_id BIGINT NOT NULL REFERENCES users(id),
source_kind TEXT NOT NULL DEFAULT 'fact_supplement',
source_summary TEXT NOT NULL DEFAULT '',
coverage_status TEXT NOT NULL DEFAULT 'index_pending',
status TEXT NOT NULL DEFAULT 'pending',
decision_nonce TEXT NOT NULL DEFAULT '',
decision_digest TEXT NOT NULL DEFAULT '',
revision BIGINT NOT NULL DEFAULT 1,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_sib_status CHECK (status IN ('pending','decided','running','done','partially_done','expired','cancelled'))
);
CREATE TABLE IF NOT EXISTS supplement_impact_items (
id BIGSERIAL PRIMARY KEY,
batch_id TEXT NOT NULL REFERENCES supplement_impact_batches(batch_id) ON DELETE CASCADE,
doc_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
item_key TEXT NOT NULL DEFAULT '',
impact_type TEXT NOT NULL DEFAULT '',
depth INT NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
author_claimed_snapshot BOOLEAN NOT NULL DEFAULT FALSE,
author_id_snapshot BIGINT NOT NULL DEFAULT 0,
busy_snapshot BOOLEAN NOT NULL DEFAULT FALSE,
change_summary TEXT NOT NULL DEFAULT '',
selected BOOLEAN NOT NULL DEFAULT FALSE,
outcome TEXT NOT NULL DEFAULT '',
proposal_id TEXT NOT NULL DEFAULT '',
run_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_sii_outcome CHECK (
outcome = '' OR outcome IN ('applied','proposal_pending','proposal_rejected','skipped_busy','skipped_locked','failed')
)
);
CREATE INDEX IF NOT EXISTS idx_sii_batch ON supplement_impact_items (batch_id);
CREATE INDEX IF NOT EXISTS idx_sib_project ON supplement_impact_batches (project_id, created_at DESC);
-- +goose Down
-- record retention: do not drop tables
SELECT 1;
+6 -1
View File
@@ -31,6 +31,7 @@ import (
"chat-agent-system/internal/jevdecide"
"chat-agent-system/internal/kb"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/supplementimpact"
"chat-agent-system/internal/observability"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
@@ -75,6 +76,8 @@ type Runtime struct {
Gov *docgov.GovernanceEngine
Rev *docrevision.Service
Trace *traceimpact.Service
SupplementImpact *supplementimpact.Service
mutateOwner *MutateOwner
Ctx *ctxmgr.ContextManager
CAS *storage.CAS
FreeDoc *freedoc.Service
@@ -312,7 +315,8 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
pflags := intentflow.CurrentFeatureFlags()
liveOwners := intentflow.LiveOwners{}
if pflags.U10Mutate {
liveOwners.Mutate = NewMutateOwner(projectAccess(rt), projectBlackboardPort(pool, parts.Ctx))
rt.mutateOwner = NewMutateOwner(projectAccess(rt), projectBlackboardPort(pool, parts.Ctx))
liveOwners.Mutate = rt.mutateOwner
}
if pflags.U11DocGov {
liveOwners.DocGov = NewDocGovOwner(parts.Gov, projectAccess(rt), nil)
@@ -335,6 +339,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
svc.SetAgentMaterializer(rt.Docflow)
rt.bindDocumentNotify()
rt.bindWorkflowApprovalHook()
rt.wireSupplementImpact()
return rt, nil
}
+4
View File
@@ -85,6 +85,10 @@ func notifyCopy(eventName string) (kind, title, body string) {
return notification.TypeDocChangeReportDegraded, "变更报告未完整评估", "变更报告降级,受控文档暂不可送审。"
case events.EventApproved:
return notification.TypeDocApproved, "文档已批准", "文档已完成终审批准。"
case events.EventDocProposalRequired:
return notification.TypeDocProposalRequired, "文档变更待确认", "已认领文档有变更提案,请确认后由您发起修改。"
case events.EventDocUnclaimedEdit:
return notification.TypeDocUnclaimedEditApplied, "文档已更新", "未认领文档已被直接更新(无需您确认)。"
default:
return "", "", ""
}
+17
View File
@@ -23,6 +23,8 @@ type MutateOwner struct {
Access ProjectAccess
Blackboard ProjectBlackboardPort
Denied bool
// OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync.
OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)
mu sync.Mutex
receipts map[string]intentflow.Receipt
@@ -91,6 +93,21 @@ func (m *MutateOwner) Execute(ctx context.Context, exec intentflow.StepExec) (in
return intentflow.Receipt{}, err
}
newVer = got
if m.OnSupplement != nil {
summary := strings.TrimSpace(exec.SafeSummary)
if summary == "" {
summary = "project facts supplemented"
}
markdown := summary
if len(facts) > 0 {
parts := make([]string, 0, len(facts))
for k, v := range facts {
parts = append(parts, k+": "+v)
}
markdown = strings.Join(parts, "\n")
}
m.OnSupplement(ctx, exec.Principal.ProjectID, exec.Principal.UserID, exec.Principal.SessionID, summary, markdown)
}
}
rec := intentflow.Receipt{
+72
View File
@@ -0,0 +1,72 @@
package app
import (
"context"
"fmt"
"log/slog"
"time"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/supplementimpact"
)
func (r *Runtime) wireSupplementImpact() {
if r == nil || r.Pool == nil || r.Gov == nil || r.Trace == nil {
return
}
if r.SupplementImpact != nil {
return
}
svc := &supplementimpact.Service{
Store: supplementimpact.NewPGStore(r.Pool),
Docs: r.Gov,
Analyze: r.Trace,
Notify: r.Notify,
Propose: r.Gov,
}
r.SupplementImpact = svc
if r.Biz != nil && r.Notify != nil {
r.Biz.SetDigestNotifier(r.Notify)
}
if r.Engine != nil {
r.Engine.SetOnSupplement(func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) {
b, err := svc.CreateFromSupplement(ctx, projectID, userID, sessionID, summary, markdown)
if err != nil {
slog.Warn("supplementimpact sync create failed", "error", err, "project_id", projectID)
return
}
r.Engine.SetPendingSupplementCard(batchToCard(b))
})
}
if r.mutateOwner != nil {
r.mutateOwner.OnSupplement = func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) {
supplementimpact.TriggerAsync(svc, projectID, userID, sessionID, summary, markdown)
}
}
}
func batchToCard(b *supplementimpact.Batch) *intent.InteractiveCardPayload {
if b == nil {
return nil
}
title, summary := supplementimpact.BuildConfirmCardMeta(b)
actions := []intent.CardAction{
{ActionID: "confirm_supplement_impact", Label: "确认更新所选文档", Style: "primary", IsAffirmativeDefault: true},
{ActionID: "cancel_supplement_impact", Label: "取消", Style: "subtle"},
}
body := summary + "\n" + fmt.Sprintf("batch_id=%s\nnonce=%s\nrevision=%d\ncoverage=%s", b.BatchID, b.DecisionNonce, b.Revision, b.CoverageStatus)
for _, it := range b.Items {
body += fmt.Sprintf("\n- %s | claimed=%v busy=%v | %s", it.DocID, it.AuthorClaimedSnapshot, it.BusySnapshot, it.Title)
}
return &intent.InteractiveCardPayload{
CardID: b.BatchID,
CardType: "SUPPLEMENT_IMPACT_CONFIRM",
Title: title,
ProposedContent: body,
ImpactRisk: b.CoverageStatus,
CurrentStatus: "active",
TTLTurns: 8,
CreatedAt: time.Now().UTC(),
Actions: actions,
}
}
+50
View File
@@ -0,0 +1,50 @@
package bizdev
import (
"context"
"fmt"
"chat-agent-system/internal/docgov"
)
// TaskExecutor is the ForceDirectEdit execution surface used by AfterProposalApproved.
type TaskExecutor interface {
ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error)
}
// AfterProposalApproved starts a ForceDirectEdit task run as the author after a
// proposal reaches approved_by_author. Finalize already calls MarkProposalApplied
// when FindApprovedProposal succeeds under ForceDirectEdit.
//
// Server composition (non-author worktree) should call this after
// DecideModificationProposal(approve=true) succeeds; pass a base TaskRunRequest
// with TaskKey/DocID/project fields filled from the document context.
func AfterProposalApproved(ctx context.Context, exec TaskExecutor, prop *docgov.ChangeProposal, authorID int64, base TaskRunRequest) (*TaskRunResult, error) {
if exec == nil {
return nil, fmt.Errorf("bizdev: AfterProposalApproved requires executor")
}
if prop == nil {
return nil, fmt.Errorf("bizdev: AfterProposalApproved requires proposal")
}
if authorID <= 0 {
return nil, fmt.Errorf("bizdev: AfterProposalApproved requires author id")
}
req := base
req.ForceDirectEdit = true
req.UserID = authorID
if req.AuthorID == 0 {
req.AuthorID = authorID
}
if req.DocID == "" {
req.DocID = prop.DocID
}
if req.ProjectID == 0 {
req.ProjectID = prop.ProjectID
}
return exec.ExecuteTask(ctx, req)
}
// AfterProposalApproved is the Service convenience wrapper.
func (s *Service) AfterProposalApproved(ctx context.Context, prop *docgov.ChangeProposal, authorID int64, base TaskRunRequest) (*TaskRunResult, error) {
return AfterProposalApproved(ctx, s, prop, authorID, base)
}
+63
View File
@@ -0,0 +1,63 @@
package bizdev_test
import (
"context"
"testing"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/docgov"
)
type stubTaskExec struct {
last bizdev.TaskRunRequest
res *bizdev.TaskRunResult
err error
}
func (s *stubTaskExec) ExecuteTask(_ context.Context, req bizdev.TaskRunRequest) (*bizdev.TaskRunResult, error) {
s.last = req
return s.res, s.err
}
func TestAfterProposalApproved_ForceEditAsAuthor(t *testing.T) {
exec := &stubTaskExec{res: &bizdev.TaskRunResult{Success: true}}
prop := &docgov.ChangeProposal{
ProposalID: "prop_1",
DocID: "doc_a",
ProjectID: 42,
Status: docgov.ProposalApprovedByAuthor,
}
res, err := bizdev.AfterProposalApproved(context.Background(), exec, prop, 9, bizdev.TaskRunRequest{
TaskKey: "feasibility_reg",
ProjectName: "P",
Backend: "stub",
})
if err != nil {
t.Fatal(err)
}
if res == nil || !res.Success {
t.Fatalf("want success result, got %+v", res)
}
if !exec.last.ForceDirectEdit {
t.Fatal("expected ForceDirectEdit=true")
}
if exec.last.UserID != 9 || exec.last.AuthorID != 9 {
t.Fatalf("want author identity 9, got user=%d author=%d", exec.last.UserID, exec.last.AuthorID)
}
if exec.last.DocID != "doc_a" || exec.last.ProjectID != 42 {
t.Fatalf("want doc/project from proposal, got doc=%s project=%d", exec.last.DocID, exec.last.ProjectID)
}
}
func TestAfterProposalApproved_NilSafe(t *testing.T) {
if _, err := bizdev.AfterProposalApproved(context.Background(), nil, &docgov.ChangeProposal{}, 1, bizdev.TaskRunRequest{}); err == nil {
t.Fatal("nil executor must error")
}
exec := &stubTaskExec{}
if _, err := bizdev.AfterProposalApproved(context.Background(), exec, nil, 1, bizdev.TaskRunRequest{}); err == nil {
t.Fatal("nil proposal must error")
}
if _, err := bizdev.AfterProposalApproved(context.Background(), exec, &docgov.ChangeProposal{DocID: "d"}, 0, bizdev.TaskRunRequest{}); err == nil {
t.Fatal("author id 0 must error")
}
}
+16
View File
@@ -20,6 +20,7 @@ import (
"chat-agent-system/internal/freedoc"
"chat-agent-system/internal/kb"
"chat-agent-system/internal/materialindex"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/storage"
@@ -53,6 +54,13 @@ type Service struct {
completedMainHook func(ctx context.Context, projectID int64) (bool, error)
// instanceDuty 可选:活跃工作流实例对 taskKey 的治理职责(由 app 装配,避免导入 projectinstance)。
instanceDuty InstanceDutySource
// notify 可选:提案拦截时写入 document digest(composition 注入)。
notify DigestNotifier
}
// DigestNotifier is the subset of notification.Service used on proposal_required.
type DigestNotifier interface {
UpsertDocumentDigest(ctx context.Context, p notification.UpsertDigestParams) error
}
// InstanceDutySource 返回项目当前活跃实例对 taskKey 的治理结论。
@@ -69,6 +77,14 @@ func (s *Service) SetInstanceDutySource(src InstanceDutySource) {
s.instanceDuty = src
}
// SetDigestNotifier wires optional claim-aware digest notifications.
func (s *Service) SetDigestNotifier(n DigestNotifier) {
if s == nil {
return
}
s.notify = n
}
// NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。
func NewService(cfg *config.Config) (*Service, error) {
if cfg == nil {
+15
View File
@@ -13,6 +13,7 @@ import (
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docduty"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/kb"
"chat-agent-system/internal/projectcontext"
@@ -485,6 +486,20 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
prop, _ = s.docGov.ProposeModification(ctx, docID, "agent_squad", proposedChanges, impactAnalysis, riskAssessment)
}
if s.notify != nil && prop != nil && docRec.AuthorID > 0 {
_ = s.notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
UserID: docRec.AuthorID,
ProjectID: req.ProjectID,
DigestKind: notification.DigestKindProposalRequired,
DocID: docID,
DocTitle: artifactTitle,
Severity: notification.SeverityActionRequired,
ActorID: req.UserID,
ActorName: "agent_squad",
Summary: prop.ProposedChanges,
ProposalID: prop.ProposalID,
})
}
streamer := stream.FromContext(ctx)
if streamer != nil {
streamer.EmitGovernance(stream.GovernancePayload{
+27
View File
@@ -76,6 +76,10 @@ type CoordinatorEngine struct {
kbClient *kb.Client
impact ImpactActions
projectAccess ProjectAccess
// OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync.
// Nil-safe: unset hooks are skipped.
OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)
pendingSupplementCard *intent.InteractiveCardPayload
}
// StageTaskRunner is the application owner for ExecuteTask / ExecuteStage.
@@ -126,6 +130,29 @@ func (e *CoordinatorEngine) SetImpactActions(a ImpactActions) {
}
}
// SetOnSupplement wires the optional post-blackboard supplement impact hook.
func (e *CoordinatorEngine) SetOnSupplement(fn func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)) {
if e != nil {
e.OnSupplement = fn
}
}
func (e *CoordinatorEngine) SetPendingSupplementCard(card *intent.InteractiveCardPayload) {
if e == nil {
return
}
e.pendingSupplementCard = card
}
func (e *CoordinatorEngine) TakePendingSupplementCard() *intent.InteractiveCardPayload {
if e == nil {
return nil
}
c := e.pendingSupplementCard
e.pendingSupplementCard = nil
return c
}
func NewCoordinatorEngine(runner StageTaskRunner) *CoordinatorEngine {
return &CoordinatorEngine{
stageTasks: runner,
+15
View File
@@ -85,6 +85,16 @@ func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, u
}
}
if e != nil && e.OnSupplement != nil {
projectID := parseDecimalID(userCtx.Project.ID)
userID := parseDecimalID(userCtx.User.ID)
markdown := strings.TrimSpace(rawDoc)
if markdown == "" && factKey != "" && factVal != "" {
markdown = factKey + ": " + factVal
}
e.OnSupplement(context.Background(), projectID, userID, "", summaryKey, markdown)
}
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
userCtx.applyPending(nextExp)
@@ -105,6 +115,10 @@ func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, u
sb.WriteString("已收录补充材料,并关联到项目事实黑板。\n后续文档修订与阶段推进时,我会自动引用这部分事实。")
}
if card := e.TakePendingSupplementCard(); card != nil {
userCtx.applyCard(card)
sb.WriteString("\n\n已分析补充材料对文档的潜在影响,请在确认卡中勾选要更新的文档。")
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
@@ -113,6 +127,7 @@ func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, u
AdvisorBrief: userCtx.LatestAdvisorBrief,
UpdatedContext: userCtx,
NextExpectation: nextExp,
InteractiveCard: userCtx.InteractiveCard,
}, nil
}
+13 -7
View File
@@ -49,13 +49,19 @@ func (e *GovernanceEngine) RecordDocumentViewed(ctx context.Context, docID strin
auditAction = "author_claim_by_view"
}
case RoleReviewer:
doc.ReviewerClaimed = true
doc.ReviewerClaimedAt = &now
auditAction = "reviewer_claim_by_view"
auditAction = "reviewer_view"
if !doc.IsTemplateOnly && !doc.ReviewerClaimed {
doc.ReviewerClaimed = true
doc.ReviewerClaimedAt = &now
auditAction = "reviewer_claim_by_view"
}
case RoleApprover:
doc.ApproverClaimed = true
doc.ApproverClaimedAt = &now
auditAction = "approver_claim_by_view"
auditAction = "approver_view"
if !doc.IsTemplateOnly && !doc.ApproverClaimed {
doc.ApproverClaimed = true
doc.ApproverClaimedAt = &now
auditAction = "approver_claim_by_view"
}
default:
auditAction = "view"
}
@@ -122,7 +128,7 @@ func (e *GovernanceEngine) RecordDocumentEdited(ctx context.Context, docID strin
}
}
if err := e.sm.CanEditDocumentContent(doc, actorID, actorRole); err != nil {
if err := e.sm.CanEditDocumentContent(doc, actorID, actorRole, false); err != nil {
return nil, err
}
+82 -14
View File
@@ -32,6 +32,7 @@ type ContentSaveRequest struct {
ActorID int64
ActorRole UserRole
ActorName string
ActorIsMember bool // server 层已用 authorizer 校验的项目成员;不得用 RoleSystem 冒充
MarkdownBody string
ExpectedSHA256 string // 乐观并发:非空时必须匹配当前 ContentSHA256
FencingToken string
@@ -83,13 +84,15 @@ func (e *GovernanceEngine) AcquireLease(ctx context.Context, req LeaseAcquireReq
"holder_id": doc.LeaseHolderID,
"expires_at": doc.LeaseExpiresAt,
})
display := LeaseHolderDisplay(doc.LeaseHolderType, doc.LeaseHolderID, "")
return doc, &DocumentBusyError{
DocID: doc.DocID,
HolderType: doc.LeaseHolderType,
HolderID: doc.LeaseHolderID,
WorkDir: doc.LeaseWorkDir,
ExpiresAt: *doc.LeaseExpiresAt,
Reason: fmt.Sprintf("文档【%s】正在被 %s(%s) 编辑中,请稍后再试", doc.Title, doc.LeaseHolderType, doc.LeaseHolderID),
DocID: doc.DocID,
HolderType: doc.LeaseHolderType,
HolderID: doc.LeaseHolderID,
HolderDisplayName: display,
WorkDir: doc.LeaseWorkDir,
ExpiresAt: *doc.LeaseExpiresAt,
Reason: FormatDocumentBusyReason(doc.Title, display, *doc.LeaseExpiresAt),
}
}
}
@@ -330,21 +333,22 @@ func clampLeaseTTL(holder LeaseHolderType, ttl time.Duration) time.Duration {
return ttl
}
// CheckHumanCanEdit verifies author duty and drafting state without taking a lease.
func (e *GovernanceEngine) CheckHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole) error {
// CheckHumanCanEdit verifies drafting state and edit permission without taking a lease.
// actorIsMember must be set by the server after project-membership / DocAccessActionEdit checks.
func (e *GovernanceEngine) CheckHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorIsMember bool) error {
e.mu.Lock()
defer e.mu.Unlock()
doc, err := e.getDocLocked(ctx, docID)
if err != nil {
return err
}
return e.sm.CanEditDocumentContent(doc, actorID, actorRole)
return e.sm.CanEditDocumentContent(doc, actorID, actorRole, actorIsMember)
}
// AssertHumanCanEdit checks author duty + drafting lifecycle BEFORE acquiring/renewing
// AssertHumanCanEdit checks edit permission + drafting lifecycle BEFORE acquiring/renewing
// the human lease, so unauthorized actors never become lease holders.
func (e *GovernanceEngine) AssertHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole) error {
if err := e.CheckHumanCanEdit(ctx, docID, actorID, actorRole); err != nil {
func (e *GovernanceEngine) AssertHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorIsMember bool) error {
if err := e.CheckHumanCanEdit(ctx, docID, actorID, actorRole, actorIsMember); err != nil {
return err
}
@@ -391,7 +395,7 @@ func (e *GovernanceEngine) SaveDocumentContent(ctx context.Context, req ContentS
return nil, err
}
if err := e.sm.CanEditDocumentContent(doc, req.ActorID, req.ActorRole); err != nil {
if err := e.sm.CanEditDocumentContent(doc, req.ActorID, req.ActorRole, req.ActorIsMember); err != nil {
return nil, err
}
@@ -474,21 +478,85 @@ func (e *GovernanceEngine) getDocLocked(ctx context.Context, docID string) (*Doc
return doc, nil
}
// LeaseHolderDisplay returns a user-facing holder label.
// squad → 项目助手; human → display name if provided, else "其他用户".
func LeaseHolderDisplay(holderType LeaseHolderType, holderID, displayName string) string {
switch holderType {
case LeaseHolderSquad:
return "项目助手"
case LeaseHolderHuman:
if strings.TrimSpace(displayName) != "" {
return strings.TrimSpace(displayName)
}
return "其他用户"
default:
if strings.TrimSpace(displayName) != "" {
return strings.TrimSpace(displayName)
}
return string(holderType)
}
}
// FormatDocumentBusyReason builds the user-visible busy copy.
func FormatDocumentBusyReason(title, holderDisplay string, expiresAt time.Time) string {
remain := "稍后"
if !expiresAt.IsZero() {
m := int(time.Until(expiresAt).Minutes())
if m < 1 {
m = 1
}
remain = fmt.Sprintf("%d 分钟后", m)
}
t := strings.TrimSpace(title)
if t == "" {
t = "文档"
}
h := strings.TrimSpace(holderDisplay)
if h == "" {
h = "其他任务"
}
return fmt.Sprintf("《%s》正在修改中(由%s发起,预计 %s释放)。您的修改请求未执行,请稍后重试。", t, h, remain)
}
func (e *GovernanceEngine) persistLeaseLocked(ctx context.Context, doc *DocumentRecord) error {
if e.pool == nil {
return nil
}
_, err := e.pool.Exec(ctx, `
// Release/clear: write empty lease unconditionally (caller already authorized).
if doc.LeaseHolderType == "" || doc.LeaseHolderType == LeaseHolderNone {
_, err := e.pool.Exec(ctx, `
UPDATE document_records
SET lease_holder_type = '', lease_holder_id = '', lease_work_dir = $1,
lease_expires_at = NULL, lease_heartbeat_at = NULL, lease_fencing_token = '', updated_at = $2
WHERE doc_id = $3
`, doc.LeaseWorkDir, doc.UpdatedAt, doc.DocID)
if err != nil {
slog.Error("docgov_lease_clear_persist_failed", "doc_id", doc.DocID, "error", err)
return fmt.Errorf("docgov: persist lease clear: %w", err)
}
return nil
}
// Acquire/renew: conditional — free, expired, or same holder only.
tag, err := e.pool.Exec(ctx, `
UPDATE document_records
SET lease_holder_type = $1, lease_holder_id = $2, lease_work_dir = $3,
lease_expires_at = $4, lease_heartbeat_at = $5, lease_fencing_token = $6, updated_at = $7
WHERE doc_id = $8
AND (
lease_holder_type = '' OR lease_holder_type = 'none'
OR lease_expires_at IS NULL OR lease_expires_at < now()
OR (lease_holder_type = $1 AND lease_holder_id = $2)
)
`, string(doc.LeaseHolderType), doc.LeaseHolderID, doc.LeaseWorkDir,
doc.LeaseExpiresAt, doc.LeaseHeartbeatAt, doc.LeaseFencingToken, doc.UpdatedAt, doc.DocID)
if err != nil {
slog.Error("docgov_lease_persist_failed", "doc_id", doc.DocID, "holder", doc.LeaseHolderID, "error", err)
return fmt.Errorf("docgov: persist lease: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrDocumentBusy
}
return nil
}
+13 -4
View File
@@ -128,8 +128,8 @@ func TestAssertHumanCanEdit_ChecksDutyBeforeLease(t *testing.T) {
t.Fatal(err)
}
// Non-author must fail without becoming lease holder.
if err := engine.AssertHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor); err == nil {
// Non-author without member flag must fail without becoming lease holder.
if err := engine.AssertHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor, false); err == nil {
t.Fatal("non-author AssertHumanCanEdit must fail")
}
doc, err := engine.GetDocument(ctx, docID)
@@ -140,7 +140,13 @@ func TestAssertHumanCanEdit_ChecksDutyBeforeLease(t *testing.T) {
t.Fatal("unauthorized actor must not hold human lease")
}
if err := engine.AssertHumanCanEdit(ctx, docID, 1, docgov.RoleAuthor); err != nil {
// Authorized member (non-author) may edit under deny_only_submit.
if err := engine.AssertHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor, true); err != nil {
t.Fatalf("member AssertHumanCanEdit: %v", err)
}
_, _ = engine.ReleaseLease(ctx, docID, string(docgov.LeaseHolderHuman), "99")
if err := engine.AssertHumanCanEdit(ctx, docID, 1, docgov.RoleAuthor, false); err != nil {
t.Fatalf("author AssertHumanCanEdit: %v", err)
}
doc, err = engine.GetDocument(ctx, docID)
@@ -173,7 +179,10 @@ func TestAcquireLeaseClampsHumanTTL(t *testing.T) {
if doc.LeaseExpiresAt == nil || doc.LeaseExpiresAt.After(before.Add(docgov.DefaultHumanLeaseTTL+time.Second)) {
t.Fatalf("ttl not clamped: expires=%v", doc.LeaseExpiresAt)
}
if err := engine.CheckHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor); err == nil {
if err := engine.CheckHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor, false); err == nil {
t.Fatal("non-author must not pass edit check")
}
if err := engine.CheckHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor, true); err != nil {
t.Fatalf("member must pass edit check: %v", err)
}
}
+13 -5
View File
@@ -16,8 +16,10 @@ func NewStateMachine() *StateMachine {
// CanEditDocumentContent 权限守卫 1:校验当前操作人是否有权修改文档正文
// 规则:
// 1. 文档处于锁定状态 (reviewing, approving, approved) 时严禁直接修改
// 2. 只有编制负责人 (Author) 有权修改文档正文;审核人和批准人严禁直接修改
func (sm *StateMachine) CanEditDocumentContent(doc *DocumentRecord, actorID int64, actorRole UserRole) error {
// 2. 审核人/批准人角色严禁直接修改
// 3. 编制负责人或已授权项目成员(ActorIsMember,由 server 层校验后传入)可在 drafting 未锁定时写正文
// 4. 不复用 RoleSystem:人工成员编辑不得记成智能体代编
func (sm *StateMachine) CanEditDocumentContent(doc *DocumentRecord, actorID int64, actorRole UserRole, actorIsMember bool) error {
if doc == nil {
return ErrDocumentNotFound
}
@@ -38,15 +40,21 @@ func (sm *StateMachine) CanEditDocumentContent(doc *DocumentRecord, actorID int6
return ErrReviewerCannotEdit
case RoleApprover:
return ErrApproverCannotEdit
case RoleSystem:
// 系统或智能体代为编辑:在编制中且未锁定阶段放行 (认领检查在外部业务层联动)
return nil
case RoleAuthor:
if actorIsMember {
return nil
}
if doc.AuthorID > 0 && actorID > 0 && doc.AuthorID != actorID {
return fmt.Errorf("%w: 文档编制负责人为用户ID %d,非操作人 %d", ErrOnlyAuthorCanEdit, doc.AuthorID, actorID)
}
return nil
case RoleSystem:
// 系统或智能体代为编辑:在编制中且未锁定阶段放行 (认领检查在外部业务层联动)
return nil
default:
if actorIsMember {
return nil
}
return ErrUnauthorizedActor
}
}
+7 -6
View File
@@ -107,12 +107,13 @@ func NewContentError(code string, cause error, retryable, needsAdmin bool) *Cont
// DocumentBusyError 携带租约占用详情,便于 HTTP 409 与 UI 提示
type DocumentBusyError struct {
DocID string `json:"doc_id"`
HolderType LeaseHolderType `json:"holder_type"`
HolderID string `json:"holder_id"`
WorkDir string `json:"work_dir,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
Reason string `json:"reason"`
DocID string `json:"doc_id"`
HolderType LeaseHolderType `json:"holder_type"`
HolderID string `json:"holder_id"`
HolderDisplayName string `json:"holder_display_name,omitempty"`
WorkDir string `json:"work_dir,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
Reason string `json:"reason"`
}
func (e *DocumentBusyError) Error() string {
+4
View File
@@ -23,6 +23,8 @@ const (
EventIntentPlanAwaiting = "intent.plan.awaiting_input"
EventIntentPlanStepChanged = "intent.plan.step_changed"
EventIntentPlanCompleted = "intent.plan.completed"
EventDocProposalRequired = "document.proposal_required"
EventDocUnclaimedEdit = "document.unclaimed_edit_applied"
)
// Contract is the static allowlist row for one event name.
@@ -50,6 +52,8 @@ func StaticContracts() []Contract {
{EventName: EventIntentPlanAwaiting, SchemaVersion: 1, Enabled: true, Producers: []string{"app/intentflow", "events"}, Consumers: []string{ConsumerProjection, ConsumerNotify}, PayloadFields: []string{"plan_id", "revision", "status", "environment", "project_id", "user_id", "session_id", "summary_ref"}},
{EventName: EventIntentPlanStepChanged, SchemaVersion: 1, Enabled: true, Producers: []string{"app/intentflow", "events"}, Consumers: []string{ConsumerProjection, ConsumerNotify}, PayloadFields: []string{"plan_id", "revision", "step_id", "status", "environment", "project_id", "user_id", "session_id", "summary_ref"}},
{EventName: EventIntentPlanCompleted, SchemaVersion: 1, Enabled: true, Producers: []string{"app/intentflow", "events"}, Consumers: []string{ConsumerProjection, ConsumerNotify}, PayloadFields: []string{"plan_id", "revision", "status", "environment", "project_id", "user_id", "session_id", "summary_ref"}},
{EventName: EventDocProposalRequired, SchemaVersion: 1, Enabled: true, Producers: []string{"docgov", "bizdev", "app/supplementimpact", "events"}, Consumers: []string{ConsumerNotify}, PayloadFields: []string{"doc_id", "proposal_id", "project_id"}},
{EventName: EventDocUnclaimedEdit, SchemaVersion: 1, Enabled: true, Producers: []string{"docgov", "bizdev", "app/supplementimpact", "events"}, Consumers: []string{ConsumerNotify}, PayloadFields: []string{"doc_id", "project_id"}},
}
}
+186
View File
@@ -0,0 +1,186 @@
package notification
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
const (
DigestKindProposalRequired = "proposal_required"
DigestKindUnclaimedEdit = "unclaimed_edit"
SeverityActionRequired = "action_required"
SeverityInfo = "info"
maxDigestItems = 20
)
// DigestItem is one occurrence folded into a daily digest.
type DigestItem struct {
At time.Time `json:"at"`
ActorID int64 `json:"actor_id"`
ActorName string `json:"actor_name"`
Summary string `json:"summary"`
ProposalID string `json:"proposal_id,omitempty"`
ChangeCount int `json:"change_count,omitempty"`
}
// UpsertDigestParams drives ON CONFLICT DO UPDATE aggregation.
type UpsertDigestParams struct {
UserID int64
ProjectID int64
DigestKind string
DocID string
DocTitle string
Severity string
ActorID int64
ActorName string
Summary string
ProposalID string
ChangeCount int
WindowDate time.Time // truncated to date; zero → today UTC
}
// UpsertDocumentDigest aggregates same-day notifications per (user, kind, doc).
func (s *Service) UpsertDocumentDigest(ctx context.Context, p UpsertDigestParams) error {
if !s.Available() {
return ErrNotAvailable
}
if p.UserID <= 0 || p.DocID == "" || p.DigestKind == "" {
return fmt.Errorf("notification: digest requires user, doc, kind")
}
if p.Severity == "" {
p.Severity = SeverityInfo
}
window := p.WindowDate
if window.IsZero() {
now := time.Now().UTC()
window = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
}
item := DigestItem{
At: time.Now().UTC(), ActorID: p.ActorID, ActorName: p.ActorName,
Summary: truncateRunes(p.Summary, 60), ProposalID: p.ProposalID, ChangeCount: p.ChangeCount,
}
itemJSON, _ := json.Marshal(item)
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var digestID, notifID int64
var count int
err = tx.QueryRow(ctx, `
INSERT INTO notification_digests (
user_id, digest_kind, doc_id, window_date, occurrence_count,
last_actor_id, last_actor_name, last_at, item_summaries, truncated_count
) VALUES ($1,$2,$3,$4,1,$5,$6,now(), jsonb_build_array($7::jsonb), 0)
ON CONFLICT (user_id, digest_kind, doc_id, window_date) DO UPDATE SET
occurrence_count = notification_digests.occurrence_count + 1,
last_actor_id = EXCLUDED.last_actor_id,
last_actor_name = EXCLUDED.last_actor_name,
last_at = now(),
item_summaries = CASE
WHEN jsonb_array_length(notification_digests.item_summaries) >= $8 THEN notification_digests.item_summaries
ELSE notification_digests.item_summaries || EXCLUDED.item_summaries
END,
truncated_count = CASE
WHEN jsonb_array_length(notification_digests.item_summaries) >= $8
THEN notification_digests.truncated_count + 1
ELSE notification_digests.truncated_count
END,
updated_at = now()
RETURNING id, occurrence_count, COALESCE(notification_id, 0)
`, p.UserID, p.DigestKind, p.DocID, window, p.ActorID, p.ActorName, string(itemJSON), maxDigestItems).
Scan(&digestID, &count, &notifID)
if err != nil {
return err
}
title, body := digestCopy(p, count)
dedupeKey := fmt.Sprintf("digest:%s:%s:%s", p.DigestKind, p.DocID, window.Format("2006-01-02"))
notifType := typeForDigestKind(p.DigestKind)
if notifID == 0 {
err = tx.QueryRow(ctx, `
INSERT INTO notifications (user_id, project_id, type, title, body, dedupe_key, status, digest_id, severity)
VALUES ($1,$2,$3,$4,$5,$6,'unread',$7,$8)
ON CONFLICT (user_id, dedupe_key) DO UPDATE SET
title = EXCLUDED.title,
body = EXCLUDED.body,
status = 'unread',
severity = EXCLUDED.severity,
digest_id = EXCLUDED.digest_id
RETURNING id
`, p.UserID, p.ProjectID, notifType, title, body, dedupeKey, digestID, p.Severity).Scan(&notifID)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `UPDATE notification_digests SET notification_id=$1 WHERE id=$2`, notifID, digestID)
if err != nil {
return err
}
} else {
_, err = tx.Exec(ctx, `
UPDATE notifications SET title=$1, body=$2, status='unread', severity=$3
WHERE id=$4 AND user_id=$5
`, title, body, p.Severity, notifID, p.UserID)
if err != nil {
return err
}
}
return tx.Commit(ctx)
}
func typeForDigestKind(kind string) string {
switch kind {
case DigestKindProposalRequired:
return TypeDocProposalRequired
case DigestKindUnclaimedEdit:
return TypeDocUnclaimedEditApplied
default:
return kind
}
}
func digestCopy(p UpsertDigestParams, count int) (title, body string) {
titleDoc := strings.TrimSpace(p.DocTitle)
if titleDoc == "" {
titleDoc = p.DocID
}
actor := strings.TrimSpace(p.ActorName)
if actor == "" {
actor = "成员"
}
summary := truncateRunes(p.Summary, 60)
switch p.DigestKind {
case DigestKindProposalRequired:
if count <= 1 {
return fmt.Sprintf("《%s》有变更待您确认", titleDoc),
fmt.Sprintf("%s 补充了「%s」,拟修改本文档。文档已被您认领,需您确认后由您发起修改。", actor, summary)
}
return fmt.Sprintf("《%s》累计 %d 次变更待您确认", titleDoc, count),
fmt.Sprintf("最近一次:%s。%d 次变更均来自他人补充材料,请逐份拍板。", actor, count)
case DigestKindUnclaimedEdit:
if count <= 1 {
return fmt.Sprintf("《%s》已被更新(无需您确认)", titleDoc),
fmt.Sprintf("%s 通过助手更新了本文档,来源:%s。本文档尚未被您认领,系统已直接应用。", actor, summary)
}
return fmt.Sprintf("《%s》今日已有 %d 次更新", titleDoc, count),
fmt.Sprintf("最近一次:%s;累计 %d 次更新。本文档尚未被您认领。", actor, count)
default:
return titleDoc, summary
}
}
func truncateRunes(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
return string(r[:n]) + "…"
}
// Ensure unused import guard for pgx when scanning with interfaces.
+4
View File
@@ -14,12 +14,16 @@ const (
EventDocHumanRevisionConflict = "document.human_revision_conflict"
EventDocDownstreamImpact = "document.downstream_impact_detected"
EventDocChangeReportDegraded = "document.change_report_degraded"
EventDocProposalRequired = "document.proposal_required"
EventDocUnclaimedEditApplied = "document.unclaimed_edit_applied"
TypeDocAgentCandidateReady = "document_agent_candidate_ready"
TypeDocAgentVersionCommitted = "document_agent_version_committed"
TypeDocDownstreamImpact = "document_downstream_impact"
TypeDocChangeReportDegraded = "document_change_report_degraded"
TypeDocApproved = "document_approved"
TypeDocProposalRequired = "document_proposal_required"
TypeDocUnclaimedEditApplied = "document_unclaimed_edit_applied"
)
// DocumentEventPayload is a minimal, permission-safe notification body (no markdown excerpts).
+44 -8
View File
@@ -173,6 +173,15 @@ func ignoreUnique(err error) error {
// ListProjectNotifications 列出项目下当前用户的通知(可选 since_id 增量)。
func (s *Service) ListProjectNotifications(ctx context.Context, userID, projectID int64, sinceID int64, limit int) ([]Notification, error) {
return s.listNotifications(ctx, userID, projectID, sinceID, limit, false)
}
// ListUserNotifications 列出当前用户跨项目通知;action_required 优先,再按 id 降序。
func (s *Service) ListUserNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]Notification, error) {
return s.listNotifications(ctx, userID, 0, sinceID, limit, true)
}
func (s *Service) listNotifications(ctx context.Context, userID, projectID, sinceID int64, limit int, allProjects bool) ([]Notification, error) {
if !s.Available() {
return nil, ErrNotAvailable
}
@@ -181,18 +190,37 @@ func (s *Service) ListProjectNotifications(ctx context.Context, userID, projectI
}
query := `
SELECT n.id, n.user_id, n.project_id, COALESCE(n.event_id, 0), n.type, n.title, n.body, n.status, n.created_at,
COALESCE(e.payload, '{}'::jsonb)
SELECT n.id, n.user_id, n.project_id, COALESCE(n.event_id, 0), n.type, n.title, n.body, n.status,
COALESCE(n.severity, 'info'), COALESCE(n.digest_id, 0), n.created_at,
COALESCE(e.payload, '{}'::jsonb),
COALESCE(d.doc_id, ''),
COALESCE((
SELECT elem->>'proposal_id'
FROM jsonb_array_elements(COALESCE(d.item_summaries, '[]'::jsonb)) WITH ORDINALITY AS t(elem, ord)
WHERE COALESCE(elem->>'proposal_id', '') <> ''
ORDER BY ord DESC
LIMIT 1
), '')
FROM notifications n
LEFT JOIN outbox_events e ON e.id = n.event_id
WHERE n.user_id = $1 AND n.project_id = $2
LEFT JOIN notification_digests d ON d.id = n.digest_id
WHERE n.user_id = $1
`
args := []any{userID, projectID}
args := []any{userID}
if !allProjects {
query += ` AND n.project_id = $2`
args = append(args, projectID)
}
if sinceID > 0 {
query += ` AND n.id > $3`
query += ` AND n.id > $` + fmt.Sprint(len(args)+1)
args = append(args, sinceID)
}
query += ` ORDER BY n.id ASC LIMIT $` + fmt.Sprint(len(args)+1)
if allProjects {
query += ` ORDER BY CASE WHEN COALESCE(n.severity, 'info') = 'action_required' THEN 0 ELSE 1 END, n.id DESC`
} else {
query += ` ORDER BY n.id ASC`
}
query += ` LIMIT $` + fmt.Sprint(len(args)+1)
args = append(args, limit)
rows, err := s.pool.Query(ctx, query, args...)
@@ -205,15 +233,23 @@ func (s *Service) ListProjectNotifications(ctx context.Context, userID, projectI
for rows.Next() {
var n Notification
var payloadRaw []byte
var digestDocID, digestProposalID string
if err := rows.Scan(
&n.ID, &n.UserID, &n.ProjectID, &n.EventID, &n.Type, &n.Title, &n.Body, &n.Status, &n.CreatedAt,
&payloadRaw,
&n.ID, &n.UserID, &n.ProjectID, &n.EventID, &n.Type, &n.Title, &n.Body, &n.Status,
&n.Severity, &n.DigestID, &n.CreatedAt,
&payloadRaw, &digestDocID, &digestProposalID,
); err != nil {
return nil, err
}
if len(payloadRaw) > 0 {
_ = json.Unmarshal(payloadRaw, &n.Payload)
}
if n.Payload.DocID == "" && digestDocID != "" {
n.Payload.DocID = digestDocID
}
if n.Payload.ProposalID == "" && digestProposalID != "" {
n.Payload.ProposalID = digestProposalID
}
out = append(out, n)
}
return out, rows.Err()
+14 -9
View File
@@ -19,18 +19,23 @@ type StageReadyPayload struct {
ConfigRevision int `json:"config_revision"`
RequiredDocCount int `json:"required_doc_count"`
ApprovedDocCount int `json:"approved_doc_count"`
DocID string `json:"doc_id,omitempty"`
ProposalID string `json:"proposal_id,omitempty"`
BatchID string `json:"batch_id,omitempty"`
}
// Notification 用户站内通知。
type Notification struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
ProjectID int64 `json:"project_id"`
EventID int64 `json:"event_id,omitempty"`
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
ProjectID int64 `json:"project_id"`
EventID int64 `json:"event_id,omitempty"`
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
Status string `json:"status"`
Severity string `json:"severity,omitempty"`
DigestID int64 `json:"digest_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Payload StageReadyPayload `json:"payload,omitempty"`
}
@@ -143,13 +143,17 @@ func TestPG_GoverningAccess_ActiveCreatorDutyOutsider(t *testing.T) {
if !dutyRun.Allowed {
t.Fatalf("author duty must run, got %+v", dutyRun)
}
outsider := svc.CheckDocTaskAccess(ctx, projectID, outsiderID, "intake", projectinstance.DocAccessActionRun, false)
if outsider.Allowed || outsider.Code != projectinstance.AccessCodeForbiddenDuty {
t.Fatalf("outsider must DOC_DUTY_FORBIDDEN, got %+v", outsider)
memberRun := svc.CheckDocTaskAccess(ctx, projectID, outsiderID, "intake", projectinstance.DocAccessActionRun, false)
if !memberRun.Allowed {
t.Fatalf("non-duty member must run, got %+v", memberRun)
}
memberSubmit := svc.CheckDocTaskAccess(ctx, projectID, outsiderID, "intake", projectinstance.DocAccessActionSubmit, false)
if memberSubmit.Allowed || memberSubmit.Code != projectinstance.AccessCodeForbiddenDuty {
t.Fatalf("non-duty member must not submit, got %+v", memberSubmit)
}
outsiderStage, _ := svc.ResolveStageAccess(ctx, projectID, outsiderID, false)
if !outsiderStage.Restricted || outsiderStage.TaskKeyAllowed("intake") {
t.Fatalf("outsider stage must deny, got %+v", outsiderStage)
if !outsiderStage.Restricted || !outsiderStage.TaskKeyAllowed("intake") {
t.Fatalf("member stage must allow intake run, got %+v", outsiderStage)
}
}
+2 -1
View File
@@ -31,7 +31,8 @@ type DocAccessDecision struct {
// CheckDocTaskAccess 对治理实例下的文档任务做判定。
//
// GetGoverningInstanceForAccess:从未激活 → view 放行、run 带 WORKFLOW_NOT_ACTIVATED(HTTP 转 202-blocked)、其余拒绝;
// completed/terminated → InstanceClosed;存储错误 fail-closed。full 文档按动作对应单一职责,创建者不隐式越权。
// completed/terminated → InstanceClosed;存储错误 fail-closed。
// deny_only_submit:run/edit 对项目成员放行;submit/review/approve 按对应单一职责,创建者不隐式越权。
func (s *Service) CheckDocTaskAccess(
ctx context.Context,
projectID int64,
@@ -36,7 +36,7 @@ func TestCheckDocTaskAccess_DisabledDocumentRejected(t *testing.T) {
}
}
func TestCheckDocTaskAccess_NonDutyUserRejected(t *testing.T) {
func TestCheckDocTaskAccess_NonDutyUserCanRunButNotSubmit(t *testing.T) {
ctx := context.Background()
store := NewMemoryStore()
_ = SeedProjectMeta(store, 401, LifecyclePlanning, "", 1, 0)
@@ -51,9 +51,17 @@ func TestCheckDocTaskAccess_NonDutyUserRejected(t *testing.T) {
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
decision := svc.CheckDocTaskAccess(ctx, 401, 99, "intake", DocAccessActionRun, false)
if decision.Allowed || decision.Code != AccessCodeForbiddenDuty {
t.Fatalf("expected DOC_DUTY_FORBIDDEN, got %+v", decision)
run := svc.CheckDocTaskAccess(ctx, 401, 99, "intake", DocAccessActionRun, false)
if !run.Allowed {
t.Fatalf("non-duty member must run, got %+v", run)
}
edit := svc.CheckDocTaskAccess(ctx, 401, 99, "intake", DocAccessActionEdit, false)
if !edit.Allowed {
t.Fatalf("non-duty member must edit, got %+v", edit)
}
submit := svc.CheckDocTaskAccess(ctx, 401, 99, "intake", DocAccessActionSubmit, false)
if submit.Allowed || submit.Code != AccessCodeForbiddenDuty {
t.Fatalf("non-duty member must not submit, got %+v", submit)
}
}
@@ -87,8 +95,8 @@ func TestCheckDocTaskAccess_UnmanagedDutyModes(t *testing.T) {
t.Fatalf("member view on none must allow, got %+v", view)
}
runMember := svc.CheckDocTaskAccess(ctx, 402, 99, "research", DocAccessActionRun, false)
if runMember.Allowed || runMember.Code != AccessCodeForbiddenDuty {
t.Fatalf("member run on none must deny, got %+v", runMember)
if !runMember.Allowed {
t.Fatalf("member run on none must allow, got %+v", runMember)
}
runCreator := svc.CheckDocTaskAccess(ctx, 402, 1, "research", DocAccessActionRun, true)
if !runCreator.Allowed {
@@ -141,14 +149,15 @@ func TestCheckDocTaskAccess_FullActionMatrix(t *testing.T) {
assert(1, false, DocAccessActionRun, true, "")
assert(1, false, DocAccessActionReview, false, AccessCodeForbiddenDuty)
assert(1, false, DocAccessActionApprove, false, AccessCodeForbiddenDuty)
assert(2, false, DocAccessActionRun, false, AccessCodeForbiddenDuty)
assert(2, false, DocAccessActionRun, true, "")
assert(2, false, DocAccessActionSubmit, false, AccessCodeForbiddenDuty)
assert(2, false, DocAccessActionReview, true, "")
assert(3, false, DocAccessActionRun, false, AccessCodeForbiddenDuty)
assert(3, false, DocAccessActionRun, true, "")
assert(3, false, DocAccessActionReview, false, AccessCodeForbiddenDuty)
assert(3, false, DocAccessActionApprove, true, "")
assert(9, true, DocAccessActionView, true, "")
assert(9, true, DocAccessActionRun, false, AccessCodeForbiddenDuty)
assert(9, true, DocAccessActionRun, true, "")
assert(9, true, DocAccessActionEdit, true, "")
assert(9, true, DocAccessActionSubmit, false, AccessCodeForbiddenDuty)
assert(9, true, DocAccessActionReview, false, AccessCodeForbiddenDuty)
assert(9, true, DocAccessActionApprove, false, AccessCodeForbiddenDuty)
+14 -10
View File
@@ -10,10 +10,11 @@ const (
GovernanceBlockedHint = "当前项目尚未激活工作流,文档任务不会启动。请先在设置中配置并激活至少一份纳入人工审批的文档。"
)
// requiredDutyRole maps a document action to the single duty that may perform it.
// requiredDutyRole maps a document action to the duty that may perform it.
// run/edit are member-level (empty = no duty binding); submit/review/approve stay duty-gated.
func requiredDutyRole(action string) string {
switch action {
case DocAccessActionRun, DocAccessActionEdit, DocAccessActionSubmit:
case DocAccessActionSubmit:
return DutyRoleAuthor
case DocAccessActionReview:
return DutyRoleReviewer
@@ -41,6 +42,9 @@ func userHoldsDuty(d DocDuty, userID int64, role string) bool {
}
// classifyAccess applies the locked duty-action matrix. govLive=false is pre-activation.
// Callers must already have enforced project membership; run/edit are then member-level
// under DutyModeFull/DutyModeNone. submit/review/approve remain duty-bound.
// DutyModePartial still denies all non-view actions to avoid half-configured mis-grants.
func classifyAccess(dt DocTaskPlan, action string, userID int64, isCreator bool, govLive bool) DocAccessDecision {
allow := DocAccessDecision{Allowed: true}
if action == DocAccessActionView {
@@ -68,14 +72,10 @@ func classifyAccess(dt DocTaskPlan, action string, userID int64, isCreator bool,
case DutyModeNone:
switch action {
case DocAccessActionRun, DocAccessActionEdit:
if isCreator {
return allow
}
return DocAccessDecision{
Allowed: false,
Code: AccessCodeForbiddenDuty,
Message: "未纳入人工审批的文档仅创建者可编辑或执行",
}
// deny_only_submit: any project member (membership checked upstream) may run/edit.
_ = isCreator
_ = userID
return allow
case DocAccessActionReview, DocAccessActionApprove, DocAccessActionSubmit:
return DocAccessDecision{
Allowed: false,
@@ -91,6 +91,10 @@ func classifyAccess(dt DocTaskPlan, action string, userID int64, isCreator bool,
}
case DutyModeFull:
role := requiredDutyRole(action)
if role == "" {
// run/edit: member-level
return allow
}
if userHoldsDuty(effective, userID, role) {
return allow
}
+31 -14
View File
@@ -3,11 +3,11 @@ package projectinstance
import "testing"
func TestRequiredDutyRole(t *testing.T) {
if requiredDutyRole(DocAccessActionRun) != DutyRoleAuthor {
t.Fatal("run -> author")
if requiredDutyRole(DocAccessActionRun) != "" {
t.Fatal("run is member-level (no duty role)")
}
if requiredDutyRole(DocAccessActionEdit) != DutyRoleAuthor {
t.Fatal("edit -> author")
if requiredDutyRole(DocAccessActionEdit) != "" {
t.Fatal("edit is member-level (no duty role)")
}
if requiredDutyRole(DocAccessActionSubmit) != DutyRoleAuthor {
t.Fatal("submit -> author")
@@ -40,20 +40,21 @@ func TestClassifyAccess_FullDutyMatrix(t *testing.T) {
{"author submit", 1, false, DocAccessActionSubmit, true, ""},
{"author review denied", 1, false, DocAccessActionReview, false, AccessCodeForbiddenDuty},
{"author approve denied", 1, false, DocAccessActionApprove, false, AccessCodeForbiddenDuty},
{"reviewer run denied", 2, false, DocAccessActionRun, false, AccessCodeForbiddenDuty},
{"reviewer edit denied", 2, false, DocAccessActionEdit, false, AccessCodeForbiddenDuty},
{"reviewer run allowed", 2, false, DocAccessActionRun, true, ""},
{"reviewer edit allowed", 2, false, DocAccessActionEdit, true, ""},
{"reviewer submit denied", 2, false, DocAccessActionSubmit, false, AccessCodeForbiddenDuty},
{"reviewer review", 2, false, DocAccessActionReview, true, ""},
{"approver run denied", 3, false, DocAccessActionRun, false, AccessCodeForbiddenDuty},
{"approver edit denied", 3, false, DocAccessActionEdit, false, AccessCodeForbiddenDuty},
{"approver run allowed", 3, false, DocAccessActionRun, true, ""},
{"approver edit allowed", 3, false, DocAccessActionEdit, true, ""},
{"approver review denied", 3, false, DocAccessActionReview, false, AccessCodeForbiddenDuty},
{"approver approve", 3, false, DocAccessActionApprove, true, ""},
{"creator view", creatorID, true, DocAccessActionView, true, ""},
{"creator run denied", creatorID, true, DocAccessActionRun, false, AccessCodeForbiddenDuty},
{"member run allowed", creatorID, true, DocAccessActionRun, true, ""},
{"member edit allowed", creatorID, true, DocAccessActionEdit, true, ""},
{"creator submit denied", creatorID, true, DocAccessActionSubmit, false, AccessCodeForbiddenDuty},
{"creator review denied", creatorID, true, DocAccessActionReview, false, AccessCodeForbiddenDuty},
{"creator approve denied", creatorID, true, DocAccessActionApprove, false, AccessCodeForbiddenDuty},
{"reviewer cannot re-run after reject", 2, false, DocAccessActionRun, false, AccessCodeForbiddenDuty},
{"reviewer can re-run after reject", 2, false, DocAccessActionRun, true, ""},
}
for _, tc := range cases {
got := classifyAccess(dt, tc.action, tc.userID, tc.isCreator, true)
@@ -74,8 +75,12 @@ func TestClassifyAccess_NoneAndPreActivation(t *testing.T) {
t.Fatalf("creator edit on none: %+v", creatorEdit)
}
memberRun := classifyAccess(none, DocAccessActionRun, 2, false, true)
if memberRun.Allowed || memberRun.Code != AccessCodeForbiddenDuty {
t.Fatalf("member run on none: %+v", memberRun)
if !memberRun.Allowed {
t.Fatalf("member run on none must allow: %+v", memberRun)
}
memberEdit := classifyAccess(none, DocAccessActionEdit, 2, false, true)
if !memberEdit.Allowed {
t.Fatalf("member edit on none must allow: %+v", memberEdit)
}
submit := classifyAccess(none, DocAccessActionSubmit, 1, true, true)
if submit.Allowed || submit.Code != AccessCodeDutyNotManaged {
@@ -91,6 +96,16 @@ func TestClassifyAccess_NoneAndPreActivation(t *testing.T) {
}
}
func TestClassifyAccess_PartialStillDeniesRunEdit(t *testing.T) {
partial := DocTaskPlan{Enabled: true, Duties: DocDuty{AuthorUserID: 1}}
for _, action := range []string{DocAccessActionRun, DocAccessActionEdit, DocAccessActionSubmit} {
got := classifyAccess(partial, action, 1, true, true)
if got.Allowed || got.Code != AccessCodeForbiddenDuty {
t.Fatalf("partial %s must deny: %+v", action, got)
}
}
}
func TestClassifyAccess_AuthorOnlyMatrix(t *testing.T) {
dt := DocTaskPlan{
Enabled: true,
@@ -109,10 +124,12 @@ func TestClassifyAccess_AuthorOnlyMatrix(t *testing.T) {
{"author submit", 7, DocAccessActionSubmit, true, ""},
{"author review", 7, DocAccessActionReview, true, ""},
{"author approve", 7, DocAccessActionApprove, true, ""},
{"other run denied", 2, DocAccessActionRun, false, AccessCodeForbiddenDuty},
{"other run allowed", 2, DocAccessActionRun, true, ""},
{"other edit allowed", 2, DocAccessActionEdit, true, ""},
{"other submit denied", 2, DocAccessActionSubmit, false, AccessCodeForbiddenDuty},
{"other review denied", 2, DocAccessActionReview, false, AccessCodeForbiddenDuty},
{"other approve denied", 3, DocAccessActionApprove, false, AccessCodeForbiddenDuty},
{"creator cannot elevate", 9, DocAccessActionApprove, false, AccessCodeForbiddenDuty},
{"creator cannot elevate approve", 9, DocAccessActionApprove, false, AccessCodeForbiddenDuty},
}
for _, tc := range cases {
got := classifyAccess(dt, tc.action, tc.userID, tc.userID == 9, true)
+14 -17
View File
@@ -15,13 +15,15 @@ type StageAccess struct {
ViewableTaskKeys map[string]bool `json:"viewable_task_keys,omitempty"`
}
// ResolveStageAccess 基于治理实例与三权指派计算阶段 ABAC 范围。
// ResolveStageAccess 基于治理实例计算阶段 ABAC 范围。
//
// GetGoverningInstanceForAccess 语义(与 GetLiveInstance/HasLiveInstance 分离):
// - (nil, nil):从未激活(无实例或仅 planning)→ 不限制(legacy / 新项目预激活)
// - live 系:按职责/创建者 fail-closed 收窄
// - live 系:AllowedTaskKeys 对活跃成员开放(run 语义);ViewableTaskKeys 对全部启用文档开放
// - completed/terminated:Restricted=true 全拒(不得误判为从未激活)
// - 存储错误:上抛,由调用方 fail-closed
//
// isCreator 保留以兼容调用方;AllowedTaskKeys 不再按职责/创建者收窄(deny_only_submit)。
func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userID int64, isCreator bool) (*StageAccess, error) {
out := &StageAccess{
AllowedStageKeys: make(map[string]bool),
@@ -31,6 +33,7 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
if s == nil || s.store == nil || projectID <= 0 || userID <= 0 {
return out, nil
}
_ = isCreator
gov, err := s.store.GetGoverningInstanceForAccess(ctx, projectID)
if err != nil {
@@ -62,14 +65,14 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
continue
}
status := stageStatus[stPlan.StageKey]
// View exemption: every enabled doc is listable. Action keys stay duty/creator-none.
// View exemption: every enabled doc is listable.
for _, dt := range stPlan.DocTasks {
if dt.Enabled {
out.ViewableTaskKeys[dt.TaskKey] = true
}
}
// Stage chat/history: any caller who already passed project membership may
// see active/completed stages. Document actions remain duty-scoped below.
// see active/completed stages. Run/edit task keys are member-level below.
switch status {
case StageActive, StageCompleted:
out.AllowedStageKeys[stPlan.StageKey] = true
@@ -80,9 +83,13 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
if !dt.Enabled {
continue
}
if docTaskDutyForUser(dt, userID) || (isCreator && docTaskDutyMode(dt) == DutyModeNone) {
out.AllowedTaskKeys[dt.TaskKey] = true
// AllowedTaskKeys feeds AllowTaskExecution / run semantics (member-level).
// submit/review/approve do NOT use TaskKeyAllowed — they go through CheckDocTaskAccess.
// DutyModePartial stays excluded so half-configured docs are not advertised as runnable.
if docTaskDutyMode(dt) == DutyModePartial {
continue
}
out.AllowedTaskKeys[dt.TaskKey] = true
}
}
return out, nil
@@ -93,17 +100,7 @@ func docTaskDutyMode(dt DocTaskPlan) DutyMode {
return mode
}
func docTaskDutyForUser(dt DocTaskPlan, userID int64) bool {
mode, effective, _ := ResolveDocTaskDuty(dt)
if mode != DutyModeFull {
return false
}
return effective.AuthorUserID == userID ||
effective.ReviewerUserID == userID ||
effective.ApproverUserID == userID
}
// TaskKeyAllowed 判断 task_key 是否可发起非 view 动作。
// TaskKeyAllowed 判断 task_key 是否可发起非 view 动作(run 语义)。
func (a *StageAccess) TaskKeyAllowed(taskKey string) bool {
if a == nil || !a.Restricted {
return true
+18 -14
View File
@@ -6,7 +6,7 @@ import (
"testing"
)
func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) {
func TestResolveStageAccess_ActiveStageMemberRun(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0)
@@ -21,8 +21,8 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) {
if !access.Restricted {
t.Fatal("expected restricted access under live instance")
}
if access.TaskKeyAllowed("intake") {
t.Fatal("user without duty should not access active stage docs")
if !access.TaskKeyAllowed("intake") {
t.Fatal("member without duty should run active stage docs")
}
if !access.TaskKeyVisible("intake") {
t.Fatal("view must stay open for enabled docs even without duty")
@@ -42,8 +42,8 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) {
if !memberAccess.StageKeyAllowed("V0.1_triage") {
t.Fatal("project member without duty should access active stage chat")
}
if memberAccess.TaskKeyAllowed("intake") {
t.Fatal("member without duty must not act on governed intake task")
if !memberAccess.TaskKeyAllowed("intake") {
t.Fatal("member without duty may act on governed intake task (run/edit)")
}
}
@@ -79,11 +79,11 @@ func TestResolveStageAccess_UnmanagedDocsStayVisible(t *testing.T) {
if !member.TaskKeyVisible("research") {
t.Fatalf("none docs must stay visible to members, got %+v", member)
}
if member.TaskKeyAllowed("research") {
t.Fatal("members must not launch unmanaged docs")
if !member.TaskKeyAllowed("research") {
t.Fatal("members may launch unmanaged docs (run/edit member-level)")
}
if member.TaskKeyAllowed("intake") {
t.Fatal("members without intake duty must not act on the governed task")
if !member.TaskKeyAllowed("intake") {
t.Fatal("members without intake duty may act on the governed task (run/edit)")
}
}
@@ -130,8 +130,8 @@ func TestResolveStageAccess_LiveMemberChatWithoutDuty(t *testing.T) {
if !member.StageKeyAllowed("V0.1_triage") {
t.Fatal("member without duty should access active stage chat")
}
if member.TaskKeyAllowed("intake") {
t.Fatal("member without duty must not launch governed intake")
if !member.TaskKeyAllowed("intake") {
t.Fatal("member without duty may launch governed intake (run/edit)")
}
}
@@ -308,8 +308,8 @@ func TestResolveStageAccess_AuthorOnlyAuthorCanAct(t *testing.T) {
}
outsider, _ := svc.ResolveStageAccess(ctx, 510, 99, false)
if outsider.TaskKeyAllowed("intake") {
t.Fatal("non-author must not act on author_only intake")
if !outsider.TaskKeyAllowed("intake") {
t.Fatal("non-author member may act on author_only intake (run/edit)")
}
d := svc.CheckDocTaskAccess(ctx, 510, 7, "intake", DocAccessActionApprove, false)
if !d.Allowed {
@@ -317,7 +317,11 @@ func TestResolveStageAccess_AuthorOnlyAuthorCanAct(t *testing.T) {
}
d = svc.CheckDocTaskAccess(ctx, 510, 99, "intake", DocAccessActionApprove, false)
if d.Allowed || d.Code != AccessCodeForbiddenDuty {
t.Fatalf("outsider approve denied: %+v", d)
t.Fatalf("non-author must not approve author_only: %+v", d)
}
d = svc.CheckDocTaskAccess(ctx, 510, 99, "intake", DocAccessActionSubmit, false)
if d.Allowed || d.Code != AccessCodeForbiddenDuty {
t.Fatalf("non-author must not submit author_only: %+v", d)
}
}
+3 -1
View File
@@ -114,7 +114,9 @@ func (g *bizdevStageGate) FilterPriorDocs(ctx context.Context, projectID, userID
}
out := make([]squad.PriorDocItem, 0, len(docs))
for _, d := range docs {
if d.DocKey != "" && access.TaskKeyAllowed(d.DocKey) {
// Prior-doc filtering is a read path: use TaskKeyVisible so member-level
// summarization/analysis is not gated by run AllowedTaskKeys.
if d.DocKey != "" && access.TaskKeyVisible(d.DocKey) {
out = append(out, d)
continue
}
@@ -214,7 +214,8 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
// This UI has no edit-session heartbeat: release the request-scoped human lease on every exit
// (success, 409, projection failure) so a 15m residual cannot block a later squad run.
if kind == "controlled" && s.gov != nil {
if err := s.gov.AssertHumanCanEdit(r.Context(), docID, user.ID, vocabulary.RoleAuthor); err != nil {
// ActorIsMember=true:上游 enforceDocGovWorkflowAction(Edit) 已按成员级放行。
if err := s.gov.AssertHumanCanEdit(r.Context(), docID, user.ID, vocabulary.RoleAuthor, true); err != nil {
writeDocGovError(w, err)
return
}
@@ -12,6 +12,7 @@ import (
"testing"
"time"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
)
@@ -68,15 +69,18 @@ func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger(
assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID)
}
func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) {
func TestDocRevision_PatchOrder_LockedDocDoesNotAdvanceLedger(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
docID := "doc_proj1_lease_denied"
workDir := filepath.Join(srv.dataRoot(), "ws-lease")
_ = os.MkdirAll(workDir, 0o755)
// Author is user 99; login admin may bypass route author gate but AssertHumanCanEdit (RoleAuthor) still fails.
if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 99, "other", 2, "r", 3, "a"); err != nil {
// Author is admin (1); lock via submit so drafting edit is denied regardless of membership.
if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil {
t.Fatal(err)
}
if _, err := srv.gov.SubmitForReview(ctx, docID, 1, vocabulary.RoleAuthor, "admin"); err != nil {
t.Fatal(err)
}
st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# L\n\nx\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
@@ -94,21 +98,14 @@ func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) {
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code == http.StatusOK {
t.Fatalf("non-author must not save successfully: %s", w.Body.String())
t.Fatalf("locked doc must not save successfully: %s", w.Body.String())
}
after, err := srv.rev.GetRevisionState(ctx, docID)
if err != nil {
t.Fatal(err)
}
if after.HeadRevisionNo != before {
t.Fatalf("ledger must not advance when AssertHumanCanEdit fails: before=%d after=%d", before, after.HeadRevisionNo)
}
doc, err := srv.gov.GetDocument(ctx, docID)
if err != nil {
t.Fatal(err)
}
if doc.HasActiveLease(time.Now()) && doc.LeaseHolderID == "1" {
t.Fatal("failed AssertHumanCanEdit must not leave non-author admin holding the lease")
t.Fatalf("ledger must not advance when edit denied: before=%d after=%d", before, after.HeadRevisionNo)
}
}
+4 -1
View File
@@ -41,7 +41,10 @@ func domainHTTPStatus(err error) (status int, ok bool) {
case errors.Is(err, docgov.ErrDocumentNotFound),
errors.Is(err, docgov.ErrProposalNotFound):
return http.StatusNotFound, true
case errors.Is(err, docgov.ErrUnauthorizedActor):
case errors.Is(err, docgov.ErrUnauthorizedActor),
errors.Is(err, docgov.ErrOnlyAuthorCanEdit),
errors.Is(err, docgov.ErrReviewerCannotEdit),
errors.Is(err, docgov.ErrApproverCannotEdit):
return http.StatusForbidden, true
case errors.Is(err, projectworkflow.ErrPrerequisiteNotMet):
return http.StatusBadRequest, true
+9
View File
@@ -28,6 +28,9 @@ func TestDomainHTTPStatus_MapsKnownSentinels(t *testing.T) {
{docgov.ErrDocumentNotFound, http.StatusNotFound, true},
{docgov.ErrProposalNotFound, http.StatusNotFound, true},
{docgov.ErrUnauthorizedActor, http.StatusForbidden, true},
{docgov.ErrOnlyAuthorCanEdit, http.StatusForbidden, true},
{docgov.ErrReviewerCannotEdit, http.StatusForbidden, true},
{docgov.ErrApproverCannotEdit, http.StatusForbidden, true},
{projectworkflow.ErrPrerequisiteNotMet, http.StatusBadRequest, true},
{&docgov.ContentError{Code: docgov.ErrCodeDocumentNotFound}, http.StatusNotFound, true},
{&docgov.ContentError{Code: docgov.ErrCodeProjectionMissing}, http.StatusConflict, true},
@@ -53,6 +56,12 @@ func TestWriteDocGovError_UsesAdapterStatuses(t *testing.T) {
t.Fatalf("status %d", rec.Code)
}
rec = httptest.NewRecorder()
writeDocGovError(rec, docgov.ErrOnlyAuthorCanEdit)
if rec.Code != http.StatusForbidden {
t.Fatalf("OnlyAuthorCanEdit status %d", rec.Code)
}
rec = httptest.NewRecorder()
writeDocGovError(rec, &docgov.DocumentBusyError{DocID: "d", Reason: "held"})
if rec.Code != http.StatusConflict {
+75
View File
@@ -0,0 +1,75 @@
package server
import (
"context"
"log/slog"
"os"
"strings"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/contracts/vocabulary"
)
// Free-lane intents: any project member may invoke without stage/duty gates.
// Controlled-document *write* actions stay on duty + stage + claim paths.
var freeLaneIntentKeys = map[string]struct{}{
"conversation.chat": {},
"conversation.help": {},
"conversation.unsupported": {},
"conversation.decline": {},
"document.status.query": {},
"project.advisory": {},
"project.impact.analyze": {},
"project.fact.supplement": {},
"regulation.query": {},
"case.query": {},
"permission.query": {},
"document.reject_reason.query": {},
"run.status.query": {},
"workflow.status.query": {},
}
// IsFreeLaneIntent reports whether the action key is member-level free collaboration.
func IsFreeLaneIntent(actionKey string) bool {
_, ok := freeLaneIntentKeys[strings.TrimSpace(actionKey)]
return ok
}
// allowFreeLaneChatInitiation always allows initiating chat once project membership
// is established. Stage ABAC still gates reading other stages' history / controlled
// content via allowActiveStageChatAccess / filterWorkflowDocsByStageAccess.
func (s *Server) allowFreeLaneChatInitiation(projectID, userID int64) bool {
if projectID <= 0 || userID <= 0 {
return false
}
return true
}
// maybeAutoClaimOnView records an author view (and claim when non-template) after a
// successful controlled-content read. Failures are logged and never block the read.
func (s *Server) maybeAutoClaimOnView(ctx context.Context, user *auth.UserAccount, docID string) {
if s == nil || s.gov == nil || user == nil || docID == "" {
return
}
if !autoClaimOnViewEnabled() {
return
}
role := s.resolveDocGovRole(ctx, docID, user)
if role != vocabulary.RoleAuthor {
return
}
if _, err := s.gov.RecordDocumentViewed(ctx, docID, user.ID, role, user.Name); err != nil {
slog.Warn("docgov_auto_claim_on_view_failed", "doc_id", docID, "user_id", user.ID, "error", err)
}
}
// autoClaimOnViewEnabled defaults true. Set DOCUMENT_AUTO_CLAIM_ON_VIEW=0|false|off to disable.
func autoClaimOnViewEnabled() bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv("DOCUMENT_AUTO_CLAIM_ON_VIEW")))
switch v {
case "0", "false", "off", "no":
return false
default:
return true
}
}
+31
View File
@@ -0,0 +1,31 @@
package server
import "testing"
func TestIsFreeLaneIntent(t *testing.T) {
if !IsFreeLaneIntent("conversation.chat") {
t.Fatal("chat should be free-lane")
}
if !IsFreeLaneIntent("project.fact.supplement") {
t.Fatal("supplement should be free-lane")
}
if IsFreeLaneIntent("document.review.submit") {
t.Fatal("submit must stay duty-gated")
}
if IsFreeLaneIntent("task.run") {
t.Fatal("task.run must stay gated")
}
}
func TestAllowFreeLaneChatInitiation(t *testing.T) {
var s Server
if !s.allowFreeLaneChatInitiation(42, 7) {
t.Fatal("member should initiate")
}
if s.allowFreeLaneChatInitiation(0, 7) {
t.Fatal("no project")
}
if s.allowFreeLaneChatInitiation(42, 0) {
t.Fatal("no user")
}
}
+1
View File
@@ -144,6 +144,7 @@ type SaveContentRequestDTO struct {
DocID string `json:"doc_id"`
MarkdownBody string `json:"markdown_body"`
ExpectedSHA256 string `json:"expected_sha256,omitempty"`
FencingToken string `json:"fencing_token,omitempty"`
}
type LeaseRequestDTO struct {
+1 -1
View File
@@ -98,7 +98,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
}
}
s.enrichSessionWorkflowStage(r.Context(), sess, user.ID)
if sess.Project() > 0 && !s.allowActiveStageChatAccess(r.Context(), sess.Project(), user.ID) {
if sess.Project() > 0 && !s.allowFreeLaneChatInitiation(sess.Project(), user.ID) {
s.writeWorkflowAccessDenied(w)
return
}
+20 -5
View File
@@ -1,6 +1,8 @@
package server
import (
"chat-agent-system/internal/bizdev"
"context"
"encoding/json"
"errors"
"fmt"
@@ -46,11 +48,6 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request)
// handleDocGovClaim 查阅并认领受控文档 (非模板状态查看方可认领)
func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
var req DocGovClaimRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
@@ -61,6 +58,11 @@ func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) {
return
}
user, ok := s.requireDocGovDocAccess(w, r, req.DocID)
if !ok {
return
}
if s.gov == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
@@ -233,6 +235,9 @@ func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http.
writeSafeError(w, r, http.StatusBadRequest, "proposed_changes cannot be empty")
return
}
if _, ok := s.requireDocGovDocAccess(w, r, req.DocID); !ok {
return
}
if s.gov == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
@@ -303,6 +308,16 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque
return
}
if req.Approve && s.svc != nil && proposal != nil {
// Auto-apply as author for human/supplement-triggered proposals only.
triggered := strings.TrimSpace(proposal.TriggeredBy)
if strings.HasPrefix(triggered, "user:") || strings.Contains(triggered, "supplement") {
base := bizdev.TaskRunRequest{DocID: proposal.DocID, ProjectID: proposal.ProjectID, ForceDirectEdit: true, UserID: user.ID, AuthorID: user.ID, AuthorName: user.Name}
go func(prop *docgov.ChangeProposal, uid int64, req bizdev.TaskRunRequest) {
_, _ = s.svc.AfterProposalApproved(context.Background(), prop, uid, req)
}(proposal, user.ID, base)
}
}
writeJSONResponse(w, http.StatusOK, proposal)
}
+35 -3
View File
@@ -8,6 +8,7 @@ import (
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
)
// -----------------------------------------------------------------------------
@@ -27,14 +28,20 @@ func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
// 成员级 DocAccessActionEdit(deny_only_submit);送审仍走 submit 职责门禁。
if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionEdit) {
return
}
role := vocabulary.RoleAuthor
res, err := s.gov.SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{
DocID: req.DocID,
ActorID: user.ID,
ActorRole: role,
ActorName: user.Name,
ActorIsMember: true, // enforceDocGovWorkflowAction 已确认成员可 edit
MarkdownBody: req.MarkdownBody,
ExpectedSHA256: req.ExpectedSHA256,
FencingToken: req.FencingToken,
})
if err != nil {
writeDocGovError(w, err)
@@ -57,7 +64,10 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request
return
}
if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderHuman {
if err := s.gov.CheckHumanCanEdit(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor); err != nil {
if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionEdit) {
return
}
if err := s.gov.CheckHumanCanEdit(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, true); err != nil {
writeDocGovError(w, err)
return
}
@@ -83,7 +93,8 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request
}
func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requirePrincipal(w, r); !ok {
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
var req LeaseRequestDTO
@@ -95,6 +106,16 @@ func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Reque
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderHuman {
req.HolderID = fmt.Sprintf("%d", user.ID)
} else if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderSquad {
if !s.callerOwnsSquadLease(user.ID, req.HolderID) {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN", "message": "无权续租该文档编辑锁",
})
return
}
}
ttl := time.Duration(req.TTLSeconds) * time.Second
doc, err := s.gov.HeartbeatLease(r.Context(), req.DocID, req.HolderType, req.HolderID, ttl)
if err != nil {
@@ -105,7 +126,8 @@ func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Reque
}
func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requirePrincipal(w, r); !ok {
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
var req LeaseRequestDTO
@@ -117,6 +139,16 @@ func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderHuman {
req.HolderID = fmt.Sprintf("%d", user.ID)
} else if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderSquad {
if !s.callerOwnsSquadLease(user.ID, req.HolderID) {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN", "message": "无权释放该文档编辑锁",
})
return
}
}
doc, err := s.gov.ReleaseLease(r.Context(), req.DocID, req.HolderType, req.HolderID)
if err != nil {
writeDocGovError(w, err)
+27
View File
@@ -0,0 +1,27 @@
package server
import (
"strings"
)
// callerOwnsSquadLease reports whether holderID (typically a task run_id) belongs to userID.
// When AllowInMemory is set and the run is not registered yet (common in HTTP unit harnesses
// that acquire a squad lease without CreateAndStart), ownership is treated as the caller's
// responsibility — production keeps AllowInMemory=false so missing runs stay denied.
func (s *Server) callerOwnsSquadLease(userID int64, holderID string) bool {
holderID = strings.TrimSpace(holderID)
if userID <= 0 || holderID == "" {
return false
}
if s == nil || s.taskRuns == nil {
return false
}
run, err := s.taskRuns.GetRun(holderID)
if err != nil || run == nil {
if s.cfg != nil && s.cfg.AllowInMemory {
return true
}
return false
}
return run.UserID == userID
}
@@ -0,0 +1,254 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/server"
"chat-agent-system/internal/workflowdef"
)
// TestNonAuthorMember_PiAgent_RunEditSubmitGates validates deny_only_submit on a real PI path:
// non-author project member may upload, run intake (pi-agent), and save draft content;
// submit remains author-duty gated.
func TestNonAuthorMember_PiAgent_RunEditSubmitGates(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow pi-agent PG E2E in short mode")
}
if !piAgentRunnerHealthy() {
t.Skip("pi-agent runner not available at http://127.0.0.1:18865/health")
}
pool := openPGPoolWithMigrations(t)
if pool == nil {
return
}
defer pool.Close()
ctx := context.Background()
handler, srv := setupPGPiAgentServer(t, pool)
ts := httptest.NewServer(handler)
defer ts.Close()
client := &http.Client{Timeout: 75 * time.Minute}
seedOfficialWorkflowManifest(t, pool, workflowdef.OfficialKeyMain)
authorToken := loginTestUser(t, handler, "zhanggong", "123456")
reviewerToken := loginTestUser(t, handler, "lizhuguan", "123456")
approverToken := loginTestUser(t, handler, "wangzong", "123456")
memberToken := loginTestUser(t, handler, "guest", "123456")
authorID := fetchAuthUserID(t, handler, authorToken)
reviewerID := fetchAuthUserID(t, handler, reviewerToken)
approverID := fetchAuthUserID(t, handler, approverToken)
memberID := fetchAuthUserID(t, handler, memberToken)
createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{
Name: fmt.Sprintf("NonAuthor Member Access %d", time.Now().UnixNano()%1_000_000),
DeviceType: "Ultrasonic Device",
IntendedUse: "Tissue resection",
TargetMarket: "NMPA Class III",
})
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
withBearerAuth(reqCreate, authorToken)
recCreate := httptest.NewRecorder()
handler.ServeHTTP(recCreate, reqCreate)
if recCreate.Code != http.StatusCreated {
t.Fatalf("create project: %d %s", recCreate.Code, recCreate.Body.String())
}
var proj projectworkflow.Project
if err := json.Unmarshal(recCreate.Body.Bytes(), &proj); err != nil {
t.Fatalf("decode project: %v", err)
}
t.Cleanup(func() { cleanupProject(t, pool, proj.ID) })
projectID := proj.ID
reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, workflowdef.OfficialKeyMain), nil)
withBearerAuth(reqDefault, authorToken)
recDefault := httptest.NewRecorder()
handler.ServeHTTP(recDefault, reqDefault)
if recDefault.Code != http.StatusOK {
t.Fatalf("default plan: %d %s", recDefault.Code, recDefault.Body.String())
}
var plan projectinstance.WorkflowPlan
_ = json.Unmarshal(recDefault.Body.Bytes(), &plan)
ensureProjectMembers(t, pool, projectID, map[int64]string{
authorID: "owner",
reviewerID: "member",
approverID: "member",
memberID: "member",
})
for i := range plan.Stages {
for j := range plan.Stages[i].DocTasks {
plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
AuthorUserID: authorID, ReviewerUserID: reviewerID, ApproverUserID: approverID,
}
}
}
saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1})
reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody))
withBearerAuth(reqSave, authorToken)
recSave := httptest.NewRecorder()
handler.ServeHTTP(recSave, reqSave)
if recSave.Code != http.StatusOK {
t.Fatalf("save plan: %d %s", recSave.Code, recSave.Body.String())
}
reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil)
withBearerAuth(reqPreview, authorToken)
recPreview := httptest.NewRecorder()
handler.ServeHTTP(recPreview, reqPreview)
var preview projectinstance.ActivationPreview
_ = json.Unmarshal(recPreview.Body.Bytes(), &preview)
activateBody, _ := json.Marshal(projectinstance.ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody))
withBearerAuth(reqActivate, authorToken)
recActivate := httptest.NewRecorder()
handler.ServeHTTP(recActivate, reqActivate)
if recActivate.Code != http.StatusOK {
t.Fatalf("activate: %d %s", recActivate.Code, recActivate.Body.String())
}
// 1) non-author member upload (membership-only)
var bufUpload bytes.Buffer
mw := multipart.NewWriter(&bufUpload)
part, err := mw.CreateFormFile("file", "member-note.txt")
if err != nil {
t.Fatal(err)
}
_, _ = part.Write([]byte("member upload for summary"))
_ = mw.Close()
reqUp := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/assets", projectID), &bufUpload)
withBearerAuth(reqUp, memberToken)
reqUp.Header.Set("Content-Type", mw.FormDataContentType())
recUp := httptest.NewRecorder()
handler.ServeHTTP(recUp, reqUp)
if recUp.Code != http.StatusOK && recUp.Code != http.StatusCreated {
t.Fatalf("member upload: %d %s", recUp.Code, recUp.Body.String())
}
t.Log("✓ [1. upload] non-author member asset upload OK")
// 2) non-author member runs intake via real pi-agent
taskStart := time.Now()
taskBody, _ := json.Marshal(server.TaskRunRequestDTO{
TaskKey: "intake",
ProjectName: proj.Name,
DeviceType: proj.DeviceType,
IntendedUse: "Tissue resection",
TargetMarket: "NMPA Class III",
Backend: "pi-agent",
ProjectID: projectID,
AuthorID: authorID,
ReviewerID: reviewerID,
ApproverID: approverID,
Fields: map[string]string{
"customer_name": "NonAuthor Member Access",
"device_type": proj.DeviceType,
"intended_use": "Tissue resection",
"target_market": "NMPA Class III",
},
})
reqTask, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run", bytes.NewReader(taskBody))
withBearerAuth(reqTask, memberToken)
respTask, err := client.Do(reqTask)
if err != nil {
t.Fatalf("member pi-agent task run: %v", err)
}
defer respTask.Body.Close()
if respTask.StatusCode != http.StatusOK {
var buf bytes.Buffer
_, _ = buf.ReadFrom(respTask.Body)
t.Fatalf("member pi-agent task HTTP %d: %s", respTask.StatusCode, buf.String())
}
var raw json.RawMessage
_ = json.NewDecoder(respTask.Body).Decode(&raw)
var taskRes struct {
Success bool `json:"success"`
ErrorReason string `json:"error_reason"`
ArtifactTitle string `json:"artifact_title"`
RequiresAuthorApproval bool `json:"requires_author_approval"`
DocRecord *struct {
DocID string `json:"doc_id"`
} `json:"doc_record"`
}
_ = json.Unmarshal(raw, &taskRes)
runOK := taskRes.Success || taskRes.DocRecord != nil || taskRes.ArtifactTitle != "" || taskRes.RequiresAuthorApproval
if !runOK {
t.Fatalf("member pi-agent intake failed: body=%s", string(raw))
}
docID := fmt.Sprintf("doc_proj%d_intake", projectID)
if taskRes.DocRecord != nil && taskRes.DocRecord.DocID != "" {
docID = taskRes.DocRecord.DocID
}
t.Logf("✓ [2. run] non-author member pi-agent intake doc=%s success=%v title=%q pending_author=%v elapsed=%s",
docID, taskRes.Success, taskRes.ArtifactTitle, taskRes.RequiresAuthorApproval, time.Since(taskStart).Round(time.Second))
docEngine := bizdev.Snapshot(srv.BizdevService()).Gov
if _, err := docEngine.GetDocument(ctx, docID); err != nil {
_, err = docEngine.CreateOrGetDocument(ctx, docID, projectID, "intake", "用户初始需求收集表", "V0.1_triage", "",
authorID, "张工", reviewerID, "李主管", approverID, "王总监")
if err != nil {
t.Fatalf("ensure doc for edit/submit gates: %v", err)
}
t.Log("note: ensured document_record after pi run without doc_record")
}
// 3) non-author member saves draft content (edit)
saveContent, _ := json.Marshal(map[string]any{
"doc_id": docID,
"markdown_body": "# Member Draft\n\n非编制成员编写的草稿内容。\n",
})
reqContent := httptest.NewRequest(http.MethodPost, "/api/v1/docgov/content", bytes.NewReader(saveContent))
withBearerAuth(reqContent, memberToken)
recContent := httptest.NewRecorder()
handler.ServeHTTP(recContent, reqContent)
if recContent.Code != http.StatusOK {
t.Fatalf("member save content: %d %s", recContent.Code, recContent.Body.String())
}
t.Log("✓ [3. edit] non-author member SaveDocumentContent OK")
// 4) non-author member submit must be forbidden (duty gate)
submitBody, _ := json.Marshal(map[string]any{"doc_id": docID})
reqSubmit := httptest.NewRequest(http.MethodPost, "/api/v1/docgov/submit-review", bytes.NewReader(submitBody))
withBearerAuth(reqSubmit, memberToken)
recSubmit := httptest.NewRecorder()
handler.ServeHTTP(recSubmit, reqSubmit)
if recSubmit.Code != http.StatusForbidden {
t.Fatalf("member submit want 403, got %d %s", recSubmit.Code, recSubmit.Body.String())
}
if !strings.Contains(recSubmit.Body.String(), "DOC_DUTY_FORBIDDEN") &&
!strings.Contains(recSubmit.Body.String(), "职责") {
t.Fatalf("member submit body should cite duty denial: %s", recSubmit.Body.String())
}
t.Log("✓ [4. submit denied] non-author member submit → 403 DOC_DUTY_FORBIDDEN")
// Release member lease so author can submit.
holderID := fmt.Sprintf("%d", memberID)
_, _ = docEngine.ReleaseLease(ctx, docID, "human", holderID)
// 5) author submit succeeds
reqSubmitAuthor := httptest.NewRequest(http.MethodPost, "/api/v1/docgov/submit-review", bytes.NewReader(submitBody))
withBearerAuth(reqSubmitAuthor, authorToken)
recSubmitAuthor := httptest.NewRecorder()
handler.ServeHTTP(recSubmitAuthor, reqSubmitAuthor)
if recSubmitAuthor.Code != http.StatusOK {
_ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_member", false)
if _, err := docEngine.SubmitForReview(ctx, docID, authorID, "author", "Author"); err != nil {
t.Fatalf("author submit: http=%d body=%s eng=%v", recSubmitAuthor.Code, recSubmitAuthor.Body.String(), err)
}
}
t.Log("✓ [5. submit author] designated author submit OK")
}
+211
View File
@@ -1,8 +1,15 @@
package server
import (
"context"
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"time"
"chat-agent-system/internal/notification"
)
func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.Request) {
@@ -33,3 +40,207 @@ func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.R
"items": items,
})
}
// userNotificationView is the all-scope inbox row (includes severity / digest when migrated).
type userNotificationView struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
ProjectID int64 `json:"project_id"`
EventID int64 `json:"event_id,omitempty"`
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
Status string `json:"status"`
Severity string `json:"severity,omitempty"`
DigestID int64 `json:"digest_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Payload map[string]any `json:"payload,omitempty"`
}
// handleListUserNotifications GET /api/v1/notifications — cross-project inbox.
func (s *Server) handleListUserNotifications(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
if s.notifySvc == nil || !s.notifySvc.Available() {
writeSafeError(w, r, http.StatusServiceUnavailable, "notification service unavailable")
return
}
sinceID, _ := strconv.ParseInt(r.URL.Query().Get("since_id"), 10, 64)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
items, err := s.listAllScopeNotifications(r.Context(), user.ID, sinceID, limit)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "list notifications failed: "+err.Error())
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"items": items,
})
}
func (s *Server) listAllScopeNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) {
// Prefer free-collab ListUserNotifications when the method exists on *notification.Service.
type userLister interface {
ListUserNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]notification.Notification, error)
}
if lister, ok := any(s.notifySvc).(userLister); ok {
raw, err := lister.ListUserNotifications(ctx, userID, sinceID, limit)
if err != nil {
return nil, err
}
out := make([]userNotificationView, 0, len(raw))
for _, n := range raw {
out = append(out, userNotificationViewFrom(n))
}
return out, nil
}
// Direct SQL via runtime pool when severity columns are available.
if s.rt != nil && s.rt.Pool != nil {
items, err := s.queryUserNotifications(ctx, userID, sinceID, limit)
if err == nil {
return items, nil
}
// Fall through to project fan-out if columns are missing pre-migration.
}
return s.fanOutProjectNotifications(ctx, userID, sinceID, limit)
}
func userNotificationViewFrom(n notification.Notification) userNotificationView {
var v userNotificationView
raw, err := json.Marshal(n)
if err != nil {
return userNotificationView{
ID: n.ID, UserID: n.UserID, ProjectID: n.ProjectID, EventID: n.EventID,
Type: n.Type, Title: n.Title, Body: n.Body, Status: n.Status,
CreatedAt: n.CreatedAt,
}
}
_ = json.Unmarshal(raw, &v)
return v
}
func (s *Server) queryUserNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) {
query := `
SELECT n.id, n.user_id, n.project_id, COALESCE(n.event_id, 0), n.type, n.title, n.body, n.status,
COALESCE(n.severity, 'info'), COALESCE(n.digest_id, 0), n.created_at,
COALESCE(e.payload, '{}'::jsonb),
COALESCE(d.doc_id, ''),
COALESCE((
SELECT elem->>'proposal_id'
FROM jsonb_array_elements(COALESCE(d.item_summaries, '[]'::jsonb)) WITH ORDINALITY AS t(elem, ord)
WHERE COALESCE(elem->>'proposal_id', '') <> ''
ORDER BY ord DESC
LIMIT 1
), '')
FROM notifications n
LEFT JOIN outbox_events e ON e.id = n.event_id
LEFT JOIN notification_digests d ON d.id = n.digest_id
WHERE n.user_id = $1
`
args := []any{userID}
if sinceID > 0 {
query += ` AND n.id > $2`
args = append(args, sinceID)
}
query += ` ORDER BY CASE WHEN COALESCE(n.severity, 'info') = 'action_required' THEN 0 ELSE 1 END, n.id DESC`
query += ` LIMIT $` + strconv.Itoa(len(args)+1)
args = append(args, limit)
rows, err := s.rt.Pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]userNotificationView, 0)
for rows.Next() {
var n userNotificationView
var payloadRaw []byte
var digestDocID, digestProposalID string
if err := rows.Scan(
&n.ID, &n.UserID, &n.ProjectID, &n.EventID, &n.Type, &n.Title, &n.Body, &n.Status,
&n.Severity, &n.DigestID, &n.CreatedAt, &payloadRaw, &digestDocID, &digestProposalID,
); err != nil {
return nil, err
}
if len(payloadRaw) > 0 {
_ = json.Unmarshal(payloadRaw, &n.Payload)
}
if n.Payload == nil {
n.Payload = map[string]any{}
}
if digestDocID != "" {
if _, ok := n.Payload["doc_id"]; !ok || n.Payload["doc_id"] == "" || n.Payload["doc_id"] == nil {
n.Payload["doc_id"] = digestDocID
}
}
if digestProposalID != "" {
if _, ok := n.Payload["proposal_id"]; !ok || n.Payload["proposal_id"] == "" || n.Payload["proposal_id"] == nil {
n.Payload["proposal_id"] = digestProposalID
}
}
out = append(out, n)
}
return out, rows.Err()
}
func (s *Server) fanOutProjectNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) {
if s.pwSvc == nil {
return []userNotificationView{}, nil
}
projects, err := s.pwSvc.ListProjects(ctx, userID)
if err != nil {
return nil, err
}
raw := make([]notification.Notification, 0)
per := limit
if per < 20 {
per = 20
}
for _, p := range projects {
if p == nil {
continue
}
chunk, listErr := s.notifySvc.ListProjectNotifications(ctx, userID, p.ID, sinceID, per)
if listErr != nil {
continue
}
raw = append(raw, chunk...)
}
sort.SliceStable(raw, func(i, j int) bool {
si, sj := inboxSeverityRank(raw[i].Type), inboxSeverityRank(raw[j].Type)
if si != sj {
return si < sj
}
return raw[i].ID > raw[j].ID
})
if len(raw) > limit {
raw = raw[:limit]
}
out := make([]userNotificationView, 0, len(raw))
for _, n := range raw {
v := userNotificationViewFrom(n)
if inboxSeverityRank(n.Type) == 0 {
v.Severity = "action_required"
} else if v.Severity == "" {
v.Severity = "info"
}
out = append(out, v)
}
return out, nil
}
func inboxSeverityRank(typ string) int {
if strings.Contains(typ, "proposal_required") {
return 0
}
return 1
}
@@ -0,0 +1,55 @@
package server
import (
"testing"
"time"
"chat-agent-system/internal/notification"
)
func TestInboxSeverityRank(t *testing.T) {
if inboxSeverityRank("proposal_required") != 0 {
t.Fatalf("proposal_required should rank first")
}
if inboxSeverityRank("stage_ready_to_advance") != 1 {
t.Fatalf("stage ready should be info rank")
}
}
func TestUserNotificationViewFromCopiesCoreFields(t *testing.T) {
n := notification.Notification{
ID: 9, UserID: 2, ProjectID: 3, Type: "stage_ready_to_advance",
Title: "t", Body: "b", Status: "unread", CreatedAt: time.Unix(1, 0).UTC(),
}
v := userNotificationViewFrom(n)
if v.ID != 9 || v.ProjectID != 3 || v.Title != "t" {
t.Fatalf("unexpected view: %+v", v)
}
}
func TestFanOutSortPrefersActionTypes(t *testing.T) {
raw := []notification.Notification{
{ID: 1, Type: "stage_ready_to_advance"},
{ID: 2, Type: "proposal_required"},
{ID: 3, Type: "stage_ready_to_advance"},
}
// mimic fanOut sort
si := func(i, j int) bool {
a, b := inboxSeverityRank(raw[i].Type), inboxSeverityRank(raw[j].Type)
if a != b {
return a < b
}
return raw[i].ID > raw[j].ID
}
// bubble
for i := 0; i < len(raw); i++ {
for j := i + 1; j < len(raw); j++ {
if si(j, i) {
raw[i], raw[j] = raw[j], raw[i]
}
}
}
if raw[0].Type != "proposal_required" {
t.Fatalf("expected proposal first, got %+v", raw)
}
}
+1
View File
@@ -160,6 +160,7 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R
return
}
s.maybeAutoClaimOnView(r.Context(), user, docID)
if inm := r.Header.Get("If-None-Match"); inm != "" && inm == view.ContentSHA256 {
w.WriteHeader(http.StatusNotModified)
return
+32 -27
View File
@@ -53,6 +53,7 @@ type Server struct {
wfDefSvc *workflowdef.Service
instSvc *projectinstance.Service
notifySvc *notification.Service
supplementImpact SupplementImpactService
resolver *workspace.Resolver
wsCap *workspace.Capability
cmds *taskrun.Commands
@@ -92,33 +93,34 @@ func NewFromRuntime(rt *app.Runtime) *Server {
panic("server: nil runtime")
}
s := &Server{
rt: rt,
cfg: rt.Cfg,
svc: rt.Biz,
pwSvc: rt.PW,
rec: rt.Rec,
engine: rt.Engine,
authSvc: rt.Auth,
sessions: make(map[string]*chat.Session),
taskRuns: rt.TaskRuns,
authorizer: rt.Authorizer,
authzSvc: rt.Authz,
wfDefSvc: rt.WFDef,
instSvc: rt.Inst,
notifySvc: rt.Notify,
resolver: rt.Resolver,
wsCap: rt.WSCap,
cmds: rt.Commands,
stageflow: rt.Stageflow,
gov: rt.Gov,
rev: rt.Rev,
ctxMgr: rt.Ctx,
cas: rt.CAS,
freeDoc: rt.FreeDoc,
kb: rt.KB,
sysadmin: rt.SysAdmin,
wsReg: workspaceRegistryFor(rt.Cfg),
intentPlans: rt.IntentPlans,
rt: rt,
cfg: rt.Cfg,
svc: rt.Biz,
pwSvc: rt.PW,
rec: rt.Rec,
engine: rt.Engine,
authSvc: rt.Auth,
sessions: make(map[string]*chat.Session),
taskRuns: rt.TaskRuns,
authorizer: rt.Authorizer,
authzSvc: rt.Authz,
wfDefSvc: rt.WFDef,
instSvc: rt.Inst,
notifySvc: rt.Notify,
supplementImpact: newSupplementImpactAdapter(rt.SupplementImpact),
resolver: rt.Resolver,
wsCap: rt.WSCap,
cmds: rt.Commands,
stageflow: rt.Stageflow,
gov: rt.Gov,
rev: rt.Rev,
ctxMgr: rt.Ctx,
cas: rt.CAS,
freeDoc: rt.FreeDoc,
kb: rt.KB,
sysadmin: rt.SysAdmin,
wsReg: workspaceRegistryFor(rt.Cfg),
intentPlans: rt.IntentPlans,
// 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口
loginLimiter: newLoginRateLimiter(10, 5*time.Minute),
}
@@ -225,6 +227,9 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /api/v1/projects/{id}/trace/recheck/{markID}/clear", s.handleProjectTraceRecheckClear)
mux.HandleFunc("GET /api/v1/projects/{id}/documents", s.handleListProjectWorkflowDocuments)
mux.HandleFunc("GET /api/v1/projects/{id}/notifications", s.handleListProjectNotifications)
mux.HandleFunc("GET /api/v1/notifications", s.handleListUserNotifications)
mux.HandleFunc("GET /api/v1/supplement-impacts/{batch_id}", s.handleGetSupplementImpact)
mux.HandleFunc("POST /api/v1/supplement-impacts/{batch_id}/decision", s.handleDecideSupplementImpact)
// 3. 对话协同服务 (标准 JSON 阻塞)
mux.HandleFunc("POST /api/v1/chat", s.handleChat)
+3 -1
View File
@@ -233,7 +233,9 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
ctx := stream.WithRequestID(r.Context(), requestID)
s.enrichSessionWorkflowStage(ctx, sess, req.UserID)
if sess.Project() > 0 && !s.allowActiveStageChatAccess(ctx, sess.Project(), req.UserID) {
// Free-lane: project members may always *initiate* chat. Stage ABAC still
// filters history reads and controlled-doc writes elsewhere.
if sess.Project() > 0 && !s.allowFreeLaneChatInitiation(sess.Project(), req.UserID) {
writeWorkflowAccessDeniedSSE(w, flusher, req.SessionID)
return
}
@@ -0,0 +1,58 @@
package server
import (
"chat-agent-system/internal/supplementimpact"
"context"
)
type supplementImpactAdapter struct {
svc *supplementimpact.Service
}
func newSupplementImpactAdapter(svc *supplementimpact.Service) SupplementImpactService {
if svc == nil {
return nil
}
return supplementImpactAdapter{svc: svc}
}
func (a supplementImpactAdapter) GetBatch(ctx context.Context, batchID string) (*SupplementImpactBatchView, error) {
b, err := a.svc.GetBatch(ctx, batchID)
if err != nil {
return nil, err
}
return mapSupplementBatch(b), nil
}
func (a supplementImpactAdapter) Decide(ctx context.Context, batchID string, actorUserID int64, in SupplementImpactDecisionRequest) (*SupplementImpactBatchView, error) {
b, err := a.svc.Decide(ctx, supplementimpact.DecisionCommand{
BatchID: batchID, ActorUserID: actorUserID, Kind: in.Kind,
SelectedDocIDs: in.SelectedDocIDs, DecisionNonce: in.DecisionNonce,
DecisionDigest: in.DecisionDigest, ExpectedRevision: in.ExpectedRevision,
})
if err != nil {
return nil, err
}
return mapSupplementBatch(b), nil
}
func mapSupplementBatch(b *supplementimpact.Batch) *SupplementImpactBatchView {
if b == nil {
return nil
}
out := &SupplementImpactBatchView{
BatchID: b.BatchID, ProjectID: b.ProjectID, SessionID: b.SessionID,
InitiatorUserID: b.InitiatorUserID, SourceKind: b.SourceKind, SourceSummary: b.SourceSummary,
CoverageStatus: b.CoverageStatus, Status: b.Status, DecisionNonce: b.DecisionNonce,
DecisionDigest: b.DecisionDigest, Revision: b.Revision, ExpiresAt: b.ExpiresAt,
}
for _, it := range b.Items {
out.Items = append(out.Items, SupplementImpactItemView{
DocID: it.DocID, Title: it.Title, ItemKey: it.ItemKey, ImpactType: it.ImpactType,
Depth: it.Depth, Reason: it.Reason, AuthorClaimedSnapshot: it.AuthorClaimedSnapshot,
BusySnapshot: it.BusySnapshot, ChangeSummary: it.ChangeSummary, Selected: it.Selected,
Outcome: it.Outcome, ProposalID: it.ProposalID,
})
}
return out
}
@@ -0,0 +1,162 @@
package server
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
)
// SupplementImpactService is the HTTP-facing port for supplement impact batches.
// Satisfied by an adapter over *supplementimpact.Service when wired; nil → 503.
type SupplementImpactService interface {
GetBatch(ctx context.Context, batchID string) (*SupplementImpactBatchView, error)
Decide(ctx context.Context, batchID string, actorUserID int64, in SupplementImpactDecisionRequest) (*SupplementImpactBatchView, error)
}
// SupplementImpactBatchView is the card-refresh / decision response shape.
type SupplementImpactBatchView struct {
BatchID string `json:"batch_id"`
ProjectID int64 `json:"project_id"`
SessionID string `json:"session_id,omitempty"`
InitiatorUserID int64 `json:"initiator_user_id"`
SourceKind string `json:"source_kind,omitempty"`
SourceSummary string `json:"source_summary,omitempty"`
CoverageStatus string `json:"coverage_status,omitempty"`
Status string `json:"status"`
DecisionNonce string `json:"decision_nonce"`
DecisionDigest string `json:"decision_digest,omitempty"`
Revision int64 `json:"revision"`
ExpiresAt time.Time `json:"expires_at"`
Items []SupplementImpactItemView `json:"items,omitempty"`
}
// SupplementImpactItemView is one affected document row on the confirm card.
type SupplementImpactItemView struct {
DocID string `json:"doc_id"`
Title string `json:"title,omitempty"`
ItemKey string `json:"item_key,omitempty"`
ImpactType string `json:"impact_type,omitempty"`
Depth int `json:"depth,omitempty"`
Reason string `json:"reason,omitempty"`
AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"`
BusySnapshot bool `json:"busy_snapshot"`
ChangeSummary string `json:"change_summary,omitempty"`
Selected bool `json:"selected"`
Outcome string `json:"outcome,omitempty"`
ProposalID string `json:"proposal_id,omitempty"`
}
// SupplementImpactDecisionRequest is the confirm/cancel CAS envelope.
type SupplementImpactDecisionRequest struct {
Kind string `json:"kind"`
SelectedDocIDs []string `json:"selected_doc_ids"`
DecisionNonce string `json:"decision_nonce"`
DecisionDigest string `json:"decision_digest"`
ExpectedRevision int64 `json:"expected_revision"`
}
func (s *Server) handleGetSupplementImpact(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
if s.supplementImpact == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "supplement impact service unavailable")
return
}
batchID := strings.TrimSpace(r.PathValue("batch_id"))
if batchID == "" {
writeSafeError(w, r, http.StatusBadRequest, "invalid batch id")
return
}
batch, err := s.supplementImpact.GetBatch(r.Context(), batchID)
if err != nil || batch == nil {
writeSafeError(w, r, http.StatusNotFound, "batch not found")
return
}
if !s.userCanAccessSupplementBatch(r.Context(), user.ID, batch.ProjectID) {
writeSafeError(w, r, http.StatusForbidden, "not a project member")
return
}
writeJSONResponse(w, http.StatusOK, batch)
}
func (s *Server) handleDecideSupplementImpact(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
if s.supplementImpact == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "supplement impact service unavailable")
return
}
batchID := strings.TrimSpace(r.PathValue("batch_id"))
if batchID == "" {
writeSafeError(w, r, http.StatusBadRequest, "invalid batch id")
return
}
var body SupplementImpactDecisionRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid body")
return
}
kind := strings.TrimSpace(strings.ToLower(body.Kind))
if kind != "confirm" && kind != "cancel" {
writeSafeError(w, r, http.StatusBadRequest, "kind must be confirm or cancel")
return
}
if strings.TrimSpace(body.DecisionNonce) == "" || strings.TrimSpace(body.DecisionDigest) == "" {
writeSafeError(w, r, http.StatusBadRequest, "decision_nonce and decision_digest required")
return
}
body.Kind = kind
// Load batch first for membership check via project_id.
existing, err := s.supplementImpact.GetBatch(r.Context(), batchID)
if err != nil || existing == nil {
writeSafeError(w, r, http.StatusNotFound, "batch not found")
return
}
if !s.userCanAccessSupplementBatch(r.Context(), user.ID, existing.ProjectID) {
writeSafeError(w, r, http.StatusForbidden, "not a project member")
return
}
batch, err := s.supplementImpact.Decide(r.Context(), batchID, user.ID, body)
if err != nil {
msg := err.Error()
status := http.StatusBadRequest
if strings.Contains(msg, "revision conflict") {
status = http.StatusConflict
} else if strings.Contains(msg, "only initiator") {
status = http.StatusForbidden
} else if strings.Contains(msg, "expired") {
status = http.StatusGone
} else if strings.Contains(msg, "not pending") {
status = http.StatusConflict
}
writeSafeError(w, r, status, msg)
return
}
writeJSONResponse(w, http.StatusOK, batch)
}
func (s *Server) userCanAccessSupplementBatch(ctx context.Context, userID, projectID int64) bool {
if projectID <= 0 || userID <= 0 {
return false
}
if s.authorizer != nil {
ok, err := s.authorizer.IsProjectMember(ctx, projectID, userID)
if err == nil && ok {
return true
}
}
if s.pwSvc != nil {
if isCreator, err := s.pwSvc.IsProjectCreator(ctx, projectID, userID); err == nil && isCreator {
return true
}
}
return false
}
+6 -1
View File
@@ -454,7 +454,12 @@ func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRu
if doc.LeaseExpiresAt != nil {
expiresAt = *doc.LeaseExpiresAt
}
reason := fmt.Sprintf("文档【%s】正在由 %s(%s) 编辑中,当前请求未创建新任务", doc.Title, doc.LeaseHolderType, doc.LeaseHolderID)
display := docgov.LeaseHolderDisplay(doc.LeaseHolderType, doc.LeaseHolderID, "")
exp := time.Time{}
if doc.LeaseExpiresAt != nil {
exp = *doc.LeaseExpiresAt
}
reason := docgov.FormatDocumentBusyReason(doc.Title, display, exp)
return &docgov.DocumentBusyError{
DocID: doc.DocID,
HolderType: doc.LeaseHolderType,
+114
View File
@@ -0,0 +1,114 @@
package supplementimpact
import (
"context"
"fmt"
"time"
)
// HTTPBatchView is the JSON shape expected by server supplement-impact handlers.
type HTTPBatchView struct {
BatchID string `json:"batch_id"`
ProjectID int64 `json:"project_id"`
SessionID string `json:"session_id,omitempty"`
InitiatorUserID int64 `json:"initiator_user_id"`
SourceKind string `json:"source_kind,omitempty"`
SourceSummary string `json:"source_summary,omitempty"`
CoverageStatus string `json:"coverage_status,omitempty"`
Status string `json:"status"`
DecisionNonce string `json:"decision_nonce"`
DecisionDigest string `json:"decision_digest,omitempty"`
Revision int64 `json:"revision"`
ExpiresAt time.Time `json:"expires_at"`
Items []HTTPItemView `json:"items,omitempty"`
}
type HTTPItemView struct {
DocID string `json:"doc_id"`
Title string `json:"title,omitempty"`
ItemKey string `json:"item_key,omitempty"`
ImpactType string `json:"impact_type,omitempty"`
Depth int `json:"depth,omitempty"`
Reason string `json:"reason,omitempty"`
AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"`
BusySnapshot bool `json:"busy_snapshot"`
ChangeSummary string `json:"change_summary,omitempty"`
Selected bool `json:"selected"`
Outcome string `json:"outcome,omitempty"`
ProposalID string `json:"proposal_id,omitempty"`
}
// HTTPDecision is the CAS decision envelope from HTTP.
type HTTPDecision struct {
Kind string
SelectedDocIDs []string
DecisionNonce string
DecisionDigest string
ExpectedRevision int64
}
// HTTPPort adapts *Service to the server SupplementImpactService port.
type HTTPPort struct{ Svc *Service }
func (p HTTPPort) GetBatch(ctx context.Context, batchID string) (*HTTPBatchView, error) {
if p.Svc == nil || p.Svc.Store == nil {
return nil, fmt.Errorf("supplementimpact: not configured")
}
b, err := p.Svc.Store.GetBatch(ctx, batchID)
if err != nil {
return nil, err
}
return toHTTPView(b), nil
}
func (p HTTPPort) Decide(ctx context.Context, batchID string, actorUserID int64, in HTTPDecision) (*HTTPBatchView, error) {
if p.Svc == nil {
return nil, fmt.Errorf("supplementimpact: not configured")
}
b, err := p.Svc.Decide(ctx, DecisionCommand{
BatchID: batchID, ActorUserID: actorUserID, Kind: in.Kind,
SelectedDocIDs: in.SelectedDocIDs, DecisionNonce: in.DecisionNonce,
DecisionDigest: in.DecisionDigest, ExpectedRevision: in.ExpectedRevision,
})
if err != nil {
return nil, err
}
return toHTTPView(b), nil
}
func toHTTPView(b *Batch) *HTTPBatchView {
if b == nil {
return nil
}
v := &HTTPBatchView{
BatchID: b.BatchID, ProjectID: b.ProjectID, SessionID: b.SessionID,
InitiatorUserID: b.InitiatorUserID, SourceKind: b.SourceKind, SourceSummary: b.SourceSummary,
CoverageStatus: b.CoverageStatus, Status: b.Status, DecisionNonce: b.DecisionNonce,
DecisionDigest: b.DecisionDigest, Revision: b.Revision, ExpiresAt: b.ExpiresAt,
}
for _, it := range b.Items {
v.Items = append(v.Items, HTTPItemView{
DocID: it.DocID, Title: it.Title, ItemKey: it.ItemKey, ImpactType: it.ImpactType,
Depth: it.Depth, Reason: it.Reason, AuthorClaimedSnapshot: it.AuthorClaimedSnapshot,
BusySnapshot: it.BusySnapshot, ChangeSummary: it.ChangeSummary, Selected: it.Selected,
Outcome: it.Outcome, ProposalID: it.ProposalID,
})
}
return v
}
// BuildConfirmCardJSON returns a lightweight map for coordinator InteractiveCardPayload.ProposedContent.
func BuildConfirmCardMeta(b *Batch) (title, summary string) {
if b == nil {
return "补充影响确认", ""
}
n := len(b.Items)
switch b.CoverageStatus {
case "index_pending", "partial", "failed":
return "补充影响分析未完成", fmt.Sprintf("覆盖状态:%s(不得视为无影响)。批次 %s", b.CoverageStatus, b.BatchID)
}
if n == 0 {
return "补充已记录", fmt.Sprintf("暂无文档受影响。批次 %s", b.BatchID)
}
return fmt.Sprintf("本次补充可能影响 %d 份文档", n), fmt.Sprintf("请勾选要更新的文档。批次 %s;nonce=%s;rev=%d", b.BatchID, b.DecisionNonce, b.Revision)
}
+74
View File
@@ -0,0 +1,74 @@
package supplementimpact
import (
"context"
"fmt"
"sync"
)
type MemStore struct {
mu sync.Mutex
batches map[string]*Batch
}
func NewMemStore() *MemStore {
return &MemStore{batches: map[string]*Batch{}}
}
func (m *MemStore) InsertBatch(_ context.Context, b *Batch) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *b
cp.Items = append([]Item{}, b.Items...)
m.batches[b.BatchID] = &cp
return nil
}
func (m *MemStore) GetBatch(_ context.Context, batchID string) (*Batch, error) {
m.mu.Lock()
defer m.mu.Unlock()
b, ok := m.batches[batchID]
if !ok {
return nil, fmt.Errorf("supplementimpact: batch not found")
}
cp := *b
cp.Items = append([]Item{}, b.Items...)
return &cp, nil
}
func (m *MemStore) SaveDecision(_ context.Context, batchID string, revision int64, nonce, digest, status string, selected map[string]bool) error {
m.mu.Lock()
defer m.mu.Unlock()
b, ok := m.batches[batchID]
if !ok {
return fmt.Errorf("supplementimpact: batch not found")
}
b.Status = status
b.DecisionNonce = nonce
b.DecisionDigest = digest
b.Revision = revision
for i := range b.Items {
if selected != nil {
b.Items[i].Selected = selected[b.Items[i].DocID]
}
}
return nil
}
func (m *MemStore) UpdateItemOutcome(_ context.Context, batchID, docID, outcome, proposalID, runID string) error {
m.mu.Lock()
defer m.mu.Unlock()
b, ok := m.batches[batchID]
if !ok {
return fmt.Errorf("supplementimpact: batch not found")
}
for i := range b.Items {
if b.Items[i].DocID == docID {
b.Items[i].Outcome = outcome
b.Items[i].ProposalID = proposalID
b.Items[i].RunID = runID
return nil
}
}
return fmt.Errorf("supplementimpact: item not found")
}
+173
View File
@@ -0,0 +1,173 @@
package supplementimpact
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// PGStore persists supplement-impact batches in PostgreSQL (migration 00070).
type PGStore struct {
pool *pgxpool.Pool
}
// NewPGStore builds a Store backed by pool.
func NewPGStore(pool *pgxpool.Pool) *PGStore {
return &PGStore{pool: pool}
}
func (p *PGStore) InsertBatch(ctx context.Context, b *Batch) error {
if p == nil || p.pool == nil {
return fmt.Errorf("supplementimpact: pg store not available")
}
if b == nil {
return fmt.Errorf("supplementimpact: nil batch")
}
tx, err := p.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO supplement_impact_batches (
batch_id, project_id, session_id, initiator_user_id, source_kind, source_summary,
coverage_status, status, decision_nonce, decision_digest, revision, expires_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
`, b.BatchID, b.ProjectID, b.SessionID, b.InitiatorUserID, b.SourceKind, b.SourceSummary,
b.CoverageStatus, b.Status, b.DecisionNonce, b.DecisionDigest, b.Revision, b.ExpiresAt)
if err != nil {
return err
}
for i := range b.Items {
it := &b.Items[i]
err = tx.QueryRow(ctx, `
INSERT INTO supplement_impact_items (
batch_id, doc_id, title, item_key, impact_type, depth, reason,
author_claimed_snapshot, author_id_snapshot, busy_snapshot, change_summary,
selected, outcome, proposal_id, run_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING id
`, it.BatchID, it.DocID, it.Title, it.ItemKey, it.ImpactType, it.Depth, it.Reason,
it.AuthorClaimedSnapshot, it.AuthorIDSnapshot, it.BusySnapshot, it.ChangeSummary,
it.Selected, it.Outcome, it.ProposalID, it.RunID).Scan(&it.ID)
if err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (p *PGStore) GetBatch(ctx context.Context, batchID string) (*Batch, error) {
if p == nil || p.pool == nil {
return nil, fmt.Errorf("supplementimpact: pg store not available")
}
var b Batch
err := p.pool.QueryRow(ctx, `
SELECT batch_id, project_id, session_id, initiator_user_id, source_kind, source_summary,
coverage_status, status, decision_nonce, decision_digest, revision, expires_at
FROM supplement_impact_batches WHERE batch_id = $1
`, batchID).Scan(
&b.BatchID, &b.ProjectID, &b.SessionID, &b.InitiatorUserID, &b.SourceKind, &b.SourceSummary,
&b.CoverageStatus, &b.Status, &b.DecisionNonce, &b.DecisionDigest, &b.Revision, &b.ExpiresAt,
)
if err != nil {
return nil, fmt.Errorf("supplementimpact: batch not found: %w", err)
}
rows, err := p.pool.Query(ctx, `
SELECT id, batch_id, doc_id, title, item_key, impact_type, depth, reason,
author_claimed_snapshot, author_id_snapshot, busy_snapshot, change_summary,
selected, outcome, proposal_id, run_id
FROM supplement_impact_items WHERE batch_id = $1 ORDER BY id
`, batchID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var it Item
if err := rows.Scan(
&it.ID, &it.BatchID, &it.DocID, &it.Title, &it.ItemKey, &it.ImpactType, &it.Depth, &it.Reason,
&it.AuthorClaimedSnapshot, &it.AuthorIDSnapshot, &it.BusySnapshot, &it.ChangeSummary,
&it.Selected, &it.Outcome, &it.ProposalID, &it.RunID,
); err != nil {
return nil, err
}
b.Items = append(b.Items, it)
}
return &b, rows.Err()
}
func (p *PGStore) SaveDecision(ctx context.Context, batchID string, revision int64, nonce, digest, status string, selected map[string]bool) error {
if p == nil || p.pool == nil {
return fmt.Errorf("supplementimpact: pg store not available")
}
tx, err := p.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `
UPDATE supplement_impact_batches
SET status = $1, decision_nonce = $2, decision_digest = $3, revision = $4, updated_at = now()
WHERE batch_id = $5
`, status, nonce, digest, revision, batchID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("supplementimpact: batch not found")
}
if selected != nil {
rows, qerr := tx.Query(ctx, `SELECT id, doc_id FROM supplement_impact_items WHERE batch_id = $1`, batchID)
if qerr != nil {
return qerr
}
type pair struct {
id int64
docID string
}
var pairs []pair
for rows.Next() {
var pr pair
if err := rows.Scan(&pr.id, &pr.docID); err != nil {
rows.Close()
return err
}
pairs = append(pairs, pr)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, pr := range pairs {
_, err = tx.Exec(ctx, `
UPDATE supplement_impact_items SET selected = $1, updated_at = now() WHERE id = $2
`, selected[pr.docID], pr.id)
if err != nil {
return err
}
}
}
return tx.Commit(ctx)
}
func (p *PGStore) UpdateItemOutcome(ctx context.Context, batchID, docID, outcome, proposalID, runID string) error {
if p == nil || p.pool == nil {
return fmt.Errorf("supplementimpact: pg store not available")
}
tag, err := p.pool.Exec(ctx, `
UPDATE supplement_impact_items
SET outcome = $1, proposal_id = $2, run_id = $3, updated_at = now()
WHERE batch_id = $4 AND doc_id = $5
`, outcome, proposalID, runID, batchID, docID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("supplementimpact: item not found")
}
return nil
}
+238
View File
@@ -0,0 +1,238 @@
package supplementimpact
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/traceimpact"
)
// DocLookup resolves document claim/lease snapshots.
type DocLookup interface {
GetDocument(ctx context.Context, docID string) (*docgov.DocumentRecord, error)
}
// Analyzer runs traceimpact analysis.
type Analyzer interface {
Analyze(ctx context.Context, projectID int64, source traceimpact.DocRef, others []traceimpact.DocRef) (*traceimpact.Report, error)
}
// Store persists batches.
type Store interface {
InsertBatch(ctx context.Context, b *Batch) error
GetBatch(ctx context.Context, batchID string) (*Batch, error)
SaveDecision(ctx context.Context, batchID string, revision int64, nonce, digest, status string, selected map[string]bool) error
UpdateItemOutcome(ctx context.Context, batchID, docID, outcome, proposalID, runID string) error
}
// Notifier emits claim-aware digests.
type Notifier interface {
UpsertDocumentDigest(ctx context.Context, p notification.UpsertDigestParams) error
}
// Proposer creates docgov change proposals for claimed docs.
type Proposer interface {
ProposeModification(ctx context.Context, docID, triggeredBy, changes, impact, risk string) (*docgov.ChangeProposal, error)
}
// Service orchestrates supplement → impact → confirm → split execution.
type Service struct {
Store Store
Docs DocLookup
Analyze Analyzer
Notify Notifier
Propose Proposer
Now func() time.Time
}
func (s *Service) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now().UTC()
}
// CreateFromSupplement runs impact analysis and opens a confirmation batch.
func (s *Service) GetBatch(ctx context.Context, batchID string) (*Batch, error) {
if s == nil || s.Store == nil {
return nil, fmt.Errorf("supplementimpact: not configured")
}
return s.Store.GetBatch(ctx, batchID)
}
func (s *Service) CreateFromSupplement(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) (*Batch, error) {
if s == nil || s.Store == nil || s.Analyze == nil {
return nil, fmt.Errorf("supplementimpact: not configured")
}
report, err := s.Analyze.Analyze(ctx, projectID, traceimpact.DocRef{DocID: "supplement", Markdown: markdown}, nil)
if err != nil {
return nil, err
}
coverage := "no_known_dependencies"
if report != nil && report.CoverageStatus != "" {
coverage = string(report.CoverageStatus)
}
// Never present index_pending/partial/failed as "no impact".
switch coverage {
case "index_pending", "partial", "failed":
// keep as-is for the card
default:
}
batchID := "sib_" + randomHex(8)
nonce := randomHex(16)
now := s.now()
b := &Batch{
BatchID: batchID, ProjectID: projectID, SessionID: sessionID,
InitiatorUserID: userID, SourceKind: "fact_supplement",
SourceSummary: truncate(summary, 120), CoverageStatus: coverage,
Status: StatusPending, DecisionNonce: nonce, Revision: 1,
ExpiresAt: now.Add(BatchTTL),
}
seen := map[string]struct{}{}
if report != nil {
for _, imp := range report.Impacts {
if imp.DocID == "" {
continue
}
if _, ok := seen[imp.DocID]; ok {
continue
}
seen[imp.DocID] = struct{}{}
item := Item{
BatchID: batchID, DocID: imp.DocID, ItemKey: imp.ItemKey,
ImpactType: string(imp.ImpactType), Depth: imp.Depth, Reason: imp.Reason,
ChangeSummary: truncate(imp.Reason, 80),
}
if s.Docs != nil {
if doc, err := s.Docs.GetDocument(ctx, imp.DocID); err == nil && doc != nil {
item.Title = doc.Title
item.AuthorClaimedSnapshot = doc.AuthorClaimed
item.AuthorIDSnapshot = doc.AuthorID
item.BusySnapshot = doc.HasActiveLease(now)
}
}
b.Items = append(b.Items, item)
}
}
if err := s.Store.InsertBatch(ctx, b); err != nil {
return nil, err
}
return b, nil
}
// Decide confirms or cancels a batch; on confirm, executes selected items.
func (s *Service) Decide(ctx context.Context, cmd DecisionCommand) (*Batch, error) {
b, err := s.Store.GetBatch(ctx, cmd.BatchID)
if err != nil {
return nil, err
}
if b.Status != StatusPending {
return nil, fmt.Errorf("supplementimpact: batch not pending")
}
if s.now().After(b.ExpiresAt) {
_ = s.Store.SaveDecision(ctx, b.BatchID, b.Revision, cmd.DecisionNonce, cmd.DecisionDigest, StatusExpired, nil)
return nil, fmt.Errorf("supplementimpact: batch expired")
}
if cmd.ExpectedRevision != b.Revision {
return nil, fmt.Errorf("supplementimpact: revision conflict")
}
if cmd.ActorUserID != b.InitiatorUserID {
return nil, fmt.Errorf("supplementimpact: only initiator may decide")
}
if cmd.Kind == "cancel" {
if err := s.Store.SaveDecision(ctx, b.BatchID, b.Revision, cmd.DecisionNonce, cmd.DecisionDigest, StatusCancelled, nil); err != nil {
return nil, err
}
return s.Store.GetBatch(ctx, cmd.BatchID)
}
selected := map[string]bool{}
for _, id := range cmd.SelectedDocIDs {
selected[id] = true
}
if err := s.Store.SaveDecision(ctx, b.BatchID, b.Revision, cmd.DecisionNonce, cmd.DecisionDigest, StatusRunning, selected); err != nil {
return nil, err
}
b, _ = s.Store.GetBatch(ctx, cmd.BatchID)
for i := range b.Items {
it := &b.Items[i]
if !selected[it.DocID] {
continue
}
if it.BusySnapshot {
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeSkippedBusy, "", "")
continue
}
if err := s.executeItem(ctx, b, it); err != nil {
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeFailed, "", "")
}
}
final := StatusDone
b, _ = s.Store.GetBatch(ctx, cmd.BatchID)
for _, it := range b.Items {
if !it.Selected {
continue
}
switch it.Outcome {
case OutcomeSkippedBusy, OutcomeSkippedLocked, OutcomeFailed, OutcomeProposalPending:
final = StatusPartiallyDone
}
}
_ = s.Store.SaveDecision(ctx, b.BatchID, b.Revision+1, cmd.DecisionNonce, cmd.DecisionDigest, final, selected)
return s.Store.GetBatch(ctx, cmd.BatchID)
}
func (s *Service) executeItem(ctx context.Context, b *Batch, it *Item) error {
if it.AuthorClaimedSnapshot {
if s.Propose == nil {
return fmt.Errorf("proposer not configured")
}
prop, err := s.Propose.ProposeModification(ctx, it.DocID, fmt.Sprintf("user:%d", b.InitiatorUserID),
it.ChangeSummary, it.Reason, "补充材料触发的已认领文档变更,需编制负责人确认")
if err != nil {
return err
}
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeProposalPending, prop.ProposalID, "")
if s.Notify != nil && it.AuthorIDSnapshot > 0 {
_ = s.Notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID,
DigestKind: notification.DigestKindProposalRequired, DocID: it.DocID, DocTitle: it.Title,
Severity: notification.SeverityActionRequired,
ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID),
Summary: b.SourceSummary, ProposalID: prop.ProposalID,
})
}
return nil
}
// Unclaimed: notify info-only; actual content rewrite is owned by squad/task path.
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeApplied, "", "")
if s.Notify != nil && it.AuthorIDSnapshot > 0 {
_ = s.Notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID,
DigestKind: notification.DigestKindUnclaimedEdit, DocID: it.DocID, DocTitle: it.Title,
Severity: notification.SeverityInfo,
ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID),
Summary: b.SourceSummary,
})
}
return nil
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func truncate(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
return string(r[:n]) + "…"
}
+108
View File
@@ -0,0 +1,108 @@
package supplementimpact_test
import (
"context"
"testing"
"time"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/supplementimpact"
"chat-agent-system/internal/traceimpact"
)
type stubAnalyzer struct{ report *traceimpact.Report }
func (s stubAnalyzer) Analyze(_ context.Context, _ int64, _ traceimpact.DocRef, _ []traceimpact.DocRef) (*traceimpact.Report, error) {
return s.report, nil
}
type stubDocs struct{ docs map[string]*docgov.DocumentRecord }
func (s stubDocs) GetDocument(_ context.Context, id string) (*docgov.DocumentRecord, error) {
return s.docs[id], nil
}
func TestCreateBatchSnapshotsClaimAndBusy(t *testing.T) {
store := supplementimpact.NewMemStore()
claimedAt := time.Now()
svc := &supplementimpact.Service{
Store: store,
Analyze: stubAnalyzer{report: &traceimpact.Report{
CoverageStatus: "confirmed_impact",
Impacts: []traceimpact.Impact{
{DocID: "doc_a", ItemKey: "UR-1", ImpactType: "confirmed_impact", Depth: 1, Reason: "hit"},
},
}},
Docs: stubDocs{docs: map[string]*docgov.DocumentRecord{
"doc_a": {DocID: "doc_a", Title: "需求", AuthorID: 9, AuthorClaimed: true, AuthorClaimedAt: &claimedAt},
}},
}
b, err := svc.CreateFromSupplement(context.Background(), 1, 2, "sess", "补充市场", "UR-1 目标市场改为欧盟")
if err != nil {
t.Fatal(err)
}
if len(b.Items) != 1 || !b.Items[0].AuthorClaimedSnapshot {
t.Fatalf("want claimed snapshot, got %+v", b.Items)
}
}
type stubPropose struct {
lastDoc string
prop *docgov.ChangeProposal
}
func (s *stubPropose) ProposeModification(_ context.Context, docID, _, _, _, _ string) (*docgov.ChangeProposal, error) {
s.lastDoc = docID
if s.prop == nil {
s.prop = &docgov.ChangeProposal{ProposalID: "prop_test_1", DocID: docID, Status: docgov.ProposalPendingAuthorApproval}
}
return s.prop, nil
}
type stubNotify struct{ n int }
func (s *stubNotify) UpsertDocumentDigest(_ context.Context, _ notification.UpsertDigestParams) error {
s.n++
return nil
}
func TestDecideClaimedProducesProposal(t *testing.T) {
store := supplementimpact.NewMemStore()
claimedAt := time.Now()
prop := &stubPropose{}
notify := &stubNotify{}
svc := &supplementimpact.Service{
Store: store,
Analyze: stubAnalyzer{report: &traceimpact.Report{
CoverageStatus: "confirmed_impact",
Impacts: []traceimpact.Impact{
{DocID: "doc_a", ItemKey: "UR-1", ImpactType: "confirmed_impact", Depth: 1, Reason: "hit"},
},
}},
Docs: stubDocs{docs: map[string]*docgov.DocumentRecord{
"doc_a": {DocID: "doc_a", Title: "需求", AuthorID: 9, AuthorClaimed: true, AuthorClaimedAt: &claimedAt},
}},
Propose: prop,
Notify: notify,
}
b, err := svc.CreateFromSupplement(context.Background(), 1, 2, "sess", "补充市场", "UR-1 目标市场改为欧盟")
if err != nil {
t.Fatal(err)
}
out, err := svc.Decide(context.Background(), supplementimpact.DecisionCommand{
BatchID: b.BatchID, ActorUserID: 2, Kind: "confirm",
SelectedDocIDs: []string{"doc_a"}, DecisionNonce: b.DecisionNonce,
DecisionDigest: "d1", ExpectedRevision: b.Revision,
})
if err != nil {
t.Fatal(err)
}
if len(out.Items) != 1 || out.Items[0].Outcome != supplementimpact.OutcomeProposalPending {
t.Fatalf("want proposal_pending, got %+v", out.Items)
}
if prop.lastDoc != "doc_a" || notify.n != 1 {
t.Fatalf("propose/notify not called: doc=%s notify=%d", prop.lastDoc, notify.n)
}
}
+28
View File
@@ -0,0 +1,28 @@
package supplementimpact
import (
"context"
"log/slog"
"time"
)
// TriggerAsync kicks CreateFromSupplement in a background goroutine with panic recovery
// so chat / mutate paths are never blocked by impact analysis.
func TriggerAsync(svc *Service, projectID, userID int64, sessionID, summary, markdown string) {
if svc == nil {
return
}
go func() {
defer func() {
if rec := recover(); rec != nil {
slog.Error("supplementimpact: TriggerAsync panic", "recover", rec, "project_id", projectID)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if _, err := svc.CreateFromSupplement(ctx, projectID, userID, sessionID, summary, markdown); err != nil {
slog.Warn("supplementimpact: CreateFromSupplement failed",
"project_id", projectID, "user_id", userID, "error", err)
}
}()
}
+70
View File
@@ -0,0 +1,70 @@
package supplementimpact
import "time"
const (
StatusPending = "pending"
StatusDecided = "decided"
StatusRunning = "running"
StatusDone = "done"
StatusPartiallyDone = "partially_done"
StatusExpired = "expired"
StatusCancelled = "cancelled"
OutcomeApplied = "applied"
OutcomeProposalPending = "proposal_pending"
OutcomeProposalRejected = "proposal_rejected"
OutcomeSkippedBusy = "skipped_busy"
OutcomeSkippedLocked = "skipped_locked"
OutcomeFailed = "failed"
BatchTTL = 24 * time.Hour
)
// Batch is one supplement → impact analysis confirmation unit.
type Batch struct {
BatchID string `json:"batch_id"`
ProjectID int64 `json:"project_id"`
SessionID string `json:"session_id"`
InitiatorUserID int64 `json:"initiator_user_id"`
SourceKind string `json:"source_kind"`
SourceSummary string `json:"source_summary"`
CoverageStatus string `json:"coverage_status"`
Status string `json:"status"`
DecisionNonce string `json:"decision_nonce"`
DecisionDigest string `json:"decision_digest"`
Revision int64 `json:"revision"`
ExpiresAt time.Time `json:"expires_at"`
Items []Item `json:"items,omitempty"`
}
// Item is one affected document inside a batch.
type Item struct {
ID int64 `json:"id"`
BatchID string `json:"batch_id"`
DocID string `json:"doc_id"`
Title string `json:"title"`
ItemKey string `json:"item_key"`
ImpactType string `json:"impact_type"`
Depth int `json:"depth"`
Reason string `json:"reason"`
AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"`
AuthorIDSnapshot int64 `json:"author_id_snapshot"`
BusySnapshot bool `json:"busy_snapshot"`
ChangeSummary string `json:"change_summary"`
Selected bool `json:"selected"`
Outcome string `json:"outcome"`
ProposalID string `json:"proposal_id"`
RunID string `json:"run_id"`
}
// DecisionCommand is the confirm/cancel envelope (nonce+digest+revision CAS).
type DecisionCommand struct {
BatchID string
ActorUserID int64
Kind string // confirm | cancel
SelectedDocIDs []string
DecisionNonce string
DecisionDigest string
ExpectedRevision int64
}
+1
View File
@@ -29,6 +29,7 @@ export interface DocGovRecord {
approver_id?: number;
approver_name?: string;
author_claimed?: boolean;
author_claimed_at?: string;
reviewer_claimed?: boolean;
approver_claimed?: boolean;
last_rejected_reason?: string;
+46
View File
@@ -0,0 +1,46 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { isProposalType, sortInboxNotifications, type UserNotification } from './notifications';
describe('notifications inbox helpers', () => {
it('ranks action_required / proposal ahead of info', () => {
const items: UserNotification[] = [
{
id: 1,
user_id: 1,
project_id: 1,
type: 'stage_ready_to_advance',
title: 'a',
body: '',
status: 'unread',
severity: 'info',
created_at: '',
},
{
id: 2,
user_id: 1,
project_id: 1,
type: 'document_proposal_required',
title: 'b',
body: '',
status: 'unread',
severity: 'action_required',
created_at: '',
},
{
id: 3,
user_id: 1,
project_id: 1,
type: 'document_unclaimed_edit_applied',
title: 'c',
body: '',
status: 'unread',
severity: 'info',
created_at: '',
},
];
const sorted = sortInboxNotifications(items);
assert.equal(sorted[0].id, 2);
assert.equal(isProposalType(sorted[0].type), true);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { apiFetch, API_BASE } from './client';
import { getBearerAuthHeaders } from './authHeaders';
export interface UserNotification {
id: number;
user_id: number;
project_id: number;
type: string;
title: string;
body: string;
status: string;
severity?: 'action_required' | 'info' | string;
digest_id?: number;
created_at: string;
payload?: {
stage_key?: string;
stage_name?: string;
config_revision?: number;
workflow_instance_id?: number;
doc_id?: string;
proposal_id?: string;
batch_id?: string;
};
}
export function sortInboxNotifications(items: UserNotification[]): UserNotification[] {
return [...items].sort((a, b) => {
const sa = a.severity === 'action_required' || isProposalType(a.type) ? 0 : 1;
const sb = b.severity === 'action_required' || isProposalType(b.type) ? 0 : 1;
if (sa !== sb) return sa - sb;
return b.id - a.id;
});
}
export function isProposalType(type: string): boolean {
return (
type === 'proposal_required' ||
type === 'document_proposal_required' ||
type === 'digest_proposal_required' ||
type.includes('proposal_required')
);
}
export async function fetchAllNotifications(opts?: {
sinceId?: number;
limit?: number;
scope?: 'all';
}): Promise<UserNotification[]> {
const params = new URLSearchParams();
if (opts?.sinceId && opts.sinceId > 0) params.set('since_id', String(opts.sinceId));
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.scope) params.set('scope', opts.scope);
const qs = params.toString();
const res = await apiFetch(`${API_BASE}/api/v1/notifications${qs ? `?${qs}` : ''}`, {
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
if (res.status === 503) return [];
const text = await res.text();
throw new Error(text || `获取通知失败: ${res.status}`);
}
const data = await res.json();
const items = Array.isArray(data.items) ? (data.items as UserNotification[]) : [];
return sortInboxNotifications(items);
}
export async function patchNotificationStatus(
id: number,
status: 'read' | 'acted' | 'dismissed' | 'unread'
): Promise<void> {
const res = await apiFetch(`${API_BASE}/api/v1/notifications/${id}`, {
method: 'PATCH',
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify({ status }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `更新通知失败: ${res.status}`);
}
}
export interface SupplementImpactItem {
doc_id: string;
title?: string;
impact_type?: string;
reason?: string;
author_claimed_snapshot?: boolean;
busy_snapshot?: boolean;
change_summary?: string;
selected?: boolean;
outcome?: string;
proposal_id?: string;
}
export interface SupplementImpactBatch {
batch_id: string;
project_id: number;
session_id?: string;
initiator_user_id: number;
source_summary?: string;
coverage_status?: string;
status: string;
decision_nonce: string;
decision_digest?: string;
revision: number;
expires_at: string;
items?: SupplementImpactItem[];
}
export async function fetchSupplementImpact(batchId: string): Promise<SupplementImpactBatch> {
const res = await apiFetch(`${API_BASE}/api/v1/supplement-impacts/${encodeURIComponent(batchId)}`, {
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `加载补充影响批次失败: ${res.status}`);
}
return res.json();
}
export async function decideSupplementImpact(
batchId: string,
body: {
kind: 'confirm' | 'cancel';
selected_doc_ids: string[];
decision_nonce: string;
decision_digest: string;
expected_revision: number;
}
): Promise<SupplementImpactBatch> {
const res = await apiFetch(
`${API_BASE}/api/v1/supplement-impacts/${encodeURIComponent(batchId)}/decision`,
{
method: 'POST',
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify(body),
}
);
if (!res.ok) {
const text = await res.text();
let message = text || `决策失败: ${res.status}`;
try {
const parsed = JSON.parse(text) as { message?: string; error?: string };
if (parsed.message) message = parsed.message;
else if (parsed.error) message = parsed.error;
} catch {
/* keep */
}
const err = new Error(message) as Error & { status?: number };
err.status = res.status;
throw err;
}
return res.json();
}
@@ -59,6 +59,10 @@ describe('InteractiveCard Logic', () => {
const planMeta = getCardTypeMeta('PLAN_CONFIRM');
expect(planMeta.label).toBe('计划确认');
expect(planMeta.badgeText).toContain('计划');
const impactMeta = getCardTypeMeta('SUPPLEMENT_IMPACT_CONFIRM');
expect(impactMeta.label).toContain('补充影响');
expect(impactMeta.badgeText).toContain('影响');
});
});
@@ -92,6 +92,16 @@ export function getCardTypeMeta(type: InteractiveCardType) {
badgeColor: 'var(--amber-600)',
badgeBorder: 'rgba(242, 169, 59, 0.3)',
};
case 'SUPPLEMENT_IMPACT_CONFIRM':
return {
label: '补充影响确认',
badgeText: '影响确认',
icon: FileQuestion,
accentColor: 'var(--brand-500)',
badgeBg: 'var(--brand-50)',
badgeColor: 'var(--brand-600)',
badgeBorder: 'var(--brand-100)',
};
default:
return {
label: '协同卡片',
@@ -566,6 +576,89 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
</div>
)}
{card.cardType === 'SUPPLEMENT_IMPACT_CONFIRM' && Array.isArray(card.impactItems) && (
<div
data-testid="supplement-impact-checklist"
style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
padding: '8px 10px',
}}
>
{card.coverageStatus && (
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: 2 }}>
覆盖状态:{card.coverageStatus}
</div>
)}
{card.impactItems.map((item) => (
<label
key={item.docId}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
fontSize: '12px',
color: item.busy ? 'var(--text-muted)' : 'var(--text-strong)',
opacity: item.busy ? 0.55 : 1,
cursor: item.busy || !canClick ? 'not-allowed' : 'pointer',
}}
>
<input
type="checkbox"
disabled={item.busy || !canClick}
checked={!!item.selected}
onChange={(e) => {
if (!onExecuteAction || item.busy) return;
onExecuteAction(card.cardId, 'toggle_impact_doc', {
docId: item.docId,
selected: e.target.checked,
});
}}
style={{ marginTop: 2 }}
/>
<span style={{ flex: 1 }}>
<span style={{ fontWeight: 600 }}>{item.title || item.docId}</span>
{item.authorClaimed && (
<span
style={{
marginLeft: 6,
fontSize: '10.5px',
fontWeight: 600,
color: 'var(--amber-600)',
background: 'var(--amber-50)',
padding: '1px 6px',
borderRadius: 'var(--r-pill)',
}}
>
已认领
</span>
)}
{item.busy && (
<span
style={{
marginLeft: 6,
fontSize: '10.5px',
color: 'var(--text-muted)',
}}
>
忙碌中
</span>
)}
{item.changeSummary && (
<div style={{ color: 'var(--text-muted)', marginTop: 2, lineHeight: 1.4 }}>
{item.changeSummary}
</div>
)}
</span>
</label>
))}
</div>
)}
{/* 拟建议修改内容 / 批注展示 */}
{card.proposedContent && (
<div
@@ -33,6 +33,7 @@ interface DocGovActionBarProps {
export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
ast,
currentRole,
currentUserId,
canOverrideVisibility = true,
onOpenVisibilityModal,
@@ -345,6 +346,34 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
• 已被编制人认领
</span>
)}
{ast.isClaimed &&
currentRole === 'author' &&
(ast.authorId === undefined || currentUserId === undefined || ast.authorId === currentUserId) && (
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background: 'rgba(33, 166, 106, 0.12)',
color: 'var(--success, #21a66a)',
maxWidth: '280px',
lineHeight: 1.35,
}}
title="认领后他人发起的修改需您确认"
>
您已于{' '}
{ast.authorClaimedAt
? new Date(ast.authorClaimedAt).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '此前'}{' '}
认领本文档;此后他人发起的修改需您确认。
</span>
)}
{ast.visibilityRule && ast.visibilityRule.mode !== 'inherit' && (
<span
style={{
+3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Sun, Moon, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Sparkles } from 'lucide-react';
import { ProjectSession } from '../../types/workbench';
import { ProjectWorkflowSwitcher } from '../workflow/ProjectWorkflowSwitcher';
import { NotificationInbox } from '../notifications/NotificationInbox';
interface TopbarProps {
currentSession: ProjectSession;
@@ -165,6 +166,8 @@ export const Topbar: React.FC<TopbarProps> = ({
/>
</div>
<NotificationInbox />
{/* 主题切换按钮 */}
<button
onClick={onToggleTheme}
@@ -0,0 +1,349 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Bell, CheckCircle2, ExternalLink, X } from 'lucide-react';
import {
fetchAllNotifications,
isProposalType,
patchNotificationStatus,
type UserNotification,
} from '../../api/notifications';
import { decideModificationProposal } from '../../api/docgov';
const POLL_MS = 20_000;
export interface NotificationInboxProps {
onJumpProject?: (projectId: number, payload?: UserNotification['payload']) => void;
}
export const NotificationInbox: React.FC<NotificationInboxProps> = ({ onJumpProject }) => {
const [open, setOpen] = useState(false);
const [items, setItems] = useState<UserNotification[]>([]);
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const next = await fetchAllNotifications({ limit: 50, scope: 'all' });
setItems(next);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : '加载通知失败');
}
}, []);
useEffect(() => {
void load();
const timer = window.setInterval(() => {
void load();
}, POLL_MS);
return () => window.clearInterval(timer);
}, [load]);
const unread = useMemo(
() => items.filter((n) => n.status === 'unread').length,
[items]
);
const actionItems = useMemo(
() =>
items.filter(
(n) => n.severity === 'action_required' || isProposalType(n.type)
),
[items]
);
const infoItems = useMemo(
() =>
items.filter(
(n) => !(n.severity === 'action_required' || isProposalType(n.type))
),
[items]
);
const markRead = async (n: UserNotification) => {
if (n.status !== 'unread') return;
try {
await patchNotificationStatus(n.id, 'read');
setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, status: 'read' } : x)));
} catch {
/* ignore */
}
};
const decideProposal = async (n: UserNotification, approve: boolean) => {
const proposalId = n.payload?.proposal_id;
if (!proposalId) return;
setBusyId(n.id);
try {
await decideModificationProposal({ proposal_id: proposalId, approve });
await patchNotificationStatus(n.id, 'acted');
setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, status: 'acted' } : x)));
} catch (e) {
setError(e instanceof Error ? e.message : '提案决策失败');
} finally {
setBusyId(null);
}
};
return (
<div style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
title="通知收件箱"
data-testid="notification-inbox-bell"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
borderRadius: 'var(--r-sm)',
color: 'var(--text-muted)',
background: open ? 'var(--brand-50)' : 'var(--bg-subtle)',
border: '1px solid var(--border)',
position: 'relative',
}}
>
<Bell size={16} />
{unread > 0 && (
<span
data-testid="notification-unread-badge"
style={{
position: 'absolute',
top: '-4px',
right: '-4px',
minWidth: '16px',
height: '16px',
padding: '0 4px',
borderRadius: '999px',
background: 'var(--danger, #dc2626)',
color: '#fff',
fontSize: '10px',
fontWeight: 700,
lineHeight: '16px',
textAlign: 'center',
}}
>
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{open && (
<div
data-testid="notification-inbox-panel"
style={{
position: 'absolute',
top: '40px',
right: 0,
width: '360px',
maxHeight: '480px',
overflow: 'auto',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
boxShadow: 'var(--shadow-md, 0 8px 24px rgba(15,23,42,0.12))',
zIndex: 50,
padding: '10px 0',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 12px 8px',
borderBottom: '1px solid var(--border)',
}}
>
<span style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-strong)' }}>
通知
</span>
<button
type="button"
onClick={() => setOpen(false)}
style={{
border: 'none',
background: 'transparent',
color: 'var(--text-muted)',
cursor: 'pointer',
padding: 4,
}}
>
<X size={14} />
</button>
</div>
{error && (
<div style={{ padding: '8px 12px', fontSize: '12px', color: 'var(--danger, #dc2626)' }}>
{error}
</div>
)}
{actionItems.length > 0 && (
<section style={{ padding: '8px 0' }}>
<div
style={{
padding: '4px 12px',
fontSize: '11px',
fontWeight: 700,
color: 'var(--amber-600, #d97706)',
letterSpacing: '0.02em',
}}
>
需要处理
</div>
{actionItems.map((n) => (
<NotificationRow
key={n.id}
n={n}
busy={busyId === n.id}
onOpen={() => {
void markRead(n);
onJumpProject?.(n.project_id, n.payload);
}}
onDecide={
isProposalType(n.type) && n.payload?.proposal_id
? (approve) => void decideProposal(n, approve)
: undefined
}
/>
))}
</section>
)}
<section style={{ padding: '8px 0' }}>
<div
style={{
padding: '4px 12px',
fontSize: '11px',
fontWeight: 700,
color: 'var(--text-muted)',
}}
>
摘要
</div>
{infoItems.length === 0 && actionItems.length === 0 ? (
<div style={{ padding: '16px 12px', fontSize: '12px', color: 'var(--text-muted)' }}>
暂无通知
</div>
) : (
infoItems.map((n) => (
<NotificationRow
key={n.id}
n={n}
busy={false}
onOpen={() => {
void markRead(n);
onJumpProject?.(n.project_id, n.payload);
}}
/>
))
)}
</section>
</div>
)}
</div>
);
};
function NotificationRow({
n,
busy,
onOpen,
onDecide,
}: {
n: UserNotification;
busy: boolean;
onOpen: () => void;
onDecide?: (approve: boolean) => void;
}) {
const unread = n.status === 'unread';
return (
<div
style={{
padding: '10px 12px',
borderBottom: '1px solid var(--border)',
background: unread ? 'var(--brand-50, rgba(79,107,255,0.06))' : 'transparent',
opacity: busy ? 0.6 : 1,
}}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '12.5px',
fontWeight: unread ? 700 : 600,
color: 'var(--text-strong)',
marginBottom: 2,
}}
>
{n.title}
</div>
<div
style={{
fontSize: '11.5px',
color: 'var(--text-muted)',
lineHeight: 1.45,
whiteSpace: 'pre-wrap',
}}
>
{n.body}
</div>
</div>
<button
type="button"
onClick={onOpen}
title="跳转"
style={{
border: 'none',
background: 'transparent',
color: 'var(--brand-600)',
cursor: 'pointer',
padding: 2,
}}
>
<ExternalLink size={14} />
</button>
</div>
{onDecide && n.status !== 'acted' && (
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button
type="button"
disabled={busy}
onClick={() => onDecide(true)}
style={{
fontSize: '11.5px',
fontWeight: 600,
padding: '4px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--brand-100)',
background: 'var(--brand-500)',
color: '#fff',
cursor: busy ? 'not-allowed' : 'pointer',
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<CheckCircle2 size={12} /> 同意修改
</span>
</button>
<button
type="button"
disabled={busy}
onClick={() => onDecide(false)}
style={{
fontSize: '11.5px',
fontWeight: 600,
padding: '4px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--text-muted)',
cursor: busy ? 'not-allowed' : 'pointer',
}}
>
拒绝
</button>
</div>
)}
</div>
);
}
+17
View File
@@ -153,6 +153,23 @@ test('read-only current-doc request does not start a task', () => {
);
});
test('summary/analysis with write verbs stays chat (read-only priority)', () => {
const phrases = [
'请总结并生成要点',
'分析一下这份附件然后编写摘要',
'概括文档内容并更新说明',
'查看附件后修改建议列表',
];
for (const text of phrases) {
assert.equal(hasDocumentWriteIntent(text), false, text);
assert.equal(resolveTaskTemplateForMessage(text, bothPool, activeIntake), undefined, text);
}
});
test('pure write intent still routes to task', () => {
assert.equal(hasDocumentWriteIntent('请编制用户初始需求收集表'), true);
});
test('template-only active slot is not pinned; keywords still apply', () => {
assert.equal(isServerRegisteredControlledDoc(templateIntakeSlot), false);
const resolved = resolveTaskTemplateForMessage(
+3 -2
View File
@@ -1,7 +1,7 @@
import { DeliverableItem } from '../types/workbench';
const READ_ONLY_INTENT =
/(?:总结|概括|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/;
/(?:总结|概括|摘要|要点|简述|解读|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/;
const WRITE_INTENT =
// 弱写动词(整理/梳理/汇总等)故意不含:走聊天通道,由后端阶段感知 + 确认门禁引导。
/(?:开始|编写|编制|生成|起草|创建|撰写|重写|更新|修改|完善|补充|刷新|重做|改写)/;
@@ -189,9 +189,10 @@ export function wrongWorkflowGuidance(info: NonNullable<ResolvedDocumentTask['wr
return `您提到的文档「${info.docTitle}」属于【${workflowDisplayLabel(info.ownerWorkflow)}】,当前激活工作流为【${workflowDisplayLabel(info.activeWorkflow)}】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。`;
}
/** 只读动词优先;无明确写动作则不启动文档任务。 */
/** 只读动词优先于写动词:含「总结/分析/查看」等时即使夹杂写动词也不启动文档任务。 */
export function hasDocumentWriteIntent(text: string): boolean {
const normalized = text.trim();
if (!normalized) return false;
if (READ_ONLY_INTENT.test(normalized)) return false;
return WRITE_INTENT.test(normalized);
}
+102
View File
@@ -4,6 +4,7 @@ import {
fetchIntentPlan,
submitIntentPlanDecision,
} from '../api/intentPlans';
import { decideSupplementImpact } from '../api/notifications';
import {
ALL_MOCK_DOCUMENTS,
} from '@demo-seeds';
@@ -72,6 +73,107 @@ export function useCardActions(input: CardActionsInput) {
if (!targetCard) return;
if (targetCard.cardType === 'SUPPLEMENT_IMPACT_CONFIRM') {
const batchId = String(payload?.batch_id || targetCard.supplementBatchId || '');
if (actionId === 'toggle_impact_doc') {
const docId = String(payload?.docId || '');
const selected = !!payload?.selected;
setMessages((prev) =>
prev.map((m) => {
if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard?.impactItems) return m;
return {
...m,
interactiveCard: {
...m.interactiveCard,
impactItems: m.interactiveCard.impactItems.map((it) =>
it.docId === docId ? { ...it, selected } : it
),
},
};
})
);
return;
}
const kind = (payload?.kind || (actionId === 'cancel' || actionId === 'supplement_cancel' ? 'cancel' : 'confirm')) as
| 'confirm'
| 'cancel';
const expectedRevision = Number(payload?.expected_revision || targetCard.planRevision || 0);
const decisionNonce =
String(payload?.decision_nonce || targetCard.decisionNonce || '') ||
(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${cardId}:${actionId}:${expectedRevision}:${Date.now()}`);
const selectedDocIds =
kind === 'confirm'
? (targetCard.impactItems || [])
.filter((it) => it.selected && !it.busy)
.map((it) => it.docId)
: [];
const decisionDigest =
String(payload?.decision_digest || targetCard.decisionDigest || '') ||
`${kind}:${expectedRevision}:${selectedDocIds.join(',')}`;
setMessages((prev) =>
prev.map((m) =>
m.interactiveCard?.cardId === cardId
? { ...m, interactiveCard: { ...m.interactiveCard!, status: 'executing' } }
: m
)
);
void (async () => {
try {
const batch = await decideSupplementImpact(batchId, {
kind,
selected_doc_ids: selectedDocIds,
decision_nonce: decisionNonce,
decision_digest: decisionDigest,
expected_revision: expectedRevision,
});
setMessages((prev) =>
prev.map((m) => {
if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard) return m;
return {
...m,
interactiveCard: {
...m.interactiveCard,
status: batch.status === 'cancelled' ? 'cancelled' : 'resolved',
planRevision: batch.revision,
decisionNonce: batch.decision_nonce,
resolutionSummary:
kind === 'cancel' ? '已取消补充影响确认' : '补充影响决策已提交',
resolvedBy: currentUser.name,
resolvedAt: new Date().toTimeString().substring(0, 5),
impactItems: (batch.items || []).map((it) => ({
docId: it.doc_id,
title: it.title,
authorClaimed: it.author_claimed_snapshot,
busy: it.busy_snapshot,
changeSummary: it.change_summary,
selected: it.selected,
})),
},
};
})
);
} catch {
setMessages((prev) =>
prev.map((m) =>
m.interactiveCard?.cardId === cardId && m.interactiveCard
? {
...m,
interactiveCard: {
...m.interactiveCard,
status: 'active',
resolutionSummary: '补充影响决策失败,请重试',
},
}
: m
)
);
}
})();
return;
}
if (targetCard.cardType === 'RECHECK_NOTICE' && actionId === 'recheck_no_change') {
const projectId = Number(payload?.project_id || currentProjectId);
const markId = Number(payload?.mark_id);
@@ -49,6 +49,7 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu
...docAST,
lifecycleState: nextState,
isClaimed: nextClaimed,
authorClaimedAt: doc.author_claimed_at || docAST.authorClaimedAt,
authorId: doc.author_id ?? docAST.authorId,
author: doc.author_name || docAST.author,
reviewerId: doc.reviewer_id ?? docAST.reviewerId,
+2 -1
View File
@@ -8,8 +8,9 @@ test('sending a message lives in one hook', () => {
assert.equal((app.match(/useSendMessage\(/g) || []).length, 1);
assert.equal(app.includes('附件服务端注册失败'), false);
assert.equal(hook.includes('registerUploadAttachments'), true);
assert.equal(hook.includes('hasDocumentWriteIntent'), true);
assert.equal(hook.includes('runTaskAttempt'), true);
assert.equal(hook.includes('runStreamAttempt'), true);
assert.equal(hook.includes('失败硬拦'), true);
assert.equal(hook.includes('写意图失败硬拦'), true);
assert.equal(hook.includes('handlePassReview'), true);
});
+68 -62
View File
@@ -11,7 +11,7 @@ import {
isAffirmativeResponse,
} from '../engine/cardActionEngine';
import { markdownToBlockAST } from '../engine/markdownToBlock';
import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
import { hasDocumentWriteIntent, resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
import { createClientTurnId } from '../lib/createClientTurnId';
import { canonicalDocIdForTask } from '../lib/controlledDocHydrate';
import { holdsDocDuty } from '../lib/docGovActionVisibility';
@@ -170,7 +170,7 @@ export function useSendMessage(input: SendMessageInput) {
};
}
// 附件必须先注册为服务端 freeform(server docId);失败硬拦,禁止本地 orphan 回退
// 附件优先注册为 freeform;写意图失败硬拦,只读/对话路径注册失败不阻断发送。
let processedAttachments = attachments;
if (attachments && attachments.length > 0) {
let registered;
@@ -183,74 +183,80 @@ export function useSendMessage(input: SendMessageInput) {
createFreeformDoc,
});
} catch (err) {
return {
accepted: false,
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
};
if (hasDocumentWriteIntent(trimmed)) {
return {
accepted: false,
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
};
}
console.warn('[upload] freeform register failed; continuing chat/summary', err);
registered = undefined;
}
const newDeliverablesList = registered.map((r) => r.deliverable);
const newDocCacheEntries: Record<string, DocumentAST> = {};
for (const r of registered) {
const baseTitle = r.deliverable.title;
const initialMd = r.detail.content_markdown;
const newAST = markdownToBlockAST(initialMd, {
docId: r.serverDocId,
title: baseTitle,
taskKey: 'user_requirements',
version: 'V1.0',
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
});
newAST.governanceMode = 'freeform';
newAST.space = 'my_private';
newDocCacheEntries[r.serverDocId] = newAST;
if (r.attachment.fileRaw) {
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
);
// 二进制同步登记 CAS,刷新后可经 raw 端点回退预览
const extMatch = r.attachment.name.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : '';
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
if (!TEXT_EXTS.has(ext) && currentProjectId > 0) {
try {
const uploaded = await uploadProjectAsset(
currentProjectId,
r.attachment.fileRaw as Blob,
r.attachment.name,
);
if (r.deliverable.fileMeta) {
r.deliverable.fileMeta.assetId = uploaded.asset_id;
if (registered) {
const newDeliverablesList = registered.map((r) => r.deliverable);
const newDocCacheEntries: Record<string, DocumentAST> = {};
for (const r of registered) {
const baseTitle = r.deliverable.title;
const initialMd = r.detail.content_markdown;
const newAST = markdownToBlockAST(initialMd, {
docId: r.serverDocId,
title: baseTitle,
taskKey: 'user_requirements',
version: 'V1.0',
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
});
newAST.governanceMode = 'freeform';
newAST.space = 'my_private';
newDocCacheEntries[r.serverDocId] = newAST;
if (r.attachment.fileRaw) {
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
);
// 二进制同步登记 CAS,刷新后可经 raw 端点回退预览
const extMatch = r.attachment.name.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : '';
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
if (!TEXT_EXTS.has(ext) && currentProjectId > 0) {
try {
const uploaded = await uploadProjectAsset(
currentProjectId,
r.attachment.fileRaw as Blob,
r.attachment.name,
);
if (r.deliverable.fileMeta) {
r.deliverable.fileMeta.assetId = uploaded.asset_id;
}
// 回填去重与解析状态,供消息附件卡片展示提示。
r.attachment.assetId = uploaded.asset_id;
r.attachment.deduplicated = uploaded.deduplicated === true;
r.attachment.parseState = uploaded.parse_state;
} catch (e) {
console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e);
}
// 回填去重与解析状态,供消息附件卡片展示提示。
r.attachment.assetId = uploaded.asset_id;
r.attachment.deduplicated = uploaded.deduplicated === true;
r.attachment.parseState = uploaded.parse_state;
} catch (e) {
console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e);
}
}
}
}
processedAttachments = registered.map((r) => r.attachment);
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
setDocCache((prev) => ({
...prev,
...newDocCacheEntries,
}));
processedAttachments = registered.map((r) => r.attachment);
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
setDocCache((prev) => ({
...prev,
...newDocCacheEntries,
}));
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
setActiveDocId(processedAttachments[0].docId);
setActiveCategory('private');
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
setActiveDocId(processedAttachments[0].docId);
setActiveCategory('private');
}
}
}
+16
View File
@@ -9,6 +9,22 @@ test('403 revision denial is not reported as a network error', () => {
assert.match(info.userMessage, /无权访问该文档/);
});
test('403 author-duty denial surfaces draft-vs-submit guidance', () => {
const info = buildNetworkErrorInfo(403, {
errorCode: 'ErrOnlyAuthorCanEdit',
detail: '',
});
assert.equal(info.code, 'ErrOnlyAuthorCanEdit');
assert.match(info.userMessage, /送审需编制人/);
assert.notEqual(info.code, 'NETWORK_ERROR');
});
test('DOC_DUTY_FORBIDDEN keeps draft-vs-submit guidance', () => {
const info = buildNetworkErrorInfo(403, { errorCode: 'DOC_DUTY_FORBIDDEN' });
assert.equal(info.code, 'DOC_DUTY_FORBIDDEN');
assert.match(info.userMessage, /可生成\/编写草稿/);
});
test('TASK_SCOPE_UNRESOLVED names the missing workflow stage', () => {
const info = buildNetworkErrorInfo(409, { errorCode: 'TASK_SCOPE_UNRESOLVED' });
assert.equal(info.code, 'TASK_SCOPE_UNRESOLVED');
+32 -5
View File
@@ -35,6 +35,22 @@ const fatalDenialCopy: Record<string, string> = {
TASK_SCOPE_UNRESOLVED: '无法定位该文档任务的工作流阶段,请检查任务名或联系管理员',
};
/** 编制人身份类错误:可生成/编写草稿,送审仍须编制人。 */
const authorDutyCopy =
'您不是该文档编制人,可生成/编写草稿,但送审需编制人操作';
const authorDutyErrorCodes = new Set([
'DOC_DUTY_FORBIDDEN',
'only_author_can_edit',
'ErrOnlyAuthorCanEdit',
'reviewer_cannot_edit',
'ErrReviewerCannotEdit',
'approver_cannot_edit',
'ErrApproverCannotEdit',
'unauthorized_actor',
'ErrUnauthorizedActor',
]);
/** 任务直通道失败后应降级到聊天确认引导的错误码(仅降级一次,防循环)。 */
export function isTaskStageDegradeCode(code: string | undefined | null): boolean {
return code === 'TASK_SCOPE_UNRESOLVED' || code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE';
@@ -48,7 +64,7 @@ export function buildNetworkErrorInfo(
const detail = opts?.detail || '';
if (code === 'DOC_BUSY' || code === 'document_busy') {
return {
userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务',
userMessage: detail || '文档正在修改中(由项目助手或其他用户发起)。您的修改请求未执行,请稍后重试。',
code: 'DOC_BUSY',
class: 'fatal',
actions: [],
@@ -62,16 +78,19 @@ export function buildNetworkErrorInfo(
actions: ['retry'],
};
}
if (code === 'DOC_DUTY_FORBIDDEN') {
if (code === 'DOC_DUTY_FORBIDDEN' || authorDutyErrorCodes.has(code)) {
return {
userMessage: detail || '当前账号不是该文档任务的职责人,无法执行',
code,
userMessage: detail || authorDutyCopy,
code: code || 'DOC_DUTY_FORBIDDEN',
class: 'fatal',
actions: ['retry'],
};
}
if (fatalDenialCopy[code]) {
const retryable = code === 'WORKFLOW_NOT_ACTIVATED' || code === 'DOC_DUTY_NOT_MANAGED' || code === 'TASK_SCOPE_UNRESOLVED';
const retryable =
code === 'WORKFLOW_NOT_ACTIVATED' ||
code === 'DOC_DUTY_NOT_MANAGED' ||
code === 'TASK_SCOPE_UNRESOLVED';
return {
userMessage: detail || fatalDenialCopy[code],
code,
@@ -96,6 +115,14 @@ export function buildNetworkErrorInfo(
actions: ['retry'],
};
}
if (authorDutyErrorCodes.has(code) || /编制|职责|送审|审核|批准/.test(detail)) {
return {
userMessage: detail || authorDutyCopy,
code: code || 'DOC_DUTY_FORBIDDEN',
class: 'fatal',
actions: ['retry'],
};
}
return {
userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段',
code: code || 'ERR_FORBIDDEN',
+2
View File
@@ -59,6 +59,8 @@ export interface DocumentAST {
sharedAt?: string;
lifecycleState: DocLifecycleState;
isClaimed: boolean;
/** ISO time when current author claimed; optional from server */
authorClaimedAt?: string;
author: string;
authorId?: number;
reviewer: string;
+15 -1
View File
@@ -85,7 +85,8 @@ export type InteractiveCardType =
| 'SLOT_FILLING'
| 'DIFF_PROPOSAL'
| 'REMINDER_NOTICE'
| 'RECHECK_NOTICE';
| 'RECHECK_NOTICE'
| 'SUPPLEMENT_IMPACT_CONFIRM';
export type CardActionStatus =
| 'active'
@@ -124,6 +125,19 @@ export interface InteractiveCardData {
planRevision?: number;
stepCount?: number;
validatedOnly?: boolean;
/** SUPPLEMENT_IMPACT_CONFIRM */
supplementBatchId?: string;
decisionNonce?: string;
decisionDigest?: string;
coverageStatus?: string;
impactItems?: Array<{
docId: string;
title?: string;
authorClaimed?: boolean;
busy?: boolean;
changeSummary?: string;
selected?: boolean;
}>;
}
export type AssistantMessageStatus =