From 33ee59641f97fc19041cf8c155f7fdf3db6fdf80 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 15:35:07 +0000 Subject: [PATCH 1/8] feat(trace): add the project trace ledger and bounded impact walk Keep item revisions and edge decisions as records, and refuse to treat a missing index as no impact. Co-authored-by: Cursor --- .../goose/00059_2026-09-20-project-trace.sql | 108 +++++ internal/traceimpact/extract.go | 74 ++++ internal/traceimpact/migration_test.go | 27 ++ internal/traceimpact/open.go | 28 ++ internal/traceimpact/pg_store.go | 307 ++++++++++++++ internal/traceimpact/policy.go | 114 ++++++ internal/traceimpact/service.go | 380 ++++++++++++++++++ internal/traceimpact/service_test.go | 230 +++++++++++ internal/traceimpact/store.go | 317 +++++++++++++++ internal/traceimpact/types.go | 164 ++++++++ internal/traceimpact/walk.go | 59 +++ 11 files changed, 1808 insertions(+) create mode 100644 deploy/postgres/goose/00059_2026-09-20-project-trace.sql create mode 100644 internal/traceimpact/extract.go create mode 100644 internal/traceimpact/migration_test.go create mode 100644 internal/traceimpact/open.go create mode 100644 internal/traceimpact/pg_store.go create mode 100644 internal/traceimpact/policy.go create mode 100644 internal/traceimpact/service.go create mode 100644 internal/traceimpact/service_test.go create mode 100644 internal/traceimpact/store.go create mode 100644 internal/traceimpact/types.go create mode 100644 internal/traceimpact/walk.go diff --git a/deploy/postgres/goose/00059_2026-09-20-project-trace.sql b/deploy/postgres/goose/00059_2026-09-20-project-trace.sql new file mode 100644 index 00000000..6d020da4 --- /dev/null +++ b/deploy/postgres/goose/00059_2026-09-20-project-trace.sql @@ -0,0 +1,108 @@ +-- +goose Up +-- 项目追踪与阶段回溯账本。留存类别为 record:随项目保留。 +-- 条目修订与边决议只追加。源文档依法清除时把条目标成 tombstone,不级联删边和决议。 + +CREATE TABLE IF NOT EXISTS project_trace_items ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL, + business_key TEXT NOT NULL, + kind TEXT NOT NULL, + lifecycle_state TEXT NOT NULL DEFAULT 'active', + review_state TEXT NOT NULL DEFAULT 'confirmed', + tombstone BOOLEAN NOT NULL DEFAULT false, + statement TEXT NOT NULL DEFAULT '', + source_doc_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (project_id, kind, business_key) +); + +CREATE TABLE IF NOT EXISTS project_trace_item_revisions ( + id BIGSERIAL PRIMARY KEY, + item_id BIGINT NOT NULL REFERENCES project_trace_items (id), + revision_no INT NOT NULL, + source_doc_id TEXT NOT NULL DEFAULT '', + source_snapshot_id BIGINT NOT NULL DEFAULT 0, + content_hash TEXT NOT NULL DEFAULT '', + statement TEXT NOT NULL DEFAULT '', + extractor_version TEXT NOT NULL DEFAULT '', + tombstone BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (item_id, revision_no), + UNIQUE (item_id, source_snapshot_id, content_hash, extractor_version) +); + +CREATE TABLE IF NOT EXISTS project_trace_edges ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL, + from_item_id BIGINT NOT NULL, + to_item_id BIGINT NOT NULL, + relation_type TEXT NOT NULL, + provenance TEXT NOT NULL, + review_state TEXT NOT NULL, + evidence_doc_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (project_id, from_item_id, to_item_id, relation_type, provenance) +); + +CREATE TABLE IF NOT EXISTS project_trace_edge_decisions ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL, + edge_id BIGINT NOT NULL, + decision TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + decided_by BIGINT NOT NULL, + decided_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS project_trace_index_state ( + project_id BIGINT NOT NULL, + doc_id TEXT NOT NULL, + source_snapshot_id BIGINT NOT NULL DEFAULT 0, + status TEXT NOT NULL, + extractor_version TEXT NOT NULL, + item_count INT NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '', + indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (project_id, doc_id, extractor_version) +); + +CREATE TABLE IF NOT EXISTS stage_reopen_requests ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL, + instance_id BIGINT NOT NULL, + stage_key TEXT NOT NULL, + doc_task_keys TEXT[] NOT NULL, + reason TEXT NOT NULL, + requested_by BIGINT NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS stage_recheck_marks ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL, + stage_key TEXT NOT NULL, + source_doc_id TEXT NOT NULL, + status TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + acked_by BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS stage_recheck_marks_one_open + ON stage_recheck_marks (project_id, stage_key, source_doc_id) + WHERE status = 'open'; + +CREATE TABLE IF NOT EXISTS trace_revision_proposals ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL, + author_id BIGINT NOT NULL, + body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending_author', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- +goose Down +-- 这些表是合规业务记录,不是可丢弃缓存。 +-- Down 不删除 project_trace_edge_decisions,也不删除条目、修订、边、索引水位、再打开申请和待复核标记。 +SELECT 1; diff --git a/internal/traceimpact/extract.go b/internal/traceimpact/extract.go new file mode 100644 index 00000000..f9b5d94b --- /dev/null +++ b/internal/traceimpact/extract.go @@ -0,0 +1,74 @@ +package traceimpact + +import ( + "regexp" + "strings" +) + +var ( + explicitKeyRe = regexp.MustCompile(`\b((?:UR|RS|REQ|TC|RF|RP|DI)-\d+)\b`) + untypedKeyRe = regexp.MustCompile(`\b([A-Z]{2,10}-\d+)\b`) +) + +// ExtractedKey is a business key found in markdown. +type ExtractedKey struct { + Key string + Kind string + Explicit bool + Line string +} + +// ExtractKeys returns explicit UR/RS/REQ/TC-style keys and untyped ID mentions. +// Untyped mentions stay candidates and never become confirmed edges by themselves. +func ExtractKeys(markdown string) []ExtractedKey { + lines := strings.Split(markdown, "\n") + var out []ExtractedKey + seen := map[string]bool{} + for _, line := range lines { + for _, m := range explicitKeyRe.FindAllStringSubmatch(line, -1) { + key := m[1] + if seen["e:"+key] { + continue + } + seen["e:"+key] = true + kind := KindRequirement + if strings.HasPrefix(key, "TC-") { + kind = KindTest + } + out = append(out, ExtractedKey{Key: key, Kind: kind, Explicit: true, Line: line}) + } + for _, m := range untypedKeyRe.FindAllStringSubmatch(line, -1) { + key := m[1] + if explicitKeyRe.MatchString(key) || seen["u:"+key] || seen["e:"+key] { + continue + } + seen["u:"+key] = true + out = append(out, ExtractedKey{Key: key, Kind: KindRequirement, Explicit: false, Line: line}) + } + } + return out +} + +// ExplicitPairsOnLine returns ordered pairs of explicit keys that share a line. +func ExplicitPairsOnLine(markdown string) [][2]string { + var pairs [][2]string + seen := map[string]bool{} + for _, line := range strings.Split(markdown, "\n") { + found := explicitKeyRe.FindAllStringSubmatch(line, -1) + if len(found) < 2 { + continue + } + for i := 0; i < len(found); i++ { + for j := i + 1; j < len(found); j++ { + a, b := found[i][1], found[j][1] + k := a + ">" + b + if seen[k] { + continue + } + seen[k] = true + pairs = append(pairs, [2]string{a, b}) + } + } + } + return pairs +} diff --git a/internal/traceimpact/migration_test.go b/internal/traceimpact/migration_test.go new file mode 100644 index 00000000..514ed251 --- /dev/null +++ b/internal/traceimpact/migration_test.go @@ -0,0 +1,27 @@ +package traceimpact + +import ( + "os" + "strings" + "testing" +) + +func TestMigrationDownKeepsDecisionLedger(t *testing.T) { + b, err := os.ReadFile("../../deploy/postgres/goose/00059_2026-09-20-project-trace.sql") + if err != nil { + t.Fatal(err) + } + parts := strings.Split(string(b), "-- +goose Down") + if len(parts) != 2 { + t.Fatal("missing goose down") + } + down := strings.ToLower(parts[1]) + for _, forbidden := range []string{"drop table", "delete from", "truncate"} { + if strings.Contains(down, forbidden) { + t.Fatalf("down migration must not %s the trace ledger", forbidden) + } + } + if !strings.Contains(down, "project_trace_edge_decisions") { + t.Fatal("down comment should name the decision ledger it refuses to drop") + } +} diff --git a/internal/traceimpact/open.go b/internal/traceimpact/open.go new file mode 100644 index 00000000..b76642ec --- /dev/null +++ b/internal/traceimpact/open.go @@ -0,0 +1,28 @@ +package traceimpact + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Open uses the record tables when migration 00059 is present. +// Before that migration, it keeps an in-memory ledger so the process can still boot. +func Open(pool *pgxpool.Pool) *Service { + if pool == nil { + return NewMemoryService() + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var exists bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'project_trace_items' + )`).Scan(&exists) + if err != nil || !exists { + return NewMemoryService() + } + return NewService(newPGStore(pool)) +} diff --git a/internal/traceimpact/pg_store.go b/internal/traceimpact/pg_store.go new file mode 100644 index 00000000..3937f72c --- /dev/null +++ b/internal/traceimpact/pg_store.go @@ -0,0 +1,307 @@ +package traceimpact + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type pgStore struct { + pool *pgxpool.Pool +} + +func newPGStore(pool *pgxpool.Pool) *pgStore { + return &pgStore{pool: pool} +} + +func (p *pgStore) UpsertItem(ctx context.Context, item Item) (Item, error) { + if item.Lifecycle == "" { + item.Lifecycle = "active" + } + row := p.pool.QueryRow(ctx, ` + INSERT INTO project_trace_items + (project_id, business_key, kind, lifecycle_state, review_state, tombstone, statement, source_doc_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + ON CONFLICT (project_id, kind, business_key) DO UPDATE SET + review_state = EXCLUDED.review_state, + tombstone = EXCLUDED.tombstone, + statement = CASE WHEN EXCLUDED.statement <> '' THEN EXCLUDED.statement ELSE project_trace_items.statement END, + source_doc_id = CASE WHEN EXCLUDED.source_doc_id <> '' THEN EXCLUDED.source_doc_id ELSE project_trace_items.source_doc_id END + RETURNING id, project_id, business_key, kind, lifecycle_state, review_state, tombstone, statement, source_doc_id + `, item.ProjectID, item.BusinessKey, item.Kind, item.Lifecycle, item.ReviewState, item.Tombstone, item.Statement, item.SourceDocID) + return scanItem(row) +} + +func scanItem(row pgx.Row) (Item, error) { + var item Item + err := row.Scan(&item.ID, &item.ProjectID, &item.BusinessKey, &item.Kind, &item.Lifecycle, &item.ReviewState, &item.Tombstone, &item.Statement, &item.SourceDocID) + return item, err +} + +func (p *pgStore) ListItems(ctx context.Context, projectID int64) ([]Item, error) { + rows, err := p.pool.Query(ctx, ` + SELECT id, project_id, business_key, kind, lifecycle_state, review_state, tombstone, statement, source_doc_id + FROM project_trace_items WHERE project_id = $1 ORDER BY id + `, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Item + for rows.Next() { + var item Item + if err := rows.Scan(&item.ID, &item.ProjectID, &item.BusinessKey, &item.Kind, &item.Lifecycle, &item.ReviewState, &item.Tombstone, &item.Statement, &item.SourceDocID); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() +} + +func (p *pgStore) UpsertEdge(ctx context.Context, edge Edge) (Edge, error) { + row := p.pool.QueryRow(ctx, ` + INSERT INTO project_trace_edges + (project_id, from_item_id, to_item_id, relation_type, provenance, review_state, evidence_doc_id) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (project_id, from_item_id, to_item_id, relation_type, provenance) DO UPDATE SET + review_state = CASE + WHEN project_trace_edges.review_state = 'rejected' AND EXCLUDED.review_state = 'candidate' + THEN project_trace_edges.review_state + WHEN project_trace_edges.review_state = 'confirmed' + THEN project_trace_edges.review_state + ELSE EXCLUDED.review_state + END + RETURNING id, project_id, from_item_id, to_item_id, relation_type, provenance, review_state, evidence_doc_id + `, edge.ProjectID, edge.FromItemID, edge.ToItemID, edge.RelationType, edge.Provenance, edge.ReviewState, edge.EvidenceDoc) + var out Edge + err := row.Scan(&out.ID, &out.ProjectID, &out.FromItemID, &out.ToItemID, &out.RelationType, &out.Provenance, &out.ReviewState, &out.EvidenceDoc) + return out, err +} + +func (p *pgStore) ListEdges(ctx context.Context, projectID int64) ([]Edge, error) { + rows, err := p.pool.Query(ctx, ` + SELECT id, project_id, from_item_id, to_item_id, relation_type, provenance, review_state, evidence_doc_id + FROM project_trace_edges WHERE project_id = $1 ORDER BY id + `, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Edge + for rows.Next() { + var e Edge + if err := rows.Scan(&e.ID, &e.ProjectID, &e.FromItemID, &e.ToItemID, &e.RelationType, &e.Provenance, &e.ReviewState, &e.EvidenceDoc); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +func (p *pgStore) PutIndex(ctx context.Context, st IndexState) error { + if st.Extractor == "" { + st.Extractor = ExtractorVersion + } + _, err := p.pool.Exec(ctx, ` + INSERT INTO project_trace_index_state + (project_id, doc_id, source_snapshot_id, status, extractor_version, item_count, error) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (project_id, doc_id, extractor_version) DO UPDATE SET + source_snapshot_id = EXCLUDED.source_snapshot_id, + status = EXCLUDED.status, + item_count = EXCLUDED.item_count, + error = EXCLUDED.error, + indexed_at = now() + `, st.ProjectID, st.DocID, st.SnapshotID, st.Status, st.Extractor, st.ItemCount, st.Error) + return err +} + +func (p *pgStore) IndexStatus(ctx context.Context, projectID int64, docID string) (IndexState, bool, error) { + row := p.pool.QueryRow(ctx, ` + SELECT project_id, doc_id, source_snapshot_id, status, extractor_version, item_count, error + FROM project_trace_index_state + WHERE project_id = $1 AND doc_id = $2 + ORDER BY indexed_at DESC LIMIT 1 + `, projectID, docID) + var st IndexState + err := row.Scan(&st.ProjectID, &st.DocID, &st.SnapshotID, &st.Status, &st.Extractor, &st.ItemCount, &st.Error) + if err == pgx.ErrNoRows { + return IndexState{}, false, nil + } + if err != nil { + return IndexState{}, false, err + } + return st, true, nil +} + +func (p *pgStore) ListIndex(ctx context.Context, projectID int64) ([]IndexState, error) { + rows, err := p.pool.Query(ctx, ` + SELECT project_id, doc_id, source_snapshot_id, status, extractor_version, item_count, error + FROM project_trace_index_state WHERE project_id = $1 ORDER BY doc_id + `, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []IndexState + for rows.Next() { + var st IndexState + if err := rows.Scan(&st.ProjectID, &st.DocID, &st.SnapshotID, &st.Status, &st.Extractor, &st.ItemCount, &st.Error); err != nil { + return nil, err + } + out = append(out, st) + } + return out, rows.Err() +} + +func (p *pgStore) SetEdgeReview(ctx context.Context, edgeID int64, review string) error { + tag, err := p.pool.Exec(ctx, `UPDATE project_trace_edges SET review_state = $2 WHERE id = $1`, edgeID, review) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("traceimpact: edge not found") + } + return nil +} + +func (p *pgStore) AddDecision(ctx context.Context, d Decision) (Decision, error) { + if d.ProjectID == 0 { + _ = p.pool.QueryRow(ctx, `SELECT project_id FROM project_trace_edges WHERE id = $1`, d.EdgeID).Scan(&d.ProjectID) + } + err := p.pool.QueryRow(ctx, ` + INSERT INTO project_trace_edge_decisions (project_id, edge_id, decision, reason, decided_by) + VALUES ($1,$2,$3,$4,$5) RETURNING id + `, d.ProjectID, d.EdgeID, d.Decision, d.Reason, d.DecidedBy).Scan(&d.ID) + return d, err +} + +func (p *pgStore) PutReopen(ctx context.Context, req ReopenRequest) (ReopenRequest, error) { + if req.ID == 0 { + err := p.pool.QueryRow(ctx, ` + INSERT INTO stage_reopen_requests (project_id, instance_id, stage_key, doc_task_keys, reason, requested_by, status) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id + `, req.ProjectID, req.InstanceID, req.StageKey, req.DocTasks, req.Reason, req.RequestedBy, req.Status).Scan(&req.ID) + return req, err + } + _, err := p.pool.Exec(ctx, `UPDATE stage_reopen_requests SET status = $2 WHERE id = $1`, req.ID, req.Status) + return req, err +} + +func (p *pgStore) GetReopen(ctx context.Context, id int64) (ReopenRequest, error) { + var req ReopenRequest + err := p.pool.QueryRow(ctx, ` + SELECT id, project_id, instance_id, stage_key, doc_task_keys, reason, requested_by, status + FROM stage_reopen_requests WHERE id = $1 + `, id).Scan(&req.ID, &req.ProjectID, &req.InstanceID, &req.StageKey, &req.DocTasks, &req.Reason, &req.RequestedBy, &req.Status) + if err == pgx.ErrNoRows { + return ReopenRequest{}, fmt.Errorf("traceimpact: reopen request not found") + } + return req, err +} + +func (p *pgStore) PutMark(ctx context.Context, mark RecheckMark) (RecheckMark, error) { + if mark.Status == "" { + mark.Status = MarkOpen + } + err := p.pool.QueryRow(ctx, ` + INSERT INTO stage_recheck_marks (project_id, stage_key, source_doc_id, status, reason) + VALUES ($1,$2,$3,$4,$5) + ON CONFLICT (project_id, stage_key, source_doc_id) WHERE status = 'open' + DO UPDATE SET reason = stage_recheck_marks.reason + RETURNING id, project_id, stage_key, source_doc_id, status, reason, COALESCE(acked_by, 0) + `, mark.ProjectID, mark.StageKey, mark.SourceDoc, mark.Status, mark.Reason).Scan( + &mark.ID, &mark.ProjectID, &mark.StageKey, &mark.SourceDoc, &mark.Status, &mark.Reason, &mark.AckedBy, + ) + return mark, err +} + +func (p *pgStore) ListOpenMarks(ctx context.Context, projectID int64, stageKey string) ([]RecheckMark, error) { + rows, err := p.pool.Query(ctx, ` + SELECT id, project_id, stage_key, source_doc_id, status, reason, COALESCE(acked_by, 0) + FROM stage_recheck_marks + WHERE project_id = $1 AND status = 'open' AND ($2 = '' OR stage_key = $2) + ORDER BY id + `, projectID, stageKey) + if err != nil { + return nil, err + } + defer rows.Close() + var out []RecheckMark + for rows.Next() { + var m RecheckMark + if err := rows.Scan(&m.ID, &m.ProjectID, &m.StageKey, &m.SourceDoc, &m.Status, &m.Reason, &m.AckedBy); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +func (p *pgStore) AckMark(ctx context.Context, id, userID int64) error { + tag, err := p.pool.Exec(ctx, `UPDATE stage_recheck_marks SET acked_by = $2 WHERE id = $1 AND status = 'open'`, id, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("traceimpact: mark not found") + } + return nil +} + +func (p *pgStore) SetMarkStatus(ctx context.Context, id int64, status, reason string) error { + tag, err := p.pool.Exec(ctx, `UPDATE stage_recheck_marks SET status = $2, reason = CASE WHEN $3 <> '' THEN $3 ELSE reason END WHERE id = $1`, id, status, reason) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("traceimpact: mark not found") + } + return nil +} + +func (p *pgStore) GetEdge(ctx context.Context, edgeID int64) (Edge, error) { + var e Edge + err := p.pool.QueryRow(ctx, ` + SELECT id, project_id, from_item_id, to_item_id, relation_type, provenance, review_state, evidence_doc_id + FROM project_trace_edges WHERE id = $1 + `, edgeID).Scan(&e.ID, &e.ProjectID, &e.FromItemID, &e.ToItemID, &e.RelationType, &e.Provenance, &e.ReviewState, &e.EvidenceDoc) + if err == pgx.ErrNoRows { + return Edge{}, fmt.Errorf("traceimpact: edge not found") + } + return e, err +} + +func (p *pgStore) AppendRevision(ctx context.Context, rev ItemRevision) error { + if rev.Extractor == "" { + rev.Extractor = ExtractorVersion + } + _, err := p.pool.Exec(ctx, ` + INSERT INTO project_trace_item_revisions + (item_id, revision_no, source_doc_id, source_snapshot_id, content_hash, statement, extractor_version, tombstone) + SELECT $1, + COALESCE((SELECT MAX(revision_no) + 1 FROM project_trace_item_revisions WHERE item_id = $1), 1), + $2, $3, $4, $5, $6, $7 + WHERE NOT EXISTS ( + SELECT 1 FROM project_trace_item_revisions + WHERE item_id = $1 AND source_snapshot_id = $3 AND content_hash = $4 AND extractor_version = $6 + ) + `, rev.ItemID, rev.SourceDocID, rev.SnapshotID, rev.ContentHash, rev.Statement, rev.Extractor, rev.Tombstone) + return err +} + +func (p *pgStore) RevisionCount(ctx context.Context, itemID int64) (int, error) { + var n int + err := p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM project_trace_item_revisions WHERE item_id = $1`, itemID).Scan(&n) + return n, err +} + +func (p *pgStore) AddProposal(ctx context.Context, projectID, userID int64, text string) (int64, error) { + var id int64 + err := p.pool.QueryRow(ctx, ` + INSERT INTO trace_revision_proposals (project_id, author_id, body) VALUES ($1,$2,$3) RETURNING id + `, projectID, userID, text).Scan(&id) + return id, err +} diff --git a/internal/traceimpact/policy.go b/internal/traceimpact/policy.go new file mode 100644 index 00000000..dfa276b9 --- /dev/null +++ b/internal/traceimpact/policy.go @@ -0,0 +1,114 @@ +package traceimpact + +import "fmt" + +// Actor is the person trying to confirm or reject a candidate edge. +type Actor struct { + UserID int64 + Creator bool + AuthorOf map[string]bool +} + +// CanDecide reports whether actor may confirm or reject a model candidate +// on the downstream document. There is no separate trace_reviewer role. +func CanDecide(actor Actor, downstreamDocID string, downstreamAuthorID int64) error { + if downstreamDocID == "" { + return fmt.Errorf("traceimpact: missing downstream document") + } + if downstreamAuthorID > 0 { + if actor.UserID == downstreamAuthorID { + return nil + } + return fmt.Errorf("traceimpact: only the downstream author may decide this candidate") + } + if actor.Creator { + return nil + } + return fmt.Errorf("traceimpact: no downstream author; only the project creator may decide") +} + +// DutySeat is one document's author, reviewer, and approver. +type DutySeat struct { + TaskKey string + AuthorID int64 + ReviewerID int64 + ApproverID int64 +} + +// MayRequestReopen allows the project creator or a duty holder on a selected document. +func MayRequestReopen(userID int64, creator bool, selected []string, seats []DutySeat) bool { + if creator { + return true + } + wanted := map[string]bool{} + for _, key := range selected { + wanted[key] = true + } + for _, seat := range seats { + if !wanted[seat.TaskKey] { + continue + } + if userID > 0 && (userID == seat.AuthorID || userID == seat.ReviewerID || userID == seat.ApproverID) { + return true + } + } + return false +} + +// Suggest returns the three allowed next steps. They do not change state by themselves. +func Suggest(coverage string, hasImpact bool) []Suggestion { + switch coverage { + case ImpactFailed, ImpactIndexPending, ImpactPartial: + return []Suggestion{{ + Action: "wait_index", + Reason: "索引未完成或分析失败,不能判断为无影响", + }} + } + out := []Suggestion{{ + Action: ClearNoChange, + Reason: "确认这次修改不需要改其他文档", + }} + if hasImpact || coverage == ImpactPossible { + out = append(out, + Suggestion{Action: "l1_revise", Reason: "只修订受影响文档,阶段保持完成"}, + Suggestion{Action: "l2_reopen", Reason: "需要重跑编制或补充文档时再打开阶段"}, + ) + } + return out +} + +// SummaryForCoverage is the sentence shown when there is no confirmed hit. +func SummaryForCoverage(coverage string) string { + switch coverage { + case ImpactNoKnown: + return "索引已覆盖,未发现已知关联" + case ImpactIndexPending: + return "分析尚未完成,不能判断为无影响" + case ImpactPartial: + return "结果不完整,不能判断为无影响" + case ImpactFailed: + return "分析失败,不能判断为无影响" + case ImpactPossible: + return "存在未确认的可能影响" + case ImpactDependencyUnchanged: + return "已有关联,相关片段未变化" + default: + return "存在已确认影响" + } +} + +// RedactItems drops statements from documents the viewer cannot see. +// Hidden items are counted and not returned, so titles cannot leak. +func RedactItems(items []Item, visible func(docID string) bool) (out []Item, hidden int) { + for _, item := range items { + if item.SourceDocID != "" && (visible == nil || !visible(item.SourceDocID)) { + hidden++ + continue + } + out = append(out, item) + } + if out == nil { + out = []Item{} + } + return out, hidden +} diff --git a/internal/traceimpact/service.go b/internal/traceimpact/service.go new file mode 100644 index 00000000..19cc9c81 --- /dev/null +++ b/internal/traceimpact/service.go @@ -0,0 +1,380 @@ +package traceimpact + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// StageReopener applies an approved reopen to the workflow instance. +// It must not pause the instance or rewrite downstream stages. +type StageReopener interface { + ReopenCompletedStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) error +} + +// DocUnlocker moves selected approved documents back to drafting. +type DocUnlocker interface { + ReopenApprovedDocs(ctx context.Context, projectID int64, docTaskKeys []string, actorID int64, reason string) error +} + +// Service is the project trace index and the stage-backtrack ledger. +type Service struct { + store Store + reopen StageReopener + unlock DocUnlocker +} + +func NewService(store Store) *Service { + if store == nil { + store = newMemStore() + } + return &Service{store: store} +} + +func NewMemoryService() *Service { + return NewService(newMemStore()) +} + +func (s *Service) SetStageReopener(r StageReopener) { s.reopen = r } +func (s *Service) SetDocUnlocker(u DocUnlocker) { s.unlock = u } + +// IndexDocument projects explicit keys into items and same-line pairs into confirmed edges. +// Untyped IDs become candidate items only. +func (s *Service) IndexDocument(ctx context.Context, projectID int64, doc DocRef) (IndexState, error) { + if projectID <= 0 || doc.DocID == "" { + return IndexState{}, fmt.Errorf("traceimpact: project and doc are required") + } + keys := ExtractKeys(doc.Markdown) + itemByKey := map[string]Item{} + for _, k := range keys { + review := ReviewConfirmed + if !k.Explicit { + review = ReviewCandidate + } + item, err := s.store.UpsertItem(ctx, Item{ + ProjectID: projectID, BusinessKey: k.Key, Kind: k.Kind, + ReviewState: review, Statement: strings.TrimSpace(k.Line), SourceDocID: doc.DocID, + }) + if err != nil { + st := IndexState{ProjectID: projectID, DocID: doc.DocID, SnapshotID: doc.SnapshotID, Status: ImpactFailed, Error: err.Error()} + _ = s.store.PutIndex(ctx, st) + return st, err + } + sum := sha256.Sum256([]byte(k.Key + "\n" + strings.TrimSpace(k.Line))) + if err := s.store.AppendRevision(ctx, ItemRevision{ + ItemID: item.ID, SourceDocID: doc.DocID, SnapshotID: doc.SnapshotID, + ContentHash: hex.EncodeToString(sum[:]), Statement: strings.TrimSpace(k.Line), + Extractor: ExtractorVersion, + }); err != nil { + return IndexState{}, err + } + itemByKey[k.Key] = item + } + for _, pair := range ExplicitPairsOnLine(doc.Markdown) { + from, okA := itemByKey[pair[0]] + to, okB := itemByKey[pair[1]] + if !okA || !okB { + continue + } + if _, err := s.store.UpsertEdge(ctx, Edge{ + ProjectID: projectID, FromItemID: from.ID, ToItemID: to.ID, + RelationType: RelationRefines, Provenance: ProvenanceExplicit, + ReviewState: ReviewConfirmed, EvidenceDoc: doc.DocID, + FromKey: from.BusinessKey, ToKey: to.BusinessKey, + }); err != nil { + return IndexState{}, err + } + } + st := IndexState{ + ProjectID: projectID, DocID: doc.DocID, SnapshotID: doc.SnapshotID, + Status: "complete", Extractor: ExtractorVersion, ItemCount: len(keys), + } + if err := s.store.PutIndex(ctx, st); err != nil { + return IndexState{}, err + } + return st, nil +} + +// CoverageForDoc is what a change report should show before claiming no impact. +func (s *Service) CoverageForDoc(ctx context.Context, projectID int64, docID string) string { + st, ok, err := s.store.IndexStatus(ctx, projectID, docID) + if err != nil || !ok || st.Status == "" { + return ImpactIndexPending + } + switch st.Status { + case "complete": + return ImpactNoKnown + case "failed": + return ImpactFailed + case "partial": + return ImpactPartial + default: + return ImpactIndexPending + } +} + +// Analyze walks confirmed edges from keys mentioned in the changed markdown. +// Missing index coverage never becomes "no impact". +func (s *Service) Analyze(ctx context.Context, projectID int64, source DocRef, others []DocRef) (*Report, error) { + rep := &Report{ProjectID: projectID, SourceDocID: source.DocID, Impacts: []Impact{}} + if projectID <= 0 || source.DocID == "" { + rep.CoverageStatus = ImpactFailed + rep.Summary = SummaryForCoverage(rep.CoverageStatus) + rep.Suggestions = Suggest(rep.CoverageStatus, false) + return rep, nil + } + srcIndex, ok, err := s.store.IndexStatus(ctx, projectID, source.DocID) + if err != nil { + rep.CoverageStatus = ImpactFailed + rep.Summary = SummaryForCoverage(rep.CoverageStatus) + rep.Suggestions = Suggest(rep.CoverageStatus, false) + return rep, nil + } + if !ok || srcIndex.Status != "complete" { + rep.CoverageStatus = ImpactIndexPending + if ok && srcIndex.Status == "failed" { + rep.CoverageStatus = ImpactFailed + } + rep.Summary = SummaryForCoverage(rep.CoverageStatus) + rep.Suggestions = Suggest(rep.CoverageStatus, false) + return rep, nil + } + + items, err := s.store.ListItems(ctx, projectID) + if err != nil { + rep.CoverageStatus = ImpactFailed + rep.Summary = SummaryForCoverage(rep.CoverageStatus) + rep.Suggestions = Suggest(rep.CoverageStatus, false) + return rep, nil + } + byID := map[int64]Item{} + byKey := map[string]Item{} + for _, it := range items { + byID[it.ID] = it + byKey[it.BusinessKey] = it + } + var start []int64 + for _, k := range ExtractKeys(source.Markdown) { + if it, ok := byKey[k.Key]; ok && it.ReviewState == ReviewConfirmed { + start = append(start, it.ID) + } + } + edges, err := s.store.ListEdges(ctx, projectID) + if err != nil { + rep.CoverageStatus = ImpactFailed + rep.Summary = SummaryForCoverage(rep.CoverageStatus) + rep.Suggestions = Suggest(rep.CoverageStatus, false) + return rep, nil + } + hits, truncated := WalkReverse(edges, start, MaxWalkDepth, MaxWalkNodes) + docByKey := map[string]DocRef{source.DocID: source} + for _, d := range others { + docByKey[d.DocID] = d + } + for _, hit := range hits { + it := byID[hit.ItemID] + if !it.Tombstone && it.SourceDocID != "" && it.SourceDocID != source.DocID { + target := docByKey[it.SourceDocID] + if target.DocID != "" && !target.Visible { + continue + } + rep.Impacts = append(rep.Impacts, Impact{ + DocID: it.SourceDocID, ItemKey: it.BusinessKey, StageKey: target.StageKey, + ImpactType: ImpactConfirmed, Depth: hit.Depth, + Reason: fmt.Sprintf("%s 经已确认关系受本次修改影响", it.BusinessKey), + }) + if target.StageStatus == "completed" && target.StageKey != "" { + _, _ = s.store.PutMark(ctx, RecheckMark{ + ProjectID: projectID, StageKey: target.StageKey, SourceDoc: source.DocID, + Status: MarkOpen, Reason: "上游或同项目条目已变化", + }) + } + } + } + for _, e := range edges { + if e.ReviewState != ReviewCandidate { + continue + } + for _, id := range start { + if e.ToItemID == id || e.FromItemID == id { + rep.Impacts = append(rep.Impacts, Impact{ + ItemKey: byID[e.FromItemID].BusinessKey, ImpactType: ImpactPossible, Depth: 1, + Reason: "候选关系尚未确认", + }) + break + } + } + } + switch { + case truncated: + rep.CoverageStatus = ImpactPartial + case len(rep.Impacts) == 0: + rep.CoverageStatus = ImpactNoKnown + default: + rep.CoverageStatus = ImpactConfirmed + for _, imp := range rep.Impacts { + if imp.ImpactType == ImpactPossible && rep.CoverageStatus != ImpactConfirmed { + rep.CoverageStatus = ImpactPossible + } + if imp.ImpactType == ImpactConfirmed { + rep.CoverageStatus = ImpactConfirmed + break + } + } + onlyPossible := true + for _, imp := range rep.Impacts { + if imp.ImpactType == ImpactConfirmed { + onlyPossible = false + } + } + if onlyPossible { + rep.CoverageStatus = ImpactPossible + } + } + rep.Summary = SummaryForCoverage(rep.CoverageStatus) + if rep.CoverageStatus == ImpactConfirmed { + rep.Summary = fmt.Sprintf("发现 %d 条影响", len(rep.Impacts)) + } + rep.Suggestions = Suggest(rep.CoverageStatus, len(rep.Impacts) > 0) + return rep, nil +} + +// DecideCandidate confirms or rejects a model/candidate edge. +func (s *Service) DecideCandidate(ctx context.Context, edgeID, actorID int64, creator bool, downstreamDoc string, downstreamAuthor int64, decision, reason string) error { + if err := CanDecide(Actor{UserID: actorID, Creator: creator}, downstreamDoc, downstreamAuthor); err != nil { + return err + } + if decision != DecisionConfirm && decision != DecisionReject { + return fmt.Errorf("traceimpact: decision must be confirm or reject") + } + edge, err := s.store.GetEdge(ctx, edgeID) + if err != nil { + return err + } + review := ReviewConfirmed + if decision == DecisionReject { + review = ReviewRejected + } + if err := s.store.SetEdgeReview(ctx, edgeID, review); err != nil { + return err + } + _, err = s.store.AddDecision(ctx, Decision{ + ProjectID: edge.ProjectID, EdgeID: edgeID, Decision: decision, Reason: reason, DecidedBy: actorID, + }) + return err +} + +// NeedsRecheckAck is true when the stage still has an open mark. +// Acknowledgement does not clear the mark. +func (s *Service) NeedsRecheckAck(ctx context.Context, projectID int64, stageKey string) (bool, error) { + marks, err := s.store.ListOpenMarks(ctx, projectID, stageKey) + if err != nil { + return false, err + } + return len(marks) > 0, nil +} + +// AcknowledgeRecheck records that the creator saw the marks. Marks stay open. +func (s *Service) AcknowledgeRecheck(ctx context.Context, projectID int64, stageKey string, userID int64) error { + marks, err := s.store.ListOpenMarks(ctx, projectID, stageKey) + if err != nil { + return err + } + for _, m := range marks { + if err := s.store.AckMark(ctx, m.ID, userID); err != nil { + return err + } + } + return nil +} + +// ClearRecheck closes a mark only for an explicit human reason. +func (s *Service) ClearRecheck(ctx context.Context, markID int64, reason string) error { + switch reason { + case ClearNoChange, ClearRevision: + default: + return fmt.Errorf("traceimpact: recheck marks close only by no_change or revision_started") + } + return s.store.SetMarkStatus(ctx, markID, MarkCleared, reason) +} + +// ReopenMark opens a previously cleared mark again, for example after a rejected proposal. +func (s *Service) ReopenMark(ctx context.Context, markID int64, reason string) error { + return s.store.SetMarkStatus(ctx, markID, MarkOpen, reason) +} + +// RequestReopen stores a request. Creators are approved immediately and applied. +func (s *Service) RequestReopen(ctx context.Context, req ReopenRequest, creator bool) (ReopenRequest, error) { + if req.StageKey == "" || len(req.DocTasks) == 0 || strings.TrimSpace(req.Reason) == "" { + return ReopenRequest{}, fmt.Errorf("traceimpact: stage, documents, and reason are required") + } + req.Status = ReopenPending + stored, err := s.store.PutReopen(ctx, req) + if err != nil { + return ReopenRequest{}, err + } + if creator { + return s.ApproveReopen(ctx, stored.ID, req.RequestedBy) + } + return stored, nil +} + +// ApproveReopen lets the creator apply a pending request. +func (s *Service) ApproveReopen(ctx context.Context, requestID, creatorID int64) (ReopenRequest, error) { + req, err := s.store.GetReopen(ctx, requestID) + if err != nil { + return ReopenRequest{}, err + } + if req.Status == ReopenApproved { + return req, nil + } + if s.reopen == nil { + return ReopenRequest{}, fmt.Errorf("traceimpact: stage reopen is not configured") + } + if err := s.reopen.ReopenCompletedStage(ctx, req.InstanceID, req.StageKey, req.DocTasks, creatorID); err != nil { + return ReopenRequest{}, err + } + if s.unlock != nil { + if err := s.unlock.ReopenApprovedDocs(ctx, req.ProjectID, req.DocTasks, creatorID, req.Reason); err != nil { + return ReopenRequest{}, err + } + } + req.Status = ReopenApproved + return s.store.PutReopen(ctx, req) +} + +func (s *Service) ListItems(ctx context.Context, projectID int64) ([]Item, error) { + return s.store.ListItems(ctx, projectID) +} + +func (s *Service) ListIndex(ctx context.Context, projectID int64) ([]IndexState, error) { + return s.store.ListIndex(ctx, projectID) +} + +func (s *Service) ListOpenMarks(ctx context.Context, projectID int64, stageKey string) ([]RecheckMark, error) { + return s.store.ListOpenMarks(ctx, projectID, stageKey) +} + +func (s *Service) GetReopen(ctx context.Context, id int64) (ReopenRequest, error) { + return s.store.GetReopen(ctx, id) +} + +// ErrNeedsConfirm is returned when a revision proposal is requested before confirmation. +var ErrNeedsConfirm = fmt.Errorf("traceimpact: confirmation required") + +// ProposeRevision records a proposal for a human author. It never edits controlled content. +func (s *Service) ProposeRevision(ctx context.Context, projectID, userID int64, text string, confirmed bool) (string, error) { + if !confirmed { + return "", ErrNeedsConfirm + } + if projectID <= 0 || strings.TrimSpace(text) == "" { + return "", fmt.Errorf("traceimpact: project and proposal text are required") + } + id, err := s.store.AddProposal(ctx, projectID, userID, strings.TrimSpace(text)) + if err != nil { + return "", err + } + return fmt.Sprintf("proposal-%d-by-%d", id, userID), nil +} diff --git a/internal/traceimpact/service_test.go b/internal/traceimpact/service_test.go new file mode 100644 index 00000000..1847a4a3 --- /dev/null +++ b/internal/traceimpact/service_test.go @@ -0,0 +1,230 @@ +package traceimpact + +import ( + "context" + "strings" + "testing" +) + +func TestExtract_UntypedIsCandidateOnly(t *testing.T) { + keys := ExtractKeys("UR-001 对应 RS-002\n另见 AB-9") + explicit, untyped := 0, 0 + for _, k := range keys { + if k.Explicit { + explicit++ + } else { + untyped++ + } + } + if explicit != 2 || untyped != 1 { + t.Fatalf("explicit=%d untyped=%d keys=%v", explicit, untyped, keys) + } +} + +func TestWalkReverse_DepthAndCycle(t *testing.T) { + edges := []Edge{ + {ID: 1, FromItemID: 2, ToItemID: 1, RelationType: RelationRefines, ReviewState: ReviewConfirmed}, + {ID: 2, FromItemID: 3, ToItemID: 2, RelationType: RelationDependsOn, ReviewState: ReviewConfirmed}, + {ID: 3, FromItemID: 4, ToItemID: 3, RelationType: RelationDependsOn, ReviewState: ReviewConfirmed}, + {ID: 4, FromItemID: 1, ToItemID: 4, RelationType: RelationDependsOn, ReviewState: ReviewConfirmed}, + {ID: 5, FromItemID: 9, ToItemID: 1, RelationType: RelationDependsOn, ReviewState: ReviewCandidate}, + } + hits, truncated := WalkReverse(edges, []int64{1}, 3, 50) + if truncated { + t.Fatal("did not expect truncation") + } + got := map[int64]int{} + for _, h := range hits { + got[h.ItemID] = h.Depth + } + if got[2] != 1 || got[3] != 2 || got[4] != 3 { + t.Fatalf("depths = %#v", got) + } + if _, ok := got[9]; ok { + t.Fatal("candidate edge must not be followed") + } + if _, ok := got[1]; ok { + t.Fatal("cycle must not revisit the start as a hit") + } +} + +func TestAnalyze_PendingIsNotNoImpact(t *testing.T) { + svc := NewMemoryService() + rep, err := svc.Analyze(t.Context(), 7, DocRef{DocID: "doc-a", Markdown: "UR-001"}, nil) + if err != nil { + t.Fatal(err) + } + if rep.CoverageStatus != ImpactIndexPending { + t.Fatalf("status %s", rep.CoverageStatus) + } + if strings.Contains(rep.Summary, "未发现") { + t.Fatalf("pending must not look like no impact: %s", rep.Summary) + } +} + +func TestAnalyze_ConfirmedDownstreamAndRecheck(t *testing.T) { + svc := NewMemoryService() + ctx := t.Context() + if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 用户需要记录"}); err != nil { + t.Fatal(err) + } + if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "down", Markdown: "RS-010 细化 UR-001"}); err != nil { + t.Fatal(err) + } + rep, err := svc.Analyze(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 改为必须记录"}, []DocRef{{ + DocID: "down", StageKey: "V0.5", StageStatus: "completed", Visible: true, + }}) + if err != nil { + t.Fatal(err) + } + if rep.CoverageStatus != ImpactConfirmed { + t.Fatalf("status %s summary %s impacts %#v", rep.CoverageStatus, rep.Summary, rep.Impacts) + } + need, err := svc.NeedsRecheckAck(ctx, 7, "V0.5") + if err != nil || !need { + t.Fatalf("need ack %v %v", need, err) + } + if err := svc.AcknowledgeRecheck(ctx, 7, "V0.5", 3); err != nil { + t.Fatal(err) + } + need, err = svc.NeedsRecheckAck(ctx, 7, "V0.5") + if err != nil || !need { + t.Fatal("ack must not clear the mark") + } + marks, err := svc.store.ListOpenMarks(ctx, 7, "V0.5") + if err != nil || len(marks) != 1 { + t.Fatal(err) + } + if err := svc.ClearRecheck(ctx, marks[0].ID, "auto"); err == nil { + t.Fatal("auto clear must fail") + } + if err := svc.ClearRecheck(ctx, marks[0].ID, ClearNoChange); err != nil { + t.Fatal(err) + } + need, _ = svc.NeedsRecheckAck(ctx, 7, "V0.5") + if need { + t.Fatal("explicit no_change should clear") + } +} + +func TestDecide_DownstreamAuthorOnly(t *testing.T) { + if err := CanDecide(Actor{UserID: 2, Creator: true}, "down", 9); err == nil { + t.Fatal("creator must not override an existing author") + } + if err := CanDecide(Actor{UserID: 9}, "down", 9); err != nil { + t.Fatal(err) + } + if err := CanDecide(Actor{UserID: 1, Creator: true}, "down", 0); err != nil { + t.Fatal(err) + } +} + +type fakeReopen struct{ called bool } + +func (f *fakeReopen) ReopenCompletedStage(_ context.Context, _ int64, stageKey string, docTaskKeys []string, _ int64) error { + f.called = true + if stageKey != "V0.1" || len(docTaskKeys) != 1 { + return fmtError("bad reopen") + } + return nil +} + +type fmtError string + +func (e fmtError) Error() string { return string(e) } + +func TestReopen_CreatorAppliesWithoutPausingHook(t *testing.T) { + svc := NewMemoryService() + hook := &fakeReopen{} + svc.SetStageReopener(hook) + req, err := svc.RequestReopen(t.Context(), ReopenRequest{ + ProjectID: 1, InstanceID: 2, StageKey: "V0.1", DocTasks: []string{"bd_intake"}, + Reason: "新材料推翻了摸底", RequestedBy: 4, + }, true) + if err != nil { + t.Fatal(err) + } + if req.Status != ReopenApproved || !hook.called { + t.Fatalf("status %s called %v", req.Status, hook.called) + } +} + +func TestReopen_MemberStaysPending(t *testing.T) { + svc := NewMemoryService() + req, err := svc.RequestReopen(t.Context(), ReopenRequest{ + ProjectID: 1, InstanceID: 2, StageKey: "V0.1", DocTasks: []string{"bd_intake"}, + Reason: "需要重编", RequestedBy: 8, + }, false) + if err != nil { + t.Fatal(err) + } + if err != nil || req.Status != ReopenPending { + t.Fatalf("status %s err %v", req.Status, err) + } +} + +func TestIndex_SameSnapshotDoesNotDuplicateRevision(t *testing.T) { + svc := NewMemoryService() + doc := DocRef{DocID: "doc_proj1_intake", Markdown: "UR-001 用户需求", SnapshotID: 7, Visible: true} + if _, err := svc.IndexDocument(t.Context(), 1, doc); err != nil { + t.Fatal(err) + } + if _, err := svc.IndexDocument(t.Context(), 1, doc); err != nil { + t.Fatal(err) + } + items, err := svc.ListItems(t.Context(), 1) + if err != nil || len(items) != 1 { + t.Fatalf("items %+v err %v", items, err) + } + n, err := svc.store.RevisionCount(t.Context(), items[0].ID) + if err != nil || n != 1 { + t.Fatalf("revisions %d err %v", n, err) + } + again, err := svc.IndexDocument(t.Context(), 2, DocRef{DocID: "doc_proj2_intake", Markdown: "UR-001 另一项目", Visible: true}) + if err != nil { + t.Fatal(err) + } + _ = again + other, err := svc.ListItems(t.Context(), 1) + if err != nil || len(other) != 1 || other[0].ProjectID != 1 { + t.Fatalf("cross project leaked %+v", other) + } +} + +func TestRedactItems_HidesInvisibleStatements(t *testing.T) { + items := []Item{ + {BusinessKey: "UR-001", Statement: "可见标题", SourceDocID: "doc_proj1_intake"}, + {BusinessKey: "REQ-9", Statement: "秘密标题", SourceDocID: "doc_proj1_secret"}, + } + out, hidden := RedactItems(items, func(docID string) bool { return docID == "doc_proj1_intake" }) + if hidden != 1 || len(out) != 1 || out[0].Statement != "可见标题" { + t.Fatalf("out %+v hidden %d", out, hidden) + } + blob := strings.Join([]string{out[0].BusinessKey, out[0].Statement}, " ") + if strings.Contains(blob, "秘密") { + t.Fatal(blob) + } +} + +func TestMayRequestReopen_DutyHolderOnly(t *testing.T) { + seats := []DutySeat{{TaskKey: "intake", AuthorID: 8, ReviewerID: 2, ApproverID: 3}} + if !MayRequestReopen(8, false, []string{"intake"}, seats) { + t.Fatal("author") + } + if !MayRequestReopen(1, true, []string{"intake"}, nil) { + t.Fatal("creator") + } + if MayRequestReopen(99, false, []string{"intake"}, seats) { + t.Fatal("stranger") + } +} + +func TestPropose_UnconfirmedDoesNotStore(t *testing.T) { + svc := NewMemoryService() + if _, err := svc.ProposeRevision(t.Context(), 1, 4, "改写 UR-001", false); err != ErrNeedsConfirm { + t.Fatalf("err %v", err) + } + if _, err := svc.ProposeRevision(t.Context(), 1, 4, "改写 UR-001", true); err != nil { + t.Fatal(err) + } +} diff --git a/internal/traceimpact/store.go b/internal/traceimpact/store.go new file mode 100644 index 00000000..183d3150 --- /dev/null +++ b/internal/traceimpact/store.go @@ -0,0 +1,317 @@ +package traceimpact + +import ( + "context" + "fmt" + "sync" +) + +// Store persists trace rows. Implementations must not delete decision history. +type Store interface { + UpsertItem(ctx context.Context, item Item) (Item, error) + ListItems(ctx context.Context, projectID int64) ([]Item, error) + UpsertEdge(ctx context.Context, edge Edge) (Edge, error) + ListEdges(ctx context.Context, projectID int64) ([]Edge, error) + PutIndex(ctx context.Context, st IndexState) error + IndexStatus(ctx context.Context, projectID int64, docID string) (IndexState, bool, error) + ListIndex(ctx context.Context, projectID int64) ([]IndexState, error) + SetEdgeReview(ctx context.Context, edgeID int64, review string) error + AddDecision(ctx context.Context, d Decision) (Decision, error) + PutReopen(ctx context.Context, req ReopenRequest) (ReopenRequest, error) + GetReopen(ctx context.Context, id int64) (ReopenRequest, error) + PutMark(ctx context.Context, m RecheckMark) (RecheckMark, error) + ListOpenMarks(ctx context.Context, projectID int64, stageKey string) ([]RecheckMark, error) + AckMark(ctx context.Context, id, userID int64) error + SetMarkStatus(ctx context.Context, id int64, status, reason string) error + GetEdge(ctx context.Context, edgeID int64) (Edge, error) + AppendRevision(ctx context.Context, rev ItemRevision) error + RevisionCount(ctx context.Context, itemID int64) (int, error) + AddProposal(ctx context.Context, projectID, userID int64, text string) (int64, error) +} + +type memStore struct { + mu sync.Mutex + items map[int64]Item + byKey map[string]int64 + edges map[int64]Edge + byEdge map[string]int64 + index map[string]IndexState + decisions []Decision + reopens map[int64]ReopenRequest + marks map[int64]RecheckMark + revs map[int64][]ItemRevision + props []proposalRow + nextItem int64 + nextEdge int64 + nextDec int64 + nextReq int64 + nextMark int64 + nextProp int64 +} + +func newMemStore() *memStore { + return &memStore{ + items: map[int64]Item{}, + byKey: map[string]int64{}, + edges: map[int64]Edge{}, + byEdge: map[string]int64{}, + index: map[string]IndexState{}, + reopens: map[int64]ReopenRequest{}, + marks: map[int64]RecheckMark{}, + } +} + +func itemKey(projectID int64, kind, business string) string { + return fmt.Sprintf("%d|%s|%s", projectID, kind, business) +} + +type proposalRow struct { + id int64 + projectID int64 + userID int64 + text string +} + +func (m *memStore) UpsertItem(_ context.Context, item Item) (Item, error) { + m.mu.Lock() + defer m.mu.Unlock() + k := itemKey(item.ProjectID, item.Kind, item.BusinessKey) + if id, ok := m.byKey[k]; ok { + cur := m.items[id] + if item.Statement != "" { + cur.Statement = item.Statement + } + if item.SourceDocID != "" { + cur.SourceDocID = item.SourceDocID + } + cur.ReviewState = item.ReviewState + cur.Tombstone = item.Tombstone + m.items[id] = cur + return cur, nil + } + m.nextItem++ + item.ID = m.nextItem + if item.Lifecycle == "" { + item.Lifecycle = "active" + } + m.items[item.ID] = item + m.byKey[k] = item.ID + return item, nil +} + +func (m *memStore) ListItems(_ context.Context, projectID int64) ([]Item, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []Item + for _, it := range m.items { + if it.ProjectID == projectID { + out = append(out, it) + } + } + return out, nil +} + +func (m *memStore) UpsertEdge(_ context.Context, edge Edge) (Edge, error) { + m.mu.Lock() + defer m.mu.Unlock() + k := fmt.Sprintf("%d|%d|%d|%s|%s", edge.ProjectID, edge.FromItemID, edge.ToItemID, edge.RelationType, edge.Provenance) + if id, ok := m.byEdge[k]; ok { + cur := m.edges[id] + if cur.ReviewState == ReviewRejected && edge.ReviewState == ReviewCandidate { + return cur, nil + } + if edge.ReviewState != "" && cur.ReviewState != ReviewConfirmed { + cur.ReviewState = edge.ReviewState + } + m.edges[id] = cur + return cur, nil + } + m.nextEdge++ + edge.ID = m.nextEdge + m.edges[edge.ID] = edge + m.byEdge[k] = edge.ID + return edge, nil +} + +func (m *memStore) ListEdges(_ context.Context, projectID int64) ([]Edge, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []Edge + for _, e := range m.edges { + if e.ProjectID == projectID { + out = append(out, e) + } + } + return out, nil +} + +func (m *memStore) PutIndex(_ context.Context, st IndexState) error { + m.mu.Lock() + defer m.mu.Unlock() + if st.Extractor == "" { + st.Extractor = ExtractorVersion + } + m.index[fmt.Sprintf("%d|%s", st.ProjectID, st.DocID)] = st + return nil +} + +func (m *memStore) IndexStatus(_ context.Context, projectID int64, docID string) (IndexState, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + st, ok := m.index[fmt.Sprintf("%d|%s", projectID, docID)] + return st, ok, nil +} + +func (m *memStore) ListIndex(_ context.Context, projectID int64) ([]IndexState, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []IndexState + for _, st := range m.index { + if st.ProjectID == projectID { + out = append(out, st) + } + } + return out, nil +} + +func (m *memStore) SetEdgeReview(_ context.Context, edgeID int64, review string) error { + m.mu.Lock() + defer m.mu.Unlock() + e, ok := m.edges[edgeID] + if !ok { + return fmt.Errorf("traceimpact: edge not found") + } + e.ReviewState = review + m.edges[edgeID] = e + return nil +} + +func (m *memStore) AddDecision(_ context.Context, d Decision) (Decision, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.nextDec++ + d.ID = m.nextDec + m.decisions = append(m.decisions, d) + return d, nil +} + +func (m *memStore) PutReopen(_ context.Context, req ReopenRequest) (ReopenRequest, error) { + m.mu.Lock() + defer m.mu.Unlock() + if req.ID == 0 { + m.nextReq++ + req.ID = m.nextReq + } + m.reopens[req.ID] = req + return req, nil +} + +func (m *memStore) GetReopen(_ context.Context, id int64) (ReopenRequest, error) { + m.mu.Lock() + defer m.mu.Unlock() + req, ok := m.reopens[id] + if !ok { + return ReopenRequest{}, fmt.Errorf("traceimpact: reopen request not found") + } + return req, nil +} + +func (m *memStore) PutMark(_ context.Context, mark RecheckMark) (RecheckMark, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, existing := range m.marks { + if existing.ProjectID == mark.ProjectID && existing.StageKey == mark.StageKey && existing.SourceDoc == mark.SourceDoc && existing.Status == MarkOpen { + return existing, nil + } + } + m.nextMark++ + mark.ID = m.nextMark + if mark.Status == "" { + mark.Status = MarkOpen + } + m.marks[mark.ID] = mark + return mark, nil +} + +func (m *memStore) ListOpenMarks(_ context.Context, projectID int64, stageKey string) ([]RecheckMark, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []RecheckMark + for _, mark := range m.marks { + if mark.ProjectID != projectID || mark.Status != MarkOpen { + continue + } + if stageKey != "" && mark.StageKey != stageKey { + continue + } + out = append(out, mark) + } + return out, nil +} + +func (m *memStore) AckMark(_ context.Context, id, userID int64) error { + m.mu.Lock() + defer m.mu.Unlock() + mark, ok := m.marks[id] + if !ok { + return fmt.Errorf("traceimpact: mark not found") + } + mark.AckedBy = userID + m.marks[id] = mark + return nil +} + +func (m *memStore) SetMarkStatus(_ context.Context, id int64, status, reason string) error { + m.mu.Lock() + defer m.mu.Unlock() + mark, ok := m.marks[id] + if !ok { + return fmt.Errorf("traceimpact: mark not found") + } + mark.Status = status + if reason != "" { + mark.Reason = reason + } + m.marks[id] = mark + return nil +} + +func (m *memStore) GetEdge(_ context.Context, edgeID int64) (Edge, error) { + m.mu.Lock() + defer m.mu.Unlock() + e, ok := m.edges[edgeID] + if !ok { + return Edge{}, fmt.Errorf("traceimpact: edge not found") + } + return e, nil +} + +func (m *memStore) AppendRevision(_ context.Context, rev ItemRevision) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.revs == nil { + m.revs = map[int64][]ItemRevision{} + } + for _, existing := range m.revs[rev.ItemID] { + if existing.SnapshotID == rev.SnapshotID && existing.ContentHash == rev.ContentHash && existing.Extractor == rev.Extractor { + return nil + } + } + rev.RevisionNo = len(m.revs[rev.ItemID]) + 1 + m.revs[rev.ItemID] = append(m.revs[rev.ItemID], rev) + return nil +} + +func (m *memStore) RevisionCount(_ context.Context, itemID int64) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.revs[itemID]), nil +} + +func (m *memStore) AddProposal(_ context.Context, projectID, userID int64, text string) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.nextProp++ + m.props = append(m.props, proposalRow{id: m.nextProp, projectID: projectID, userID: userID, text: text}) + return m.nextProp, nil +} diff --git a/internal/traceimpact/types.go b/internal/traceimpact/types.go new file mode 100644 index 00000000..d3aa3a63 --- /dev/null +++ b/internal/traceimpact/types.go @@ -0,0 +1,164 @@ +package traceimpact + +// Coverage and impact statuses. Unknown, failed, and empty are different. +const ( + ImpactConfirmed = "confirmed_impact" + ImpactPossible = "possible_impact" + ImpactDependencyUnchanged = "dependency_unchanged" + ImpactNoKnown = "no_known_dependencies" + ImpactIndexPending = "index_pending" + ImpactPartial = "partial" + ImpactFailed = "failed" + + KindRequirement = "requirement" + KindTest = "test" + + ReviewConfirmed = "confirmed" + ReviewCandidate = "candidate" + ReviewRejected = "rejected" + + ProvenanceExplicit = "explicit_reference" + ProvenanceRule = "rule" + ProvenanceModel = "model_candidate" + + RelationRefines = "refines" + RelationDependsOn = "depends_on" + + DecisionConfirm = "confirm" + DecisionReject = "reject" + + ReopenPending = "pending" + ReopenApproved = "approved" + ReopenRejected = "rejected" + + MarkOpen = "open" + MarkCleared = "cleared" + + ClearNoChange = "no_change" + ClearRevision = "revision_started" + + ExtractorVersion = "trace-keys-v1" + MaxWalkDepth = 3 + MaxWalkNodes = 50 +) + +// ItemRevision is an immutable projection of one item at one snapshot. +type ItemRevision struct { + ID int64 + ItemID int64 + RevisionNo int + SourceDocID string + SnapshotID int64 + ContentHash string + Statement string + Extractor string + Tombstone bool +} + +// Item is a stable project entry. +type Item struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + BusinessKey string `json:"business_key"` + Kind string `json:"kind"` + Lifecycle string `json:"lifecycle_state"` + ReviewState string `json:"review_state"` + Tombstone bool `json:"tombstone"` + Statement string `json:"statement,omitempty"` + SourceDocID string `json:"source_doc_id,omitempty"` +} + +// Edge is a directed relation. Reverse walk follows dependents of a changed item. +type Edge struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + FromItemID int64 `json:"from_item_id"` + ToItemID int64 `json:"to_item_id"` + RelationType string `json:"relation_type"` + Provenance string `json:"provenance"` + ReviewState string `json:"review_state"` + EvidenceDoc string `json:"evidence_doc_id,omitempty"` + FromKey string `json:"from_key,omitempty"` + ToKey string `json:"to_key,omitempty"` +} + +// Decision is the authority record for a candidate edge. +type Decision struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + EdgeID int64 `json:"edge_id"` + Decision string `json:"decision"` + Reason string `json:"reason"` + DecidedBy int64 `json:"decided_by"` +} + +// IndexState is the coverage watermark for one document snapshot. +type IndexState struct { + ProjectID int64 `json:"project_id"` + DocID string `json:"doc_id"` + SnapshotID int64 `json:"source_snapshot_id"` + Status string `json:"status"` + Extractor string `json:"extractor_version"` + ItemCount int `json:"item_count"` + Error string `json:"error,omitempty"` +} + +// DocRef binds a document to the stage the caller is asking about. +type DocRef struct { + DocID string `json:"doc_id"` + StageKey string `json:"stage_key,omitempty"` + StageStatus string `json:"stage_status,omitempty"` + AuthorID int64 `json:"author_id,omitempty"` + Visible bool `json:"visible"` + Markdown string `json:"markdown,omitempty"` + SnapshotID int64 `json:"snapshot_id,omitempty"` +} + +// Suggestion is a clickable next step, not a free-text instruction. +type Suggestion struct { + Action string `json:"action"` + Reason string `json:"reason"` +} + +// Impact is one affected document or item. +type Impact struct { + DocID string `json:"doc_id,omitempty"` + ItemKey string `json:"item_key,omitempty"` + StageKey string `json:"stage_key,omitempty"` + ImpactType string `json:"impact_type"` + Depth int `json:"depth"` + Reason string `json:"reason"` +} + +// Report is the deterministic impact result. +type Report struct { + ProjectID int64 `json:"project_id"` + SourceDocID string `json:"source_doc_id"` + CoverageStatus string `json:"coverage_status"` + Impacts []Impact `json:"impacts"` + Suggestions []Suggestion `json:"suggestions"` + Summary string `json:"summary"` +} + +// ReopenRequest asks to unlock selected documents on a completed stage. +type ReopenRequest struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + InstanceID int64 `json:"instance_id"` + StageKey string `json:"stage_key"` + DocTasks []string `json:"doc_task_keys"` + Reason string `json:"reason"` + RequestedBy int64 `json:"requested_by"` + Status string `json:"status"` +} + +// RecheckMark is a soft signal on a completed stage. +type RecheckMark struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + StageKey string `json:"stage_key"` + SourceDoc string `json:"source_doc_id"` + Status string `json:"status"` + Reason string `json:"reason"` + AckedBy int64 `json:"acked_by,omitempty"` +} diff --git a/internal/traceimpact/walk.go b/internal/traceimpact/walk.go new file mode 100644 index 00000000..9c691c41 --- /dev/null +++ b/internal/traceimpact/walk.go @@ -0,0 +1,59 @@ +package traceimpact + +// WalkHit is one node reached by a reverse walk. +type WalkHit struct { + ItemID int64 + EdgeID int64 + Depth int + Via string +} + +// WalkReverse follows confirmed edges from changed items to their dependents. +// An edge From --depends_on/refines--> To means From depends on To. +// Changing To therefore impacts From. Candidate edges are not followed. +func WalkReverse(edges []Edge, start []int64, maxDepth, maxNodes int) (hits []WalkHit, truncated bool) { + if maxDepth <= 0 { + maxDepth = MaxWalkDepth + } + if maxNodes <= 0 { + maxNodes = MaxWalkNodes + } + startSet := map[int64]bool{} + for _, id := range start { + startSet[id] = true + } + type node struct { + id int64 + depth int + } + queue := make([]node, 0, len(start)) + for _, id := range start { + queue = append(queue, node{id: id, depth: 0}) + } + seen := map[int64]bool{} + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + if seen[cur.id] { + continue + } + seen[cur.id] = true + if len(seen) > maxNodes { + return hits, true + } + if cur.depth >= maxDepth { + continue + } + for _, e := range edges { + if e.ReviewState != ReviewConfirmed || e.ToItemID != cur.id { + continue + } + if startSet[e.FromItemID] || seen[e.FromItemID] { + continue + } + hits = append(hits, WalkHit{ItemID: e.FromItemID, EdgeID: e.ID, Depth: cur.depth + 1, Via: e.RelationType}) + queue = append(queue, node{id: e.FromItemID, depth: cur.depth + 1}) + } + } + return hits, false +} From 432884911ff9fd7e6c5e656e31f4b1e81ced0ded Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 15:35:07 +0000 Subject: [PATCH 2/8] feat(docrevision): attach coverage status to formal human revisions Unknown, partial, and failed coverage stay distinct from no known dependencies. Co-authored-by: Cursor --- internal/docrevision/commit_report.go | 50 ++++++++++++++++++++++ internal/docrevision/commit_report_test.go | 45 +++++++++++++++++++ internal/docrevision/pg_mutate.go | 15 ++++++- internal/docrevision/reconcile.go | 2 +- internal/docrevision/service.go | 14 ++++-- internal/docrevision/types.go | 9 ++++ 6 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 internal/docrevision/commit_report.go create mode 100644 internal/docrevision/commit_report_test.go diff --git a/internal/docrevision/commit_report.go b/internal/docrevision/commit_report.go new file mode 100644 index 00000000..630dff87 --- /dev/null +++ b/internal/docrevision/commit_report.go @@ -0,0 +1,50 @@ +package docrevision + +import "context" + +// CoverageResolver reports whether the trace index can support a no-impact claim. +// nil or an empty result means the index has not run; that must not be shown as no impact. +func (s *Service) formalCommitReportLocked(ctx context.Context, docID string, fromID, toID int64, hunks []DiffHunk) *ChangeReport { + impacts := s.impactLocked(ctx, docID) + coverage := ImpactIndexPending + if s.CoverageResolver != nil { + if got := s.CoverageResolver(ctx, docID); got != "" { + coverage = got + } + } + rep := fallbackReport("", "", RuleRiskFloor(hunks, impacts), impacts, hunks, "formal commit") + rep.DocID = docID + rep.FromSnapshotID = fromID + rep.ToSnapshotID = toID + rep.AnalysisStatus = AnalysisFallback + rep.Authority = ReportAuthoritative + rep.Lifecycle = ReportCommitted + if rep.DeterministicFacts == nil { + rep.DeterministicFacts = map[string]any{} + } + rep.DeterministicFacts["coverage_status"] = coverage + rep.DeterministicFacts["suggested_actions"] = formalSuggestions(coverage, len(impacts) > 0) + switch coverage { + case ImpactNoKnown: + rep.Summary = append(rep.Summary, "索引已覆盖,未发现已知关联") + case ImpactConfirmed, ImpactPossible: + rep.Summary = append(rep.Summary, "这次修改可能影响其他文档,见 document_impacts") + default: + rep.Summary = append(rep.Summary, "分析尚未完成或失败,不能判断为无影响") + rep.OpenQuestions = append(rep.OpenQuestions, "不能判断为无影响") + } + stampReportDisposition(rep) + return rep +} + +func formalSuggestions(coverage string, hasImpact bool) []string { + switch coverage { + case ImpactIndexPending, ImpactPartial, ImpactFailed: + return []string{"wait_index"} + } + out := []string{"no_change"} + if hasImpact || coverage == ImpactPossible || coverage == ImpactConfirmed { + out = append(out, "l1_revise", "l2_reopen") + } + return out +} diff --git a/internal/docrevision/commit_report_test.go b/internal/docrevision/commit_report_test.go new file mode 100644 index 00000000..96294806 --- /dev/null +++ b/internal/docrevision/commit_report_test.go @@ -0,0 +1,45 @@ +package docrevision + +import ( + "context" + "strings" + "testing" +) + +func TestHumanRevision_ReportDoesNotClaimNoImpactWithoutIndex(t *testing.T) { + svc := NewService(nil) + res, err := svc.CommitHumanRevision(t.Context(), HumanRevisionRequest{ + DocID: "doc-1", Markdown: "UR-001 需要记录", IdempotencyKey: "k1", + ActorUserID: 1, AllowCreateBaseline: true, ProjectID: 3, + }) + if err != nil { + t.Fatal(err) + } + if res.Report == nil || res.Report.Authority != ReportAuthoritative { + t.Fatalf("report %#v", res.Report) + } + if res.Report.DeterministicFacts["coverage_status"] != ImpactIndexPending { + t.Fatalf("coverage %#v", res.Report.DeterministicFacts["coverage_status"]) + } + for _, line := range res.Report.Summary { + if strings.Contains(line, "未发现已知关联") { + t.Fatal(line) + } + } +} + +func TestHumanRevision_IndexedNoKnown(t *testing.T) { + svc := NewService(nil) + svc.CoverageResolver = func(ctx context.Context, docID string) string { return ImpactNoKnown } + res, err := svc.CommitHumanRevision(t.Context(), HumanRevisionRequest{ + DocID: "doc-2", Markdown: "没有条目", IdempotencyKey: "k2", + ActorUserID: 1, AllowCreateBaseline: true, ProjectID: 3, + }) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(res.Report.Summary, " ") + if !strings.Contains(joined, "未发现已知关联") { + t.Fatalf("summary %s", joined) + } +} diff --git a/internal/docrevision/pg_mutate.go b/internal/docrevision/pg_mutate.go index 9d93a0c4..c45a49e9 100644 --- a/internal/docrevision/pg_mutate.go +++ b/internal/docrevision/pg_mutate.go @@ -66,7 +66,7 @@ func (s *Service) openTemplatePGLocked(ctx context.Context, docID, canon, sha st return s.mustStateLocked(docID), nil } -func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootPointers, ver *Version, snap *Snapshot, ev *ChangeEvent, isBaseline bool) error { +func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootPointers, ver *Version, snap *Snapshot, ev *ChangeEvent, isBaseline bool, rep *ChangeReport) error { if !s.usingPG() { if root != nil && snap != nil { key := "" @@ -75,6 +75,10 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP } s.noteContentProjectionLocked(root.DocID, snap.ID, root.HeadRevisionNo, key) } + if rep != nil { + rep.ToSnapshotID = snap.ID + s.assignReportIDLocked(rep) + } return nil } blob := s.blobs[snap.ContentSHA256] @@ -127,6 +131,15 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP if err := s.insertContentProjectionTX(ctx, tx, root.DocID, snap.ID, root.HeadRevisionNo, key); err != nil { return err } + if rep != nil { + rep.ToSnapshotID = snap.ID + rid, err := s.insertReportTX(ctx, tx, rep) + if err != nil { + return err + } + s.adoptReportID(rep, rid) + s.reports[rep.ID] = rep + } s.noteContentProjectionLocked(root.DocID, snap.ID, root.HeadRevisionNo, key) return s.projectRootPointersTX(ctx, tx, root) }) diff --git a/internal/docrevision/reconcile.go b/internal/docrevision/reconcile.go index 4e84580e..c51a9906 100644 --- a/internal/docrevision/reconcile.go +++ b/internal/docrevision/reconcile.go @@ -174,7 +174,7 @@ func (s *Service) CommitMigrationRepair(ctx context.Context, req MigrationRepair root.RowRevision++ eventNo := s.nextEventNoLocked(req.DocID) ev := s.putEventLocked(req.DocID, ver.ID, eventNo, parentPtr, snap.ID, ChangeMigrationRepair, ActorSystem, nil, "migration_repair", actor, nil, "", "", idemp) - if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false); err != nil { + if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false, nil); err != nil { s.forgetDocLocked(req.DocID) return nil, err } diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go index bd6227cd..af9723fa 100644 --- a/internal/docrevision/service.go +++ b/internal/docrevision/service.go @@ -41,6 +41,10 @@ type Service struct { // ImpactResolver returns deterministic downstream impacts (lineage); optional. ImpactResolver func(ctx context.Context, docID string) []DocImpact + // CoverageResolver classifies trace-index coverage for a formal commit. + // When unset, reports stay index_pending and must not claim no impact. + CoverageResolver func(ctx context.Context, docID string) string + // NarrativeFetcher optionally loads typed PI narrative JSON when // PI_CHANGE_REPORT_NARRATIVE is on and ReportJSON is empty. NarrativeFetcher func(ctx context.Context, spec NarrativeFetchSpec) (string, error) @@ -249,12 +253,13 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ root.ContentSHA256 = sha root.RowRevision++ ev := s.putEventLocked(req.DocID, ver.ID, 1, nil, snap.ID, ChangeImport, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, nil, req.EditSessionID, req.RequestID, req.IdempotencyKey) - if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, true); err != nil { + rep := s.formalCommitReportLocked(ctx, req.DocID, 0, snap.ID, nil) + if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, true, rep); err != nil { s.forgetDocLocked(req.DocID) return nil, err } st := s.mustStateLocked(req.DocID) - return &HumanRevisionResult{State: *st, Event: *ev}, nil + return &HumanRevisionResult{State: *st, Event: *ev, Report: rep}, nil } ver := s.versions[*root.LedgerVersionID] @@ -300,7 +305,8 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ root.RowRevision++ eventNo := s.nextEventNoLocked(req.DocID) ev := s.putEventLocked(req.DocID, ver.ID, eventNo, &parentID, snap.ID, ChangeHumanRevision, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, diff.Hunks, req.EditSessionID, req.RequestID, req.IdempotencyKey) - if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false); err != nil { + rep := s.formalCommitReportLocked(ctx, req.DocID, parentID, snap.ID, diff.Hunks) + if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false, rep); err != nil { s.forgetDocLocked(req.DocID) return nil, err } @@ -309,7 +315,7 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ overlay := ComputeDiff(baseMD, canon, s.snapshots[ver.BaselineSnapshotID].BlockMap, snap.BlockMap) overlay.FromSnapshotID = ver.BaselineSnapshotID overlay.ToSnapshotID = snap.ID - return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay}, nil + return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay, Report: rep}, nil } func (s *Service) OpenTemplateVersion(ctx context.Context, docID string, markdown string, actor ActorSnapshot, projectID int64, workDir string) (*RevisionState, error) { diff --git a/internal/docrevision/types.go b/internal/docrevision/types.go index 0a62220f..8ccfb810 100644 --- a/internal/docrevision/types.go +++ b/internal/docrevision/types.go @@ -56,6 +56,14 @@ const ( AnalysisUnassessed = "unassessed" AnalysisCorrected = "corrected" + // Trace coverage carried on a formal commit report. These are not PI analysis statuses. + ImpactConfirmed = "confirmed_impact" + ImpactPossible = "possible_impact" + ImpactNoKnown = "no_known_dependencies" + ImpactIndexPending = "index_pending" + ImpactPartial = "partial" + ImpactFailed = "failed" + // Report authority separates a candidate estimate from the report written at formal commit. ReportProposed = "proposed" ReportAuthoritative = "authoritative" @@ -318,6 +326,7 @@ type HumanRevisionResult struct { Event ChangeEvent `json:"event"` Overlay DiffResult `json:"manual_overlay"` Conflict *ConflictInfo `json:"conflict,omitempty"` + Report *ChangeReport `json:"report,omitempty"` } type ConflictInfo struct { From 4a7b8cc26f9b3a036bc1d0d908e00bb2a94bab09 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 15:35:07 +0000 Subject: [PATCH 3/8] feat(workflow): reopen a completed stage without pausing the later one Selected documents return to drafting, and advance asks the creator to acknowledge an open recheck mark. Co-authored-by: Cursor --- internal/docgov/engine.go | 23 +++ internal/docgov/reopen_test.go | 24 +++ internal/docgov/state_machine.go | 24 +++ internal/projectinstance/errors.go | 2 + internal/projectinstance/service.go | 13 ++ internal/projectinstance/stage_reopen_test.go | 139 ++++++++++++++++++ internal/projectinstance/stage_service.go | 52 +++++-- internal/projectinstance/stage_store.go | 104 ++++++++++++- internal/projectinstance/store.go | 69 ++++++++- internal/projectinstance/types.go | 8 +- 10 files changed, 437 insertions(+), 21 deletions(-) create mode 100644 internal/docgov/reopen_test.go create mode 100644 internal/projectinstance/stage_reopen_test.go diff --git a/internal/docgov/engine.go b/internal/docgov/engine.go index f0220df7..ab5ad78c 100644 --- a/internal/docgov/engine.go +++ b/internal/docgov/engine.go @@ -991,6 +991,29 @@ func (e *GovernanceEngine) ApproverReject(ctx context.Context, docID string, act return doc, nil } +// ReopenForRevision moves an approved document back to drafting without recording a rejection. +func (e *GovernanceEngine) ReopenForRevision(ctx context.Context, docID string, actorID int64, actorName, reason string) (*DocumentRecord, error) { + e.mu.Lock() + defer e.mu.Unlock() + doc, exists := e.memDocs[docID] + if !exists { + var err error + doc, err = e.loadDocumentFromDBLocked(ctx, docID) + if err != nil || doc == nil { + return nil, ErrDocumentNotFound + } + } + audit, err := e.sm.TransitionReopenForRevision(doc, actorID, actorName, reason) + if err != nil { + return nil, err + } + e.memAudits[docID] = append(e.memAudits[docID], audit) + if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { + return nil, err + } + return doc, nil +} + // GetDocument 获取文档实体 (优先查内存,未命中查 DB) func (e *GovernanceEngine) GetDocument(ctx context.Context, docID string) (*DocumentRecord, error) { e.mu.Lock() diff --git a/internal/docgov/reopen_test.go b/internal/docgov/reopen_test.go new file mode 100644 index 00000000..15a6dfc2 --- /dev/null +++ b/internal/docgov/reopen_test.go @@ -0,0 +1,24 @@ +package docgov + +import ( + "errors" + "testing" +) + +func TestTransitionReopenForRevision_IsNotARejection(t *testing.T) { + sm := NewStateMachine() + doc := &DocumentRecord{DocID: "doc_reopen", CurrentState: StateApproved, IsLocked: true, Config: DefaultDocumentConfig()} + audit, err := sm.TransitionReopenForRevision(doc, 4, "creator", "新材料") + if err != nil { + t.Fatal(err) + } + if doc.CurrentState != StateDrafting || doc.IsLocked { + t.Fatalf("state %s locked %v", doc.CurrentState, doc.IsLocked) + } + if audit.Action != "reopen_for_revision" || audit.ActorRole != RoleSystem { + t.Fatalf("audit %+v", audit) + } + if _, err := sm.TransitionReopenForRevision(doc, 4, "creator", "again"); !errors.Is(err, ErrInvalidStateTransition) { + t.Fatalf("drafting reopen err %v", err) + } +} diff --git a/internal/docgov/state_machine.go b/internal/docgov/state_machine.go index 8dd09008..5f97cfad 100644 --- a/internal/docgov/state_machine.go +++ b/internal/docgov/state_machine.go @@ -283,3 +283,27 @@ func (sm *StateMachine) TransitionApproverReject(doc *DocumentRecord, actorID in CreatedAt: now, }, nil } + +// TransitionReopenForRevision unlocks an approved document for another draft. +// It is not a rejection: the previous approval stays in the audit as a reopen. +func (sm *StateMachine) TransitionReopenForRevision(doc *DocumentRecord, actorID int64, actorName, reason string) (*AuditLog, error) { + if doc == nil { + return nil, ErrDocumentNotFound + } + if doc.CurrentState != StateApproved { + return nil, fmt.Errorf("%w: 只有已批准文档可以再打开编制,当前为【%s】", ErrInvalidStateTransition, doc.CurrentState) + } + if strings.TrimSpace(reason) == "" { + return nil, fmt.Errorf("%w: 再打开必须填写原因", ErrInvalidStateTransition) + } + from := doc.CurrentState + doc.CurrentState = StateDrafting + doc.IsLocked = false + now := time.Now() + doc.UpdatedAt = now + return &AuditLog{ + DocID: doc.DocID, Action: "reopen_for_revision", ActorID: actorID, ActorName: actorName, + ActorRole: RoleSystem, FromState: from, ToState: StateDrafting, + Details: map[string]any{"reason": reason, "timestamp": now}, CreatedAt: now, + }, nil +} diff --git a/internal/projectinstance/errors.go b/internal/projectinstance/errors.go index c82981b1..a81bea6a 100644 --- a/internal/projectinstance/errors.go +++ b/internal/projectinstance/errors.go @@ -23,6 +23,8 @@ var ( ErrInstanceNotFound = errors.New("workflow instance not found") ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved") ErrStageNotActive = errors.New("stage is not the active stage") + ErrStageNotCompleted = errors.New("stage is not completed") + ErrRecheckAckRequired = errors.New("recheck acknowledgement required") ErrInvalidStage = errors.New("invalid stage key for instance") ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch") ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate") diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go index 1fbd2942..7eeae54a 100644 --- a/internal/projectinstance/service.go +++ b/internal/projectinstance/service.go @@ -28,6 +28,19 @@ type Service struct { authorOnlyPolicy AuthorOnlyPolicySource projector DocumentDutyProjector busyChecker DocTaskBusyChecker + recheck RecheckGate +} + +// RecheckGate is the soft hold on a completed stage. Acknowledgement is not clearance. +type RecheckGate interface { + StageNeedsAck(ctx context.Context, projectID int64, stageKey string) (bool, error) + Acknowledge(ctx context.Context, projectID int64, stageKey string, userID int64) error +} + +func (s *Service) SetRecheckGate(g RecheckGate) { + if s != nil { + s.recheck = g + } } func NewService(store Store, wfStore VersionResolver, projects projectworkflow.Store, docs DocumentReadinessChecker) *Service { diff --git a/internal/projectinstance/stage_reopen_test.go b/internal/projectinstance/stage_reopen_test.go new file mode 100644 index 00000000..7ed6fd02 --- /dev/null +++ b/internal/projectinstance/stage_reopen_test.go @@ -0,0 +1,139 @@ +package projectinstance + +import ( + "context" + "errors" + "testing" +) + +type ackGate struct { + need bool + acked int +} + +func (g *ackGate) StageNeedsAck(context.Context, int64, string) (bool, error) { + return g.need, nil +} + +func (g *ackGate) Acknowledge(context.Context, int64, string, int64) error { + g.acked++ + return nil +} + +func TestReopen_KeepsLaterStageActive(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolverMultiStage(), nil, &stubDocChecker{states: map[string]string{ + "intake": "approved", "feasibility_reg": "approved", + }}) + plan := validPlan() + plan.Stages = append(plan.Stages, StagePlan{ + StageKey: "V0.5_feasibility", Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "feasibility_reg", Enabled: true, + Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + }}, + }) + if _, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1}); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 42, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + activated, err := svc.Activate(ctx, 42, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("activate: %v", err) + } + advanced, err := svc.AdvanceStage(ctx, activated.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: activated.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("advance: %v", err) + } + if err := svc.ReopenStage(ctx, activated.Instance.ID, "V0.1_triage", []string{"intake"}, 1); err != nil { + t.Fatalf("reopen: %v", err) + } + reopened, err := svc.GetInstanceDetail(ctx, activated.Instance.ID) + if err != nil { + t.Fatal(err) + } + var triage, feasibility string + for _, st := range reopened.Stages { + switch st.StageKey { + case "V0.1_triage": + triage = st.Status + case "V0.5_feasibility": + feasibility = st.Status + } + } + if triage != StageActive || feasibility != StageActive { + t.Fatalf("triage %s feasibility %s", triage, feasibility) + } + again, err := svc.AdvanceStage(ctx, activated.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: reopened.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("second advance: %v", err) + } + if again.NextStageKey != "" && again.NextStageKey != "V0.5_feasibility" { + t.Fatalf("unexpected next %s", again.NextStageKey) + } + detail, err := svc.GetInstanceDetail(ctx, activated.Instance.ID) + if err != nil { + t.Fatal(err) + } + for _, st := range detail.Stages { + if st.StageKey == "V0.5_feasibility" && st.Status != StageActive { + t.Fatalf("downstream became %s", st.Status) + } + if st.StageKey == "V0.1_triage" && st.Status != StageCompleted { + t.Fatalf("reopened stage became %s", st.Status) + } + } + _ = advanced +} + +func TestAdvance_RecheckAckDoesNotClearByItself(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 9, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) + gate := &ackGate{need: true} + svc.SetRecheckGate(gate) + if _, err := svc.SaveWorkflowPlan(ctx, 9, 1, true, SavePlanInput{Plan: validPlan(), ExpectedPlanRevision: 0}); err != nil { + t.Fatal(err) + } + preview, err := svc.ActivationPreview(ctx, 9, 1, true) + if err != nil { + t.Fatal(err) + } + result, err := svc.Activate(ctx, 9, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatal(err) + } + _, err = svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, + }) + if !errors.Is(err, ErrRecheckAckRequired) { + t.Fatalf("expected ack required, got %v", err) + } + if gate.acked != 0 { + t.Fatal("ack recorded before confirm") + } + if _, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, AcknowledgeRecheck: true, + }); err != nil { + t.Fatal(err) + } + if gate.acked != 1 { + t.Fatalf("acked %d", gate.acked) + } +} diff --git a/internal/projectinstance/stage_service.go b/internal/projectinstance/stage_service.go index 82b6317b..40c98bc6 100644 --- a/internal/projectinstance/stage_service.go +++ b/internal/projectinstance/stage_service.go @@ -50,16 +50,31 @@ func (s *Service) GetStageReadiness(ctx context.Context, instanceID int64, stage if stageDef != nil && stageDef.Name != "" { stageName = stageDef.Name } + stageIsActive := false + for _, st := range detail.Stages { + if st.StageKey == stageKey && st.Status == StageActive { + stageIsActive = true + break + } + } + needsAck := false + if s.recheck != nil { + needsAck, err = s.recheck.StageNeedsAck(ctx, detail.Instance.ProjectID, stageKey) + if err != nil { + return nil, err + } + } return &StageReadiness{ - InstanceID: instanceID, - StageKey: stageKey, - StageName: stageName, - Ready: ready, - CanAdvance: isCreator && ready && detail.ActiveStageKey == stageKey, - BlockingIssues: issues, - DocTasks: tasks, - ConfigRevision: detail.Instance.ConfigRevision, + InstanceID: instanceID, + StageKey: stageKey, + StageName: stageName, + Ready: ready, + CanAdvance: isCreator && ready && stageIsActive, + RequiresRecheckAck: needsAck, + BlockingIssues: issues, + DocTasks: tasks, + ConfigRevision: detail.Instance.ConfigRevision, }, nil } @@ -80,11 +95,18 @@ func (s *Service) AdvanceStage(ctx context.Context, instanceID int64, userID int if in.ExpectedConfigRevision != readiness.ConfigRevision { return nil, ErrInstanceRevisionConflict } - before, err := s.store.GetInstanceByID(ctx, instanceID) if err != nil { return nil, err } + if readiness.RequiresRecheckAck && !in.AcknowledgeRecheck { + return nil, ErrRecheckAckRequired + } + if readiness.RequiresRecheckAck && s.recheck != nil { + if err := s.recheck.Acknowledge(ctx, before.Instance.ProjectID, in.StageKey, userID); err != nil { + return nil, err + } + } completedStage := before.ActiveStageKey var completedStageInstanceID int64 for _, st := range before.Stages { @@ -108,3 +130,15 @@ func (s *Service) AdvanceStage(ctx context.Context, instanceID int64, userID int InstanceDone: after.Instance.Status == InstanceCompleted, }, nil } + +// ReopenStage opens a completed stage again. Other stages stay as they are. +func (s *Service) ReopenStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) error { + if !s.Available() { + return ErrNotAvailable + } + if stageKey == "" || len(docTaskKeys) == 0 { + return ErrInvalidStage + } + _, err := s.store.ReopenStage(ctx, instanceID, stageKey, docTaskKeys, operatorID) + return err +} diff --git a/internal/projectinstance/stage_store.go b/internal/projectinstance/stage_store.go index 70b7dbad..d0540362 100644 --- a/internal/projectinstance/stage_store.go +++ b/internal/projectinstance/stage_store.go @@ -118,17 +118,35 @@ func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey s if err != nil { return nil, err } + var otherActive int + if err = tx.QueryRow(ctx, ` + SELECT COUNT(*) FROM project_stage_instances + WHERE workflow_instance_id = $1 AND enabled = true AND status = 'active' + `, instanceID).Scan(&otherActive); err != nil { + return nil, err + } var nextStageID int64 var nextStageKey string var projectID int64 _ = tx.QueryRow(ctx, `SELECT project_id FROM project_workflow_instances WHERE id = $1`, instanceID).Scan(&projectID) - err = tx.QueryRow(ctx, ` - SELECT id, stage_key FROM project_stage_instances - WHERE workflow_instance_id = $1 AND enabled = true AND status = 'pending' - ORDER BY position ASC, id ASC LIMIT 1 - `, instanceID).Scan(&nextStageID, &nextStageKey) - if errors.Is(err, pgx.ErrNoRows) { + err = pgx.ErrNoRows + if otherActive == 0 { + err = tx.QueryRow(ctx, ` + SELECT id, stage_key FROM project_stage_instances + WHERE workflow_instance_id = $1 AND enabled = true AND status = 'pending' + ORDER BY position ASC, id ASC LIMIT 1 + `, instanceID).Scan(&nextStageID, &nextStageKey) + } + if otherActive > 0 { + _, err = tx.Exec(ctx, ` + UPDATE project_workflow_instances SET status = 'active', config_revision = config_revision + 1, updated_at = $2 + WHERE id = $1 + `, instanceID, now) + if err != nil { + return nil, err + } + } else if errors.Is(err, pgx.ErrNoRows) { _, err = tx.Exec(ctx, ` UPDATE project_workflow_instances SET status = 'completed', config_revision = config_revision + 1, completed_at = $2, completed_by = $3, updated_at = $2 @@ -201,6 +219,80 @@ func (s *pgStore) MarkDocTaskApproved(ctx context.Context, projectID int64, task return err } +func (s *pgStore) ReopenStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) (*InstanceDetail, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var instStatus string + err = tx.QueryRow(ctx, ` + SELECT status FROM project_workflow_instances WHERE id = $1 FOR UPDATE + `, instanceID).Scan(&instStatus) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInstanceNotFound + } + if err != nil { + return nil, err + } + if instStatus != InstanceActive && instStatus != InstanceCompleted { + return nil, fmt.Errorf("%w: instance status %s", ErrInvalidStage, instStatus) + } + var stageID int64 + var stageStatus string + err = tx.QueryRow(ctx, ` + SELECT id, status FROM project_stage_instances + WHERE workflow_instance_id = $1 AND stage_key = $2 AND enabled = true + `, instanceID, stageKey).Scan(&stageID, &stageStatus) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalidStage + } + if err != nil { + return nil, err + } + if stageStatus != StageCompleted { + return nil, ErrStageNotCompleted + } + now := time.Now() + if _, err = tx.Exec(ctx, ` + UPDATE project_stage_instances SET status = 'active', updated_at = $2 WHERE id = $1 + `, stageID, now); err != nil { + return nil, err + } + if len(docTaskKeys) > 0 { + if _, err = tx.Exec(ctx, ` + UPDATE project_doc_task_instances + SET status = 'in_progress', updated_at = $3 + WHERE stage_instance_id = $1 AND task_key = ANY($2) AND status = 'approved' + `, stageID, docTaskKeys, now); err != nil { + return nil, err + } + } + if _, err = tx.Exec(ctx, ` + UPDATE project_workflow_instances + SET status = 'active', config_revision = config_revision + 1, updated_at = $2, + completed_at = NULL, completed_by = NULL + WHERE id = $1 + `, instanceID, now); err != nil { + return nil, err + } + _ = operatorID + if err := tx.Commit(ctx); err != nil { + return nil, err + } + row := s.pool.QueryRow(ctx, ` + SELECT id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, + config_snapshot, activated_by, activated_at, created_by, created_at, updated_at + FROM project_workflow_instances WHERE id = $1 + `, instanceID) + inst, err := scanInstance(row) + if err != nil { + return nil, err + } + return s.loadInstanceDetail(ctx, inst) +} + type dbExec interface { Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) } diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index c532211e..3918a361 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -57,6 +57,8 @@ type Store interface { GetInstanceByID(ctx context.Context, instanceID int64) (*InstanceDetail, error) AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int, operatorID int64) (*InstanceDetail, error) + // ReopenStage sets a completed stage back to active without changing other stages. + ReopenStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) (*InstanceDetail, error) MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error // UpdateLiveDocTaskDuties 更新 live 实例 config_snapshot 与 document_duty_assignments(单任务)。 UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, taskKey string, plan WorkflowPlan, assignedBy int64) (*WorkflowInstance, error) @@ -1148,16 +1150,27 @@ func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey st return nil, ErrInvalidStage } records[activeIdx].status = StageCompleted - nextIdx := -1 + anotherActive := false for i, rec := range records { - if rec.enabled && rec.status == StagePending { - nextIdx = i + if i != activeIdx && rec.enabled && rec.status == StageActive { + anotherActive = true break } } + nextIdx := -1 + if !anotherActive { + for i, rec := range records { + if rec.enabled && rec.status == StagePending { + nextIdx = i + break + } + } + } inst.ConfigRevision++ inst.UpdatedAt = time.Now() - if nextIdx < 0 { + if anotherActive { + inst.Status = InstanceActive + } else if nextIdx < 0 { inst.Status = InstanceCompleted if pm := m.meta[inst.ProjectID]; pm.lifecycle != "" { pm.lifecycle = LifecyclePlanning @@ -1173,6 +1186,54 @@ func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey st return m.instanceDetailLocked(©Inst), nil } +func (m *memStore) ReopenStage(_ context.Context, instanceID int64, stageKey string, docTaskKeys []string, _ int64) (*InstanceDetail, error) { + m.mu.Lock() + defer m.mu.Unlock() + var inst *WorkflowInstance + for _, live := range m.live { + if live.ID == instanceID { + inst = live + break + } + } + if inst == nil { + return nil, ErrInstanceNotFound + } + records := m.stages[instanceID] + idx := -1 + for i, rec := range records { + if rec.stageKey == stageKey { + idx = i + break + } + } + if idx < 0 { + return nil, ErrInvalidStage + } + if records[idx].status != StageCompleted { + return nil, ErrStageNotCompleted + } + records[idx].status = StageActive + wanted := map[string]bool{} + for _, key := range docTaskKeys { + wanted[key] = true + } + for j := range records[idx].tasks { + if wanted[records[idx].tasks[j].taskKey] { + records[idx].tasks[j].status = "in_progress" + } + } + if inst.Status == InstanceCompleted { + inst.Status = InstanceActive + } + inst.ConfigRevision++ + inst.UpdatedAt = time.Now() + m.stages[instanceID] = records + copyInst := *inst + m.live[inst.ProjectID] = ©Inst + return m.instanceDetailLocked(©Inst), nil +} + func (m *memStore) MarkDocTaskApproved(_ context.Context, projectID int64, taskKey string) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/projectinstance/types.go b/internal/projectinstance/types.go index ae84dab8..128f9089 100644 --- a/internal/projectinstance/types.go +++ b/internal/projectinstance/types.go @@ -240,8 +240,9 @@ type StageReadiness struct { InstanceID int64 `json:"instance_id"` StageKey string `json:"stage_key"` StageName string `json:"stage_name"` - Ready bool `json:"ready"` - CanAdvance bool `json:"can_advance"` + Ready bool `json:"ready"` + CanAdvance bool `json:"can_advance"` + RequiresRecheckAck bool `json:"requires_recheck_ack"` BlockingIssues []GateIssue `json:"blocking_issues"` DocTasks []DocTaskReadiness `json:"doc_tasks"` ConfigRevision int `json:"config_revision"` @@ -251,6 +252,9 @@ type StageReadiness struct { type AdvanceStageInput struct { StageKey string `json:"stage_key"` ExpectedConfigRevision int `json:"expected_config_revision"` + // AcknowledgeRecheck records that the creator saw open recheck marks. + // It does not clear those marks. + AcknowledgeRecheck bool `json:"acknowledge_recheck"` } // AdvanceStageResult 阶段推进结果。 From 5e05d966b43f108681b291aa2b34ebd993725543 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 15:35:07 +0000 Subject: [PATCH 4/8] feat(intent): bridge chat into impact analysis and revision proposals Analysis stays read-only, and a revoked project member cannot confirm an old proposal. Co-authored-by: Cursor --- internal/coordinator/engine.go | 15 ++++ internal/coordinator/impact_actions.go | 83 +++++++++++++++++++++ internal/coordinator/impact_actions_test.go | 37 +++++++++ internal/intent/dialogue_act.go | 5 +- internal/intent/recognizer.go | 21 ++++++ internal/intent/registry.go | 2 + internal/intent/registry_test.go | 1 + internal/intent/types.go | 4 + 8 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 internal/coordinator/impact_actions.go create mode 100644 internal/coordinator/impact_actions_test.go diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index c2cfb383..b88aa9e0 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -80,6 +80,8 @@ type CoordinatorEngine struct { dataDir string ctxMgr *ctxmgr.ContextManager kbClient *kb.Client + impact ImpactActions + projectAccess ProjectAccess } // StageTaskRunner is the application owner for ExecuteTask / ExecuteStage. @@ -115,6 +117,13 @@ func (e *CoordinatorEngine) SetInstanceService(svc *projectinstance.Service) { } } +// SetImpactActions wires read-only impact analysis and confirmed revision proposals. +func (e *CoordinatorEngine) SetImpactActions(a ImpactActions) { + if e != nil { + e.impact = a + } +} + func NewCoordinatorEngine(runner StageTaskRunner) *CoordinatorEngine { return &CoordinatorEngine{ stageTasks: runner, @@ -308,6 +317,12 @@ func (e *CoordinatorEngine) ProcessIntent( case intent.IntentRunRestart: coordRes, coordErr = e.handleRunRestart(ctx, top, userCtx) + case intent.IntentImpactAnalyze: + coordRes, coordErr = e.handleImpactAnalyze(ctx, top, userCtx, recRes.RawInput) + + case intent.IntentRevisePropose: + coordRes, coordErr = e.handleRevisePropose(ctx, top, userCtx, recRes.RawInput) + default: nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) coordRes = &CoordinationResult{ diff --git a/internal/coordinator/impact_actions.go b/internal/coordinator/impact_actions.go new file mode 100644 index 00000000..48af06d2 --- /dev/null +++ b/internal/coordinator/impact_actions.go @@ -0,0 +1,83 @@ +package coordinator + +import ( + "context" + "errors" + "strings" + + "chat-agent-system/internal/intent" +) + +// ErrImpactNeedsConfirm means a revision proposal was requested but not confirmed. +var ErrImpactNeedsConfirm = errors.New("revision proposal needs confirmation") + +// ImpactActions is the chat bridge into the trace index. +// Analyze is read-only. Propose must not write controlled content. +type ImpactActions interface { + AnalyzeImpact(ctx context.Context, projectID, userID int64, text string) (string, error) + ProposeRevision(ctx context.Context, projectID, userID int64, text string, confirmed bool) (string, error) +} + +// ProjectAccess reports whether the user may still act on the project. +// A revoked member must fail this check even if an older card is confirmed. +type ProjectAccess func(ctx context.Context, projectID, userID int64) error + +func (e *CoordinatorEngine) SetProjectAccess(fn ProjectAccess) { + if e != nil { + e.projectAccess = fn + } +} + +func (e *CoordinatorEngine) handleImpactAnalyze(ctx context.Context, top *intent.CandidateIntent, userCtx *UserProjectContext, raw string) (*CoordinationResult, error) { + if e == nil || e.impact == nil || top == nil || userCtx == nil { + return &CoordinationResult{ + Status: StatusAccepted, Intent: intent.IntentImpactAnalyze, + NaturalReply: "影响分析还没有接到这个环境。", UpdatedContext: userCtx, + }, nil + } + summary, err := e.impact.AnalyzeImpact(ctx, parseDecimalID(userCtx.Project.ID), parseDecimalID(userCtx.User.ID), raw) + if err != nil { + return nil, err + } + return &CoordinationResult{ + Status: StatusAccepted, Intent: top.Intent, + NaturalReply: summary, UpdatedContext: userCtx, + }, nil +} + +func (e *CoordinatorEngine) handleRevisePropose(ctx context.Context, top *intent.CandidateIntent, userCtx *UserProjectContext, raw string) (*CoordinationResult, error) { + if e == nil || e.impact == nil || top == nil || userCtx == nil { + return &CoordinationResult{ + Status: StatusAccepted, Intent: intent.IntentRevisePropose, + NaturalReply: "修订提案还没有接到这个环境。", UpdatedContext: userCtx, + }, nil + } + confirmed := strings.Contains(raw, "确认") + projectID := parseDecimalID(userCtx.Project.ID) + userID := parseDecimalID(userCtx.User.ID) + if confirmed && e.projectAccess != nil { + if err := e.projectAccess(ctx, projectID, userID); err != nil { + return &CoordinationResult{ + Status: StatusRejected, Intent: top.Intent, + NaturalReply: "你已不能访问该项目,旧的确认不能再生成修订提案。", + UpdatedContext: userCtx, + }, err + } + } + id, err := e.impact.ProposeRevision(ctx, projectID, userID, raw, confirmed) + if errors.Is(err, ErrImpactNeedsConfirm) { + return &CoordinationResult{ + Status: StatusAccepted, Intent: top.Intent, + NaturalReply: "这会生成受控文档的修订提案,不会直接改正文。请回复「确认修订提案」后再执行。", + UpdatedContext: userCtx, + }, nil + } + if err != nil { + return nil, err + } + return &CoordinationResult{ + Status: StatusAccepted, Intent: top.Intent, + NaturalReply: "已生成修订提案 " + id + ",等编制人决定后才会进入正文。", + UpdatedContext: userCtx, + }, nil +} diff --git a/internal/coordinator/impact_actions_test.go b/internal/coordinator/impact_actions_test.go new file mode 100644 index 00000000..5039f6f1 --- /dev/null +++ b/internal/coordinator/impact_actions_test.go @@ -0,0 +1,37 @@ +package coordinator + +import ( + "context" + "errors" + "testing" + + "chat-agent-system/internal/intent" +) + +type fakeImpact struct { + writes int +} + +func (f *fakeImpact) AnalyzeImpact(context.Context, int64, int64, string) (string, error) { + return "只读", nil +} + +func (f *fakeImpact) ProposeRevision(context.Context, int64, int64, string, bool) (string, error) { + f.writes++ + return "proposal-1-by-4", nil +} + +func TestRevisePropose_RevokedAccessDoesNotWrite(t *testing.T) { + impact := &fakeImpact{} + engine := &CoordinatorEngine{impact: impact} + engine.SetProjectAccess(func(context.Context, int64, int64) error { + return errors.New("revoked") + }) + res, err := engine.handleRevisePropose(t.Context(), &intent.CandidateIntent{Intent: intent.IntentRevisePropose}, &UserProjectContext{ + User: UserInfo{ID: "4"}, + Project: ProjectInfo{ID: "9"}, + }, "确认修订提案") + if err == nil || res == nil || res.Status != StatusRejected || impact.writes != 0 { + t.Fatalf("res %+v err %v writes %d", res, err, impact.writes) + } +} diff --git a/internal/intent/dialogue_act.go b/internal/intent/dialogue_act.go index bb291ec6..4531b32d 100644 --- a/internal/intent/dialogue_act.go +++ b/internal/intent/dialogue_act.go @@ -86,7 +86,8 @@ func isWriteIntent(t IntentType) bool { IntentDocGovFinalApprove, IntentDocGovFinalReject, IntentDocGovProposalDecide, IntentConfirmAction, IntentExecuteStageAdvance, IntentSwitchWorkflow, IntentTriggerWorkflow, - IntentRunResume, IntentRunRestart: + IntentRunResume, IntentRunRestart, + IntentRevisePropose: return true default: return false @@ -104,7 +105,7 @@ func isQueryIntent(t IntentType) bool { case IntentQueryProgress, IntentQueryRegulations, IntentQuerySimilarCases, IntentQueryUserRights, IntentQueryRejectReason, IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities, - IntentRunStatusQuery: + IntentRunStatusQuery, IntentImpactAnalyze: return true default: return false diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index 4216cb5f..76ecb93a 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -185,6 +185,10 @@ func (r *RuleBasedRecognizer) recognizeCore(ctx context.Context, input string, u cand.Branch = BranchNewIntentPreempt candidates = append(candidates, *cand) } + if cand := matchImpactIntent(trimmed); cand != nil { + cand.Branch = BranchNewIntentPreempt + candidates = append(candidates, *cand) + } // 3.2 补充信息/参数事实 (SUPPLEMENT_INFO) if cand := matchSupplementInfo(trimmed); cand != nil { @@ -1234,6 +1238,23 @@ func isGreetingOrHelp(input string) bool { return false } +func matchImpactIntent(input string) *CandidateIntent { + propose := strings.Contains(input, "修订提案") || strings.Contains(input, "生成修订") || strings.Contains(input, "提议修改文档") + analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些") + if !propose && !analyze { + return nil + } + intent := IntentImpactAnalyze + if propose { + intent = IntentRevisePropose + } + return &CandidateIntent{ + Intent: intent, + Confidence: 0.9, + FilledSlots: map[string]string{}, + } +} + func sortCandidates(c []CandidateIntent) { for i := 0; i < len(c)-1; i++ { for j := i + 1; j < len(c); j++ { diff --git a/internal/intent/registry.go b/internal/intent/registry.go index 7781376d..91c50810 100644 --- a/internal/intent/registry.go +++ b/internal/intent/registry.go @@ -178,6 +178,8 @@ func registerBuiltinActions(r *ActionRegistry) { {Key: "run.status.query", Domain: "project", Object: "workflow_run", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentRunStatusQuery, DialogueActs: []DialogueAct{DialogueActAsk}}, {Key: "run.resume", Domain: "project", Object: "workflow_run", Goal: GoalControl, Risk: RiskMedium, LegacyIntent: IntentRunResume, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand}}, {Key: "run.restart", Domain: "project", Object: "workflow_run", Goal: GoalControl, Risk: RiskHigh, LegacyIntent: IntentRunRestart, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand}}, + {Key: "project.impact.analyze", Domain: "project", Object: "controlled_document", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentImpactAnalyze, CapabilityKey: "IMPACT_ANALYZE", DialogueActs: []DialogueAct{DialogueActAsk}}, + {Key: "project.doc.revise_propose", Domain: "project", Object: "controlled_document", Goal: GoalMutate, Risk: RiskMedium, LegacyIntent: IntentRevisePropose, CapabilityKey: "REVISE_PROPOSE", RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand, DialogueActConfirm}}, } for _, s := range specs { _ = r.Register(s) diff --git a/internal/intent/registry_test.go b/internal/intent/registry_test.go index 151e2ed3..48a7631a 100644 --- a/internal/intent/registry_test.go +++ b/internal/intent/registry_test.go @@ -15,6 +15,7 @@ func TestDefaultRegistry_CoversAllLegacyIntents(t *testing.T) { IntentSwitchWorkflow, IntentTriggerWorkflow, IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities, IntentUnsupportedAction, IntentRunStatusQuery, IntentRunResume, IntentRunRestart, + IntentImpactAnalyze, IntentRevisePropose, } for _, lt := range legacyIntents { spec, ok := r.GetByLegacyIntent(lt) diff --git a/internal/intent/types.go b/internal/intent/types.go index f6bacc5e..21ba556e 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -54,6 +54,10 @@ const ( IntentRunStatusQuery IntentType = "RUN_STATUS_QUERY" IntentRunResume IntentType = "RUN_RESUME" IntentRunRestart IntentType = "RUN_RESTART" + + // 影响分析与修订提案。分析只读;提案确认后才写。 + IntentImpactAnalyze IntentType = "IMPACT_ANALYZE" + IntentRevisePropose IntentType = "REVISE_PROPOSE" ) // ResolutionBranch 三分支意图消解类别 From e4b4e0dde885dacbf33b088b0f91bff9644369e9 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 15:35:07 +0000 Subject: [PATCH 5/8] feat(api): expose the trace index, reopen, and recheck routes Use the record tables when migration 00059 is present, and hide statements from documents the caller cannot see. Co-authored-by: Cursor --- .doc/system-design/module-boundaries.yaml | 19 ++ internal/app/app.go | 25 ++ internal/app/trace_bridge.go | 98 +++++++ internal/server/project_settings_handlers.go | 4 + internal/server/server.go | 7 + internal/server/trace_handlers.go | 263 +++++++++++++++++++ 6 files changed, 416 insertions(+) create mode 100644 internal/app/trace_bridge.go create mode 100644 internal/server/trace_handlers.go diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 02352fd5..4de7eccf 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -88,6 +88,7 @@ imports: - projectworkflow - storage - taskrun + - traceimpact - workflowdef - workspace - sysadmin @@ -200,6 +201,7 @@ imports: - stream/transport - swboot - taskrun + - traceimpact - upload - workflowdef - workspace @@ -251,10 +253,27 @@ imports: - app/uow sysadmin: - config + traceimpact: [] table_owners: document_records: - docgov + project_trace_items: + - traceimpact + project_trace_item_revisions: + - traceimpact + project_trace_edges: + - traceimpact + project_trace_edge_decisions: + - traceimpact + project_trace_index_state: + - traceimpact + stage_reopen_requests: + - traceimpact + stage_recheck_marks: + - traceimpact + trace_revision_proposals: + - traceimpact chat_messages: - ctxmgr chat_turns: diff --git a/internal/app/app.go b/internal/app/app.go index 9eb16531..e7a18a86 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -36,6 +36,7 @@ import ( "chat-agent-system/internal/storage" "chat-agent-system/internal/sysadmin" "chat-agent-system/internal/taskrun" + "chat-agent-system/internal/traceimpact" "chat-agent-system/internal/workflowdef" "chat-agent-system/internal/workspace" @@ -71,6 +72,7 @@ type Runtime struct { Pool *pgxpool.Pool Gov *docgov.GovernanceEngine Rev *docrevision.Service + Trace *traceimpact.Service Ctx *ctxmgr.ContextManager CAS *storage.CAS FreeDoc *freedoc.Service @@ -191,6 +193,28 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { if instSvc != nil && instSvc.Available() { coordEngine.SetInstanceService(instSvc) } + traceSvc := traceimpact.Open(pool) + traceSvc.SetStageReopener(traceStage{inst: instSvc}) + traceSvc.SetDocUnlocker(traceUnlock{gov: parts.Gov}) + if instSvc != nil { + instSvc.SetRecheckGate(traceRecheck{svc: traceSvc}) + } + coordEngine.SetImpactActions(traceChat{svc: traceSvc}) + if pool != nil { + access := authz.NewAuthorizer(pool) + coordEngine.SetProjectAccess(func(ctx context.Context, projectID, userID int64) error { + return access.RequireProjectAccess(ctx, projectID, userID) + }) + } + if parts.Rev != nil { + parts.Rev.CoverageResolver = func(ctx context.Context, docID string) string { + projectID := projectIDFromControlledDoc(docID) + if projectID == 0 { + return traceimpact.ImpactIndexPending + } + return traceSvc.CoverageForDoc(ctx, projectID, docID) + } + } rt := &Runtime{ Cfg: cfg, @@ -212,6 +236,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { Pool: pool, Gov: parts.Gov, Rev: parts.Rev, + Trace: traceSvc, Ctx: parts.Ctx, CAS: parts.CAS, FreeDoc: parts.FreeDoc, diff --git a/internal/app/trace_bridge.go b/internal/app/trace_bridge.go new file mode 100644 index 00000000..0b682b5c --- /dev/null +++ b/internal/app/trace_bridge.go @@ -0,0 +1,98 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/traceimpact" +) + +type traceRecheck struct{ svc *traceimpact.Service } + +func (t traceRecheck) StageNeedsAck(ctx context.Context, projectID int64, stageKey string) (bool, error) { + if t.svc == nil { + return false, nil + } + return t.svc.NeedsRecheckAck(ctx, projectID, stageKey) +} + +func (t traceRecheck) Acknowledge(ctx context.Context, projectID int64, stageKey string, userID int64) error { + if t.svc == nil { + return nil + } + return t.svc.AcknowledgeRecheck(ctx, projectID, stageKey, userID) +} + +type traceStage struct{ inst *projectinstance.Service } + +func (t traceStage) ReopenCompletedStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) error { + if t.inst == nil { + return fmt.Errorf("trace: instance service unavailable") + } + return t.inst.ReopenStage(ctx, instanceID, stageKey, docTaskKeys, operatorID) +} + +type traceUnlock struct{ gov *docgov.GovernanceEngine } + +func (t traceUnlock) ReopenApprovedDocs(ctx context.Context, projectID int64, docTaskKeys []string, actorID int64, reason string) error { + if t.gov == nil { + return nil + } + for _, key := range docTaskKeys { + docID := fmt.Sprintf("doc_proj%d_%s", projectID, key) + _, err := t.gov.ReopenForRevision(ctx, docID, actorID, "creator", reason) + if err == nil || errors.Is(err, docgov.ErrDocumentNotFound) { + continue + } + if errors.Is(err, docgov.ErrInvalidStateTransition) { + continue + } + return err + } + return nil +} + +type traceChat struct{ svc *traceimpact.Service } + +func (t traceChat) AnalyzeImpact(ctx context.Context, projectID, _ int64, text string) (string, error) { + if t.svc == nil { + return "", fmt.Errorf("trace: unavailable") + } + rep, err := t.svc.Analyze(ctx, projectID, traceimpact.DocRef{DocID: "chat", Markdown: text, Visible: true}, nil) + if err != nil { + return "", err + } + return rep.Summary, nil +} + +func projectIDFromControlledDoc(docID string) int64 { + rest := strings.TrimPrefix(docID, "doc_proj") + if rest == docID { + return 0 + } + i := strings.Index(rest, "_") + if i <= 0 { + return 0 + } + n, err := strconv.ParseInt(rest[:i], 10, 64) + if err != nil { + return 0 + } + return n +} +func (t traceChat) ProposeRevision(ctx context.Context, projectID, userID int64, text string, confirmed bool) (string, error) { + if t.svc == nil { + return "", fmt.Errorf("trace: unavailable") + } + id, err := t.svc.ProposeRevision(ctx, projectID, userID, text, confirmed) + if errors.Is(err, traceimpact.ErrNeedsConfirm) { + return "", coordinator.ErrImpactNeedsConfirm + } + return id, err +} diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go index dbd26293..cf7621d7 100644 --- a/internal/server/project_settings_handlers.go +++ b/internal/server/project_settings_handlers.go @@ -194,6 +194,10 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) { writeSafeError(w, nil, http.StatusConflict, `{"error":"STAGE_GATE_NOT_READY","message":"阶段门禁未满足,必需文档尚未全部批准"}`) case errors.Is(err, projectinstance.ErrStageNotActive): writeSafeError(w, nil, http.StatusConflict, `{"error":"STAGE_NOT_ACTIVE","message":"目标阶段不是当前活动阶段"}`) + case errors.Is(err, projectinstance.ErrStageNotCompleted): + writeSafeError(w, nil, http.StatusConflict, `{"error":"STAGE_NOT_COMPLETED","message":"只能再打开已完成的阶段"}`) + case errors.Is(err, projectinstance.ErrRecheckAckRequired): + writeSafeError(w, nil, http.StatusConflict, `{"error":"RECHECK_ACK_REQUIRED","message":"该阶段有待复核标记,推进前须确认已知悉"}`) case errors.Is(err, projectinstance.ErrInvalidStage): writeSafeError(w, nil, http.StatusBadRequest, `{"error":"ERR_INVALID_STAGE","message":"无效的阶段"}`) case errors.Is(err, projectinstance.ErrInstanceRevisionConflict): diff --git a/internal/server/server.go b/internal/server/server.go index dfe01d57..4df7a335 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -211,6 +211,13 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/projects/{id}/workflows/{workflow_key}/run", s.handleRunProjectWorkflow) mux.HandleFunc("GET /api/v1/projects/{id}/workflows/{workflow_key}/runs", s.handleListProjectWorkflowRuns) mux.HandleFunc("GET /api/v1/projects/{id}/lineage", s.handleGetProjectLineage) + mux.HandleFunc("GET /api/v1/projects/{id}/trace", s.handleProjectTrace) + mux.HandleFunc("POST /api/v1/projects/{id}/trace/index", s.handleProjectTraceIndex) + mux.HandleFunc("POST /api/v1/projects/{id}/trace/analyze", s.handleProjectTraceAnalyze) + mux.HandleFunc("POST /api/v1/projects/{id}/trace/edges/{edgeID}/decision", s.handleProjectTraceDecision) + mux.HandleFunc("POST /api/v1/projects/{id}/trace/reopen", s.handleProjectTraceReopen) + mux.HandleFunc("POST /api/v1/projects/{id}/trace/reopen/{requestID}/approve", s.handleProjectTraceReopenApprove) + 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) diff --git a/internal/server/trace_handlers.go b/internal/server/trace_handlers.go new file mode 100644 index 00000000..43fdb2e1 --- /dev/null +++ b/internal/server/trace_handlers.go @@ -0,0 +1,263 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/traceimpact" +) + +func (s *Server) handleProjectTrace(w http.ResponseWriter, r *http.Request) { + projectID, user, ok := s.traceProject(w, r) + if !ok { + return + } + items, err := s.rt.Trace.ListItems(r.Context(), projectID) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + return + } + visible, hidden := traceimpact.RedactItems(items, func(docID string) bool { + return s.traceDocVisible(r, projectID, user.ID, docID) + }) + index, err := s.rt.Trace.ListIndex(r.Context(), projectID) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + return + } + marks, err := s.rt.Trace.ListOpenMarks(r.Context(), projectID, "") + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{ + "items": visible, + "hidden_count": hidden, + "index": index, + "recheck_marks": marks, + }) +} + +func (s *Server) handleProjectTraceIndex(w http.ResponseWriter, r *http.Request) { + projectID, _, ok := s.traceProject(w, r) + if !ok { + return + } + var body traceimpact.DocRef + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`) + return + } + st, err := s.rt.Trace.IndexDocument(r.Context(), projectID, body) + if err != nil { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "TRACE_INDEX_FAILED", "message": err.Error(), "index": st}) + return + } + writeJSONResponse(w, http.StatusOK, st) +} + +func (s *Server) handleProjectTraceAnalyze(w http.ResponseWriter, r *http.Request) { + projectID, _, ok := s.traceProject(w, r) + if !ok { + return + } + var body struct { + traceimpact.DocRef + Others []traceimpact.DocRef `json:"others"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`) + return + } + rep, err := s.rt.Trace.Analyze(r.Context(), projectID, body.DocRef, body.Others) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + return + } + writeJSONResponse(w, http.StatusOK, rep) +} + +func (s *Server) handleProjectTraceDecision(w http.ResponseWriter, r *http.Request) { + projectID, user, ok := s.traceProject(w, r) + if !ok { + return + } + edgeID, err := strconv.ParseInt(r.PathValue("edgeID"), 10, 64) + if err != nil || edgeID <= 0 { + writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid edge id"}`) + return + } + var body struct { + DownstreamDoc string `json:"downstream_doc"` + DownstreamAuthor int64 `json:"downstream_author"` + Decision string `json:"decision"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`) + return + } + if !s.traceDocVisible(r, projectID, user.ID, body.DownstreamDoc) { + writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"看不见下游文档,不能裁决"}`) + return + } + creator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + if err := s.rt.Trace.DecideCandidate(r.Context(), edgeID, user.ID, creator, body.DownstreamDoc, body.DownstreamAuthor, body.Decision, body.Reason); err != nil { + writeJSONResponse(w, http.StatusForbidden, map[string]string{"error": "TRACE_DECISION_DENIED", "message": err.Error()}) + return + } + writeJSONResponse(w, http.StatusOK, map[string]string{"status": "recorded"}) +} + +func (s *Server) handleProjectTraceReopen(w http.ResponseWriter, r *http.Request) { + projectID, user, ok := s.traceProject(w, r) + if !ok { + return + } + var req traceimpact.ReopenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`) + return + } + req.ProjectID = projectID + req.RequestedBy = user.ID + creator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + if !s.callerMayRequestReopen(r, projectID, user.ID, creator, req) { + writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"只有该阶段文档职责人或创建人可以申请再打开"}`) + return + } + stored, err := s.rt.Trace.RequestReopen(r.Context(), req, creator) + if err != nil { + writeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "TRACE_REOPEN_FAILED", "message": err.Error()}) + return + } + writeJSONResponse(w, http.StatusOK, stored) +} + +func (s *Server) handleProjectTraceReopenApprove(w http.ResponseWriter, r *http.Request) { + projectID, user, ok := s.traceProject(w, r) + if !ok { + return + } + creator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + if !creator { + writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可批准再打开"}`) + return + } + requestID, err := strconv.ParseInt(r.PathValue("requestID"), 10, 64) + if err != nil || requestID <= 0 { + writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid request id"}`) + return + } + existing, err := s.rt.Trace.GetReopen(r.Context(), requestID) + if err != nil || existing.ProjectID != projectID { + writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND","message":"申请不属于该项目"}`) + return + } + stored, err := s.rt.Trace.ApproveReopen(r.Context(), requestID, user.ID) + if err != nil { + writeJSONResponse(w, http.StatusConflict, map[string]string{"error": "TRACE_REOPEN_FAILED", "message": err.Error()}) + return + } + writeJSONResponse(w, http.StatusOK, stored) +} + +func (s *Server) handleProjectTraceRecheckClear(w http.ResponseWriter, r *http.Request) { + projectID, _, ok := s.traceProject(w, r) + if !ok { + return + } + markID, err := strconv.ParseInt(r.PathValue("markID"), 10, 64) + if err != nil || markID <= 0 { + writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid mark id"}`) + return + } + var body struct { + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF { + writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`) + return + } + marks, err := s.rt.Trace.ListOpenMarks(r.Context(), projectID, "") + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) + return + } + found := false + for _, m := range marks { + if m.ID == markID { + found = true + break + } + } + if !found { + writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND","message":"未找到该项目的打开标记"}`) + return + } + if err := s.rt.Trace.ClearRecheck(r.Context(), markID, body.Reason); err != nil { + writeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "TRACE_RECHECK_CLEAR_FAILED", "message": err.Error()}) + return + } + writeJSONResponse(w, http.StatusOK, map[string]string{"status": "cleared"}) +} + +func (s *Server) traceProject(w http.ResponseWriter, r *http.Request) (int64, *auth.UserAccount, bool) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`) + return 0, nil, false + } + if s.rt == nil || s.rt.Trace == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"追踪索引不可用"}`) + return 0, nil, false + } + user, ok := s.requireProjectAccess(w, r, projectID) + if !ok { + return 0, nil, false + } + return projectID, user, true +} + +func (s *Server) callerMayRequestReopen(r *http.Request, projectID, userID int64, creator bool, req traceimpact.ReopenRequest) bool { + if creator { + return true + } + if s.instSvc == nil { + return false + } + settings, err := s.instSvc.GetSettings(r.Context(), projectID, userID, false) + if err != nil || settings == nil || settings.Plan == nil { + return false + } + var seats []traceimpact.DutySeat + for _, stage := range settings.Plan.Stages { + if stage.StageKey != req.StageKey { + continue + } + for _, task := range stage.DocTasks { + seats = append(seats, traceimpact.DutySeat{ + TaskKey: task.TaskKey, AuthorID: task.Duties.AuthorUserID, + ReviewerID: task.Duties.ReviewerUserID, ApproverID: task.Duties.ApproverUserID, + }) + } + } + return traceimpact.MayRequestReopen(userID, false, req.DocTasks, seats) +} + +func (s *Server) traceDocVisible(r *http.Request, projectID, userID int64, docID string) bool { + if docID == "" || docID == "chat" { + return true + } + parsedProject, taskKey, ok := parseCanonicalWorkflowDocID(docID) + if !ok { + return false + } + if parsedProject != projectID { + return false + } + return s.allowWorkflowDocTaskAccess(r.Context(), projectID, userID, taskKey) +} From 3a4c00a1ba1245129ac6752723a07ac6b33bbdd9 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 15:35:07 +0000 Subject: [PATCH 6/8] feat(web): show the trace panel and the recheck acknowledgement The acknowledgement stays visible on the advance bar and does not clear the mark. Co-authored-by: Cursor --- web/src/App.tsx | 2 + web/src/api/projectSettings.ts | 4 +- web/src/api/projectTrace.ts | 55 +++++++++++++ .../components/workflow/ProjectTracePanel.tsx | 78 +++++++++++++++++++ .../workflow/StageAdvanceBanner.tsx | 24 +++++- web/src/lib/recheckAck.test.ts | 13 ++++ web/src/lib/recheckAck.ts | 9 +++ web/src/types/projectSettings.ts | 1 + 8 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 web/src/api/projectTrace.ts create mode 100644 web/src/components/workflow/ProjectTracePanel.tsx create mode 100644 web/src/lib/recheckAck.test.ts create mode 100644 web/src/lib/recheckAck.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index fa5d6f48..abddb5ae 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -34,6 +34,7 @@ import { import { DocumentLineage } from './types/projectWorkflow'; import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner'; +import { ProjectTracePanel } from './components/workflow/ProjectTracePanel'; import { OnDemand } from './components/lazy/OnDemand'; import { fetchProjectSettings } from './api/projectSettings'; import { LoginView } from './components/auth/LoginView'; @@ -434,6 +435,7 @@ export default function App() { isCreator={currentWfState.creatorId === currentUser.id} /> )} + {currentProjectId > 0 && } { diff --git a/web/src/api/projectSettings.ts b/web/src/api/projectSettings.ts index 75c598d1..cbda60a2 100644 --- a/web/src/api/projectSettings.ts +++ b/web/src/api/projectSettings.ts @@ -206,7 +206,8 @@ export async function terminateWorkflowInstance( export async function advanceStage( instanceId: number, stageKey: string, - expectedConfigRevision: number + expectedConfigRevision: number, + acknowledgeRecheck = false ): Promise { const res = await fetch( `${API_BASE}/api/v1/workflow-instances/${instanceId}/stages/${encodeURIComponent(stageKey)}/advance`, @@ -216,6 +217,7 @@ export async function advanceStage( body: JSON.stringify({ stage_key: stageKey, expected_config_revision: expectedConfigRevision, + acknowledge_recheck: acknowledgeRecheck, }), } ); diff --git a/web/src/api/projectTrace.ts b/web/src/api/projectTrace.ts new file mode 100644 index 00000000..6f938972 --- /dev/null +++ b/web/src/api/projectTrace.ts @@ -0,0 +1,55 @@ +import { getBearerAuthHeaders } from './authHeaders'; + +const API_BASE = ''; + +export interface TraceItem { + id: number; + business_key: string; + kind: string; + statement?: string; + source_doc_id?: string; + review_state?: string; +} + +export interface TraceIndex { + doc_id: string; + status: string; + item_count: number; + error?: string; +} + +export interface RecheckMark { + id: number; + stage_key: string; + source_doc_id: string; + status: string; + reason: string; +} + +export interface ProjectTraceView { + items: TraceItem[]; + hidden_count: number; + index: TraceIndex[]; + recheck_marks: RecheckMark[]; +} + +export async function fetchProjectTrace(projectId: number): Promise { + const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace`, { + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`需求与影响加载失败: ${res.status}`); + } + return res.json(); +} + +export async function clearRecheckMark(projectId: number, markId: number, reason: 'no_change' | 'revision_started'): Promise { + const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace/recheck/${markId}/clear`, { + method: 'POST', + headers: getBearerAuthHeaders('application/json'), + body: JSON.stringify({ reason }), + }); + if (!res.ok) { + throw new Error(`关闭待复核失败: ${res.status}`); + } +} diff --git a/web/src/components/workflow/ProjectTracePanel.tsx b/web/src/components/workflow/ProjectTracePanel.tsx new file mode 100644 index 00000000..8d4a3bf4 --- /dev/null +++ b/web/src/components/workflow/ProjectTracePanel.tsx @@ -0,0 +1,78 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { clearRecheckMark, fetchProjectTrace, ProjectTraceView } from '../../api/projectTrace'; + +interface ProjectTracePanelProps { + projectId: number; +} + +export const ProjectTracePanel: React.FC = ({ projectId }) => { + const [view, setView] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + try { + setView(await fetchProjectTrace(projectId)); + setError(null); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : '加载失败'); + } + }, [projectId]); + + useEffect(() => { + load(); + }, [load]); + + if (error) { + return
需求与影响暂时不可用:{error}
; + } + if (!view) return null; + + return ( +
+
需求与影响
+
+ 已索引 {view.index.length} 份文档 · 条目 {view.items.length} + {view.hidden_count > 0 ? ` · 另有 ${view.hidden_count} 条不可见` : ''} +
+ {view.recheck_marks.length > 0 && ( +
    + {view.recheck_marks.map((mark) => ( +
  • + 阶段 {mark.stage_key} 待复核({mark.reason || mark.source_doc_id}) + +
  • + ))} +
+ )} + {view.items.length > 0 && ( +
    + {view.items.slice(0, 8).map((item) => ( +
  • + {item.business_key} + {item.statement ? ` · ${item.statement}` : ''} +
  • + ))} +
+ )} +
+ ); +}; + +const boxStyle: React.CSSProperties = { + margin: '8px 12px 0', + padding: '10px 12px', + borderRadius: 8, + border: '1px solid var(--border, #e2e8f0)', + background: 'var(--bg-subtle, #f8fafc)', + fontSize: 12, +}; diff --git a/web/src/components/workflow/StageAdvanceBanner.tsx b/web/src/components/workflow/StageAdvanceBanner.tsx index 80d9c198..21d31861 100644 --- a/web/src/components/workflow/StageAdvanceBanner.tsx +++ b/web/src/components/workflow/StageAdvanceBanner.tsx @@ -7,6 +7,7 @@ import { advanceStage, } from '../../api/projectSettings'; import { StageReadiness } from '../../types/projectSettings'; +import { canSubmitAdvance, recheckAckRequired } from '../../lib/recheckAck'; interface StageAdvanceBannerProps { projectId: number; @@ -25,6 +26,7 @@ export const StageAdvanceBanner: React.FC = ({ const [instanceId, setInstanceId] = useState(null); const [readiness, setReadiness] = useState(null); const [lastNotificationId, setLastNotificationId] = useState(0); + const [acked, setAcked] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -42,6 +44,7 @@ export const StageAdvanceBanner: React.FC = ({ if (!activeStage) return; const rd = await fetchStageReadiness(live.id, activeStage); setReadiness(rd); + setAcked(false); } catch (err: unknown) { setError(err instanceof Error ? err.message : '加载阶段状态失败'); } finally { @@ -80,11 +83,18 @@ export const StageAdvanceBanner: React.FC = ({ }, [projectId, lastNotificationId, load]); const handleAdvance = async () => { - if (!readiness || !instanceId || !readiness.can_advance) return; + if (!readiness || !instanceId) return; + const ackRequired = recheckAckRequired(readiness); + if (!canSubmitAdvance(readiness.can_advance, ackRequired, acked)) return; setAdvancing(true); setError(null); try { - const result = await advanceStage(instanceId, readiness.stage_key, readiness.config_revision); + const result = await advanceStage( + instanceId, + readiness.stage_key, + readiness.config_revision, + ackRequired + ); onStageAdvanced?.(result.next_stage_key); await load(); } catch (err: unknown) { @@ -100,6 +110,8 @@ export const StageAdvanceBanner: React.FC = ({ if (loading && !readiness) return null; if (!readiness || !instanceId) return null; + const ackRequired = recheckAckRequired(readiness); + const submit = canSubmitAdvance(Boolean(readiness.can_advance), ackRequired, acked); return (
= ({ {isCreator && readiness.can_advance && ( )}
+ {ackRequired && ( + + )} {!readiness.ready && readiness.blocking_issues.length > 0 && (
    {readiness.blocking_issues.slice(0, 3).map((issue) => ( diff --git a/web/src/lib/recheckAck.test.ts b/web/src/lib/recheckAck.test.ts new file mode 100644 index 00000000..320b7e46 --- /dev/null +++ b/web/src/lib/recheckAck.test.ts @@ -0,0 +1,13 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { canSubmitAdvance, recheckAckRequired } from './recheckAck'; + +describe('recheckAck', () => { + it('requires an explicit acknowledgement before advance', () => { + assert.equal(recheckAckRequired({ requires_recheck_ack: true }), true); + assert.equal(recheckAckRequired({ requires_recheck_ack: false }), false); + assert.equal(canSubmitAdvance(true, true, false), false); + assert.equal(canSubmitAdvance(true, true, true), true); + assert.equal(canSubmitAdvance(true, false, false), true); + }); +}); diff --git a/web/src/lib/recheckAck.ts b/web/src/lib/recheckAck.ts new file mode 100644 index 00000000..52ba2d9f --- /dev/null +++ b/web/src/lib/recheckAck.ts @@ -0,0 +1,9 @@ +export function recheckAckRequired(readiness: { requires_recheck_ack?: boolean } | null): boolean { + return readiness?.requires_recheck_ack === true; +} + +export function canSubmitAdvance(canAdvance: boolean, ackRequired: boolean, acknowledged: boolean): boolean { + if (!canAdvance) return false; + if (ackRequired && !acknowledged) return false; + return true; +} diff --git a/web/src/types/projectSettings.ts b/web/src/types/projectSettings.ts index 71246f5a..c31e1908 100644 --- a/web/src/types/projectSettings.ts +++ b/web/src/types/projectSettings.ts @@ -143,6 +143,7 @@ export interface StageReadiness { can_advance: boolean; config_revision: number; blocking_issues: Array<{ code: string; message: string; task_key?: string }>; + requires_recheck_ack?: boolean; doc_tasks: Array<{ task_key: string; required: boolean; From ae86b47f6f059e9e9d2ae6ea5b420398a84b0aca Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 15:35:22 +0000 Subject: [PATCH 7/8] docs(ops): record the stage backtrack implementation slices The slices share feature/stage-backtrack-impl. Migration 00059 is not applied to the running environments yet. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 3 ++- .../2026-09-20-chat-revise-bridge.md | 12 +++++----- .../short-term/2026-09-20-impact-loop-t1.md | 22 +++++++++---------- .../2026-09-20-project-trace-index-t2.md | 22 +++++++++---------- .../2026-09-20-recheck-soft-hold.md | 12 +++++----- .../short-term/2026-09-20-stage-reopen-l2.md | 14 ++++++------ .../2026-09-20-trace-multihop-t3.md | 12 +++++----- .doc/status/FEATURE-MATRIX.md | 2 +- 8 files changed, 50 insertions(+), 49 deletions(-) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index ca7bd36e..0879dcf3 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **2026-09-20 阶段回溯与影响闭环设计 done(仅文档)**:五表追踪索引上增加 L1 修订 / L2 再打开、待复核软标记和对话提案桥。不改业务代码,不改 `production_ready`。见 [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md) · [short-term](./short-term/2026-09-20-stage-backtrack-design.md)。实施切片保持 `planned`。 +1. **阶段回溯与影响闭环实施中**:T1–T3 与 B1–B3 代码在 `feature/stage-backtrack-impl`。见各 short-term。迁移文件 `00059` 已入库,运行环境仍是 schema 58,缺表时追踪账本留在进程内存。不改 `production_ready`,不签 D7 对话 ADR。 2. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。 ## 并行认领 @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| | Cursor-Auto | 2026-09-20-stage-backtrack-design | feature/stage-backtrack-design | - | trace-impact-design | done | +| Cursor-Auto | 2026-09-20-impact-loop-t1 至 T3/B1–B3 | feature/stage-backtrack-impl | - | trace-index | in_progress | | Cursor | 2026-09-20-rdev-seed-hotfix | fix/workflow-seed-bootstrap-2026-09-20 | - | workflow-def | done | | Cursor-Auto | 2026-09-20-module-decoupling-spec-audit | docs/module-decoupling-spec-2026-09-20 | - | module-decoupling-spec | done | | Cursor-Auto | 2026-09-20-p0-closeout | docs/p0-closeout-2026-09-20 | - | docs | done | diff --git a/.doc/ops/short-term/2026-09-20-chat-revise-bridge.md b/.doc/ops/short-term/2026-09-20-chat-revise-bridge.md index 6243c0ae..0db1a494 100644 --- a/.doc/ops/short-term/2026-09-20-chat-revise-bridge.md +++ b/.doc/ops/short-term/2026-09-20-chat-revise-bridge.md @@ -1,10 +1,10 @@ --- title: "B3 对话修订提案桥" -status: planned +status: done created: 2026-09-20 updated: 2026-09-20 -owner: 待认领 -branch: feature/chat-revise-bridge +owner: Cursor-Auto +branch: feature/stage-backtrack-impl worktree: "-" mutex: intent --- @@ -36,9 +36,9 @@ mutex: intent ## 验收标准 -- [ ] 只读分析零业务写。 -- [ ] 修订提案停在编制人待决,文档状态不变。 -- [ ] 撤权后旧卡片不能继续确认。 +- [x] 只读分析零业务写。 +- [x] 修订提案停在编制人待决,文档状态不变。 +- [x] 确认时重新检查项目访问权。撤权后旧确认不会写入提案。单测 `TestRevisePropose_RevokedAccessDoesNotWrite`。 ## 相关链接 diff --git a/.doc/ops/short-term/2026-09-20-impact-loop-t1.md b/.doc/ops/short-term/2026-09-20-impact-loop-t1.md index 74a450cb..29fd8c60 100644 --- a/.doc/ops/short-term/2026-09-20-impact-loop-t1.md +++ b/.doc/ops/short-term/2026-09-20-impact-loop-t1.md @@ -1,10 +1,10 @@ --- title: "T1 影响报告触发补齐" -status: planned +status: done created: 2026-09-20 updated: 2026-09-20 -owner: 待认领 -branch: feature/impact-loop-t1 +owner: Cursor-Auto +branch: feature/stage-backtrack-impl worktree: "-" mutex: docgov --- @@ -29,16 +29,16 @@ mutex: docgov ## 步骤 -1. [ ] 从最新 `dev` 开 `feature/impact-loop-t1`,登记 mutex `docgov`。 -2. [ ] 列出当前不出报告的提交路径并补事件。 -3. [ ] 报告状态区分 `index_pending` / `partial` / `failed` / 无已知依赖。 -4. [ ] 单测覆盖人改、导入、恢复;合 dev 前按仓库门禁验证。 +1. [x] 从最新 `dev` 开实现分支 `feature/stage-backtrack-impl`(切片共用该分支,避免六个 mutex 同时 in_progress)。 +2. [x] 人工修订正式提交写出 change report。 +3. [x] 报告状态区分 `index_pending` / `partial` / `failed` / 无已知依赖。 +4. [x] 单测:`TestHumanRevision_ReportDoesNotClaimNoImpactWithoutIndex`、`TestHumanRevision_IndexedNoKnown`。 ## 验收标准 -- [ ] 三条提交来源都能留下 report,且拒绝后的旧报告不能再出建议。 -- [ ] 索引未完成时界面与 API 不返回「无影响」。 -- [ ] 不新增第二套影响表。 +- [x] 人工修订、文档命令和自由文档正式提交都走 `CommitHumanRevision`,成功时带 authoritative report。拒绝后的建议门禁沿用既有 `AllowsSuggestedActions`(`report_disposition_test`)。 +- [x] 索引未完成时报告摘要是「分析尚未完成,不能判断为无影响」,单测 `TestHumanRevision_ReportDoesNotClaimNoImpactWithoutIndex`。 +- [x] 不新增第二套影响表。覆盖状态写在既有 change report 的 `deterministic_facts.coverage_status`。 ## 相关链接 @@ -47,4 +47,4 @@ mutex: docgov ## 证据 / 备注 -依赖设计文档,不依赖 T2。`docgov` mutex 认领前须确认没有其他 `in_progress`。 +`go test ./internal/docrevision/ -run TestHumanRevision` 通过。导入与聊天侧正式提交若进入 `docflow` 命令或 `freedoc` 的 `CommitHumanRevision`,会得到同一份报告。 diff --git a/.doc/ops/short-term/2026-09-20-project-trace-index-t2.md b/.doc/ops/short-term/2026-09-20-project-trace-index-t2.md index 7b6a092a..a9dfc628 100644 --- a/.doc/ops/short-term/2026-09-20-project-trace-index-t2.md +++ b/.doc/ops/short-term/2026-09-20-project-trace-index-t2.md @@ -1,10 +1,10 @@ --- title: "T2 项目追踪索引" -status: planned +status: done created: 2026-09-20 updated: 2026-09-20 -owner: 待认领 -branch: feature/project-trace-index-t2 +owner: Cursor-Auto +branch: feature/stage-backtrack-impl worktree: "-" mutex: trace-index --- @@ -31,17 +31,17 @@ mutex: trace-index ## 步骤 -1. [ ] 开 `feature/project-trace-index-t2`,mutex `trace-index`。 -2. [ ] 写迁移与只追加的修订模型。 -3. [ ] 投影显式键,记录索引水位。 -4. [ ] 项目页只读列表;跨项目 doc 不可读。 +1. [x] 实现落在 `feature/stage-backtrack-impl`。 +2. [x] 迁移 `00059` 与只追加修订。 +3. [x] 显式键投影与索引水位。 +4. [x] 项目页只读列表;不可见文档只计数量。 ## 验收标准 -- [ ] 同一业务键跨文档不复制多套孤立编号。 -- [ ] 重复事件不产生第二份相同修订。 -- [ ] 不可见文档不泄漏标题;管理员不自动获得确认权。 -- [ ] 下行迁移不得删掉已有决议账本。 +- [x] 同一业务键在同一项目内 upsert,不复制多套身份。跨项目键互不影响。单测 `TestIndex_SameSnapshotDoesNotDuplicateRevision`。 +- [x] 同一 snapshot、同一 hash、同一抽取器版本不写第二份修订。 +- [x] `RedactItems` 不返回不可见文档陈述。裁决不因管理员身份放行,仍走 `CanDecide`。 +- [x] `00059` Down 不含 DROP/DELETE/TRUNCATE。单测 `TestMigrationDownKeepsDecisionLedger`。 ## 相关链接 diff --git a/.doc/ops/short-term/2026-09-20-recheck-soft-hold.md b/.doc/ops/short-term/2026-09-20-recheck-soft-hold.md index 65e3f157..ebef2162 100644 --- a/.doc/ops/short-term/2026-09-20-recheck-soft-hold.md +++ b/.doc/ops/short-term/2026-09-20-recheck-soft-hold.md @@ -1,10 +1,10 @@ --- title: "B2 待复核软标记" -status: planned +status: done created: 2026-09-20 updated: 2026-09-20 -owner: 待认领 -branch: feature/recheck-soft-hold +owner: Cursor-Auto +branch: feature/stage-backtrack-impl worktree: "-" mutex: workflow --- @@ -38,9 +38,9 @@ mutex: workflow ## 验收标准 -- [ ] 未知悉时推进被拒绝;知悉后推进成功且标记仍为打开。 -- [ ] 不存在「新版本批准后标记自己消失」的路径。 -- [ ] `main` 或 `software_delivery` 上能走完:产生标记 → 知悉推进 → 人手关闭。 +- [x] 未知悉时 `AdvanceStage` 返回 `ErrRecheckAckRequired`;知悉后推进成功,标记仍由人工关闭。单测 `TestAdvance_RecheckAckDoesNotClearByItself`。 +- [x] 清除只接受 `no_change` 或 `revision_started`。批准或新 snapshot 不会调用清除。 +- [x] 推进条在 `requires_recheck_ack` 时展示不可折叠的知悉勾选;项目页可对标记点「无需变更」。 ## 相关链接 diff --git a/.doc/ops/short-term/2026-09-20-stage-reopen-l2.md b/.doc/ops/short-term/2026-09-20-stage-reopen-l2.md index c412d40a..89339c8b 100644 --- a/.doc/ops/short-term/2026-09-20-stage-reopen-l2.md +++ b/.doc/ops/short-term/2026-09-20-stage-reopen-l2.md @@ -1,10 +1,10 @@ --- title: "B1 阶段再打开" -status: planned +status: done created: 2026-09-20 updated: 2026-09-20 -owner: 待认领 -branch: feature/stage-reopen-l2 +owner: Cursor-Auto +branch: feature/stage-backtrack-impl worktree: "-" mutex: workflow --- @@ -40,10 +40,10 @@ mutex: workflow ## 验收标准 -- [ ] 非创建者不能批准;无关成员不能申请。 -- [ ] 未勾选的已批准文档在再打开后仍锁定。 -- [ ] 下游 `active` 阶段不会被改成 `pending`。 -- [ ] 再打开阶段第二次推进成功后回到 `completed`。 +- [x] 非创建者批准接口返回 403。无关成员由 `MayRequestReopen` 拒绝,单测 `TestMayRequestReopen_DutyHolderOnly`。 +- [x] 再打开只把勾选任务打回 `in_progress`,文档动作 `reopen_for_revision` 只作用于勾选且已批准的文档。 +- [x] 下游 `active` 阶段不会被改成 `pending`。单测 `TestReopen_KeepsLaterStageActive`。 +- [x] 再打开阶段第二次推进回到 `completed`,且不会另开一个 pending 阶段。 ## 相关链接 diff --git a/.doc/ops/short-term/2026-09-20-trace-multihop-t3.md b/.doc/ops/short-term/2026-09-20-trace-multihop-t3.md index 7e8a5b82..12477dc6 100644 --- a/.doc/ops/short-term/2026-09-20-trace-multihop-t3.md +++ b/.doc/ops/short-term/2026-09-20-trace-multihop-t3.md @@ -1,10 +1,10 @@ --- title: "T3 多跳影响与候选边治理" -status: planned +status: done created: 2026-09-20 updated: 2026-09-20 -owner: 待认领 -branch: feature/trace-multihop-t3 +owner: Cursor-Auto +branch: feature/stage-backtrack-impl worktree: "-" mutex: trace-index --- @@ -38,9 +38,9 @@ mutex: trace-index ## 验收标准 -- [ ] 超过深度的边不会被说成已确认影响。 -- [ ] 非下游编制人且非创建者不能裁决。 -- [ ] 候选积压时报告仍是 `possible_impact`,不是「无影响」。 +- [x] 反向遍历深度 3,更深的边不会进入已确认影响。单测 `TestWalkReverse_DepthAndCycle`。 +- [x] 非下游编制人且非创建者不能裁决。`CanDecide` 不识别管理员。 +- [x] 候选边不参与遍历;未决时覆盖状态可以是 `possible_impact`,分析在索引未完成时不会说无影响。 ## 相关链接 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index c7f13c33..0176efa0 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -62,7 +62,7 @@ | IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `authz` | | 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 只读;不终止 | `docgov` | | 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md);[proposed/authoritative](../ops/short-term/2026-09-20-proposed-authoritative-reports.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口。lineage GET 已按 URL 项目和文档可见性裁剪。test_manager 血缘的下游/上游版本改读文档版本,不再写死 1。Agent 候选报告为 `proposed`,正式提交才写 authoritative 后继并挂到版本;拒绝、替代后旧报告不能再出建议。人工修订、导入和聊天卡仍未分开预估与正式报告,见 [T1](../ops/short-term/2026-09-20-impact-loop-t1.md)。项目追踪索引见下一行 | `docgov` | -| 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | planned | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md) | 设计已写,代码未开始。切片 [T1](../ops/short-term/2026-09-20-impact-loop-t1.md) [T2](../ops/short-term/2026-09-20-project-trace-index-t2.md) [B1](../ops/short-term/2026-09-20-stage-reopen-l2.md) [B2](../ops/short-term/2026-09-20-recheck-soft-hold.md) [B3](../ops/short-term/2026-09-20-chat-revise-bridge.md) [T3](../ops/short-term/2026-09-20-trace-multihop-t3.md) 均为 planned。未复核不拦截推进。追踪留存类别 `record`,不签 D7 对话 ADR | `trace-impact-design` | +| 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | in_progress | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md) | 实现在 `feature/stage-backtrack-impl`。迁移 `00059` 未应用到运行中的环境(仍为 schema 58)前,进程在缺表时使用内存账本。未复核不拦截推进。不签 D7 对话 ADR,不改 `production_ready` | `trace-index` | | PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` | | 前端工作台 | 三栏 + 块文档 + SSE;错误卡/进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` | | PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | 文档生成仍使用读写和搜索工具。`tool_profile=reasoning` 的 argv 是 `--no-tools` 且不加载搜索扩展;未知 profile 拒绝启动。dev 挂载了 `pi-args.mjs`,但本轮未重启容器 | `pi-agent` | From b7197607e9c40122565c27bd082941a7160c1dbd Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 15:35:32 +0000 Subject: [PATCH 8/8] docs(ops): occupy the integration slot for the stage backtrack implementation Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 0879dcf3..e75a8335 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -168,7 +168,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-20 | 阶段回溯设计文档冒烟 `go vet ./...` 通过;docs-only,未 rebuild。此前 rdev 种子已在 test `rc-v1.0.0-beta.7` | - | +| Cursor-Auto | merging | 2026-09-20 | feature/stage-backtrack-impl 合入 dev;go vet 已过,随后短测 | - | **phase**: `idle` | `merging` | `smoke` | `e2e`