feat(service): implement docrevision Version/Revision kernel

Add canonicalize, stable block map, diff, risk floor, memory ledger
service, contract fixtures, concurrency tests, and backfill CLI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 06:15:20 +00:00
co-authored by Cursor
parent 7bbd6783fc
commit 268a9bdb56
15 changed files with 2290 additions and 2 deletions
+121
View File
@@ -0,0 +1,121 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"chat-agent-system/internal/docrevision"
"github.com/jackc/pgx/v5/pgxpool"
)
// document-revision-backfill dry-runs or applies controlled document output.md into the ledger.
func main() {
dsn := flag.String("dsn", os.Getenv("TEST_DATABASE_URL"), "postgres DSN")
repoRoot := flag.String("repo-root", ".", "repository root for relative work_dir")
apply := flag.Bool("apply", false, "apply changes (default dry-run)")
outPath := flag.String("report", "", "write JSON report path")
flag.Parse()
if *dsn == "" {
fmt.Fprintln(os.Stderr, "dsn or TEST_DATABASE_URL required")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
pool, err := pgxpool.New(ctx, *dsn)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer pool.Close()
rows, err := pool.Query(ctx, `
SELECT doc_id, COALESCE(work_dir,''), COALESCE(content_sha256,''), COALESCE(current_state,'drafting'), project_id
FROM document_records
ORDER BY doc_id`)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer rows.Close()
type item struct {
DocID string `json:"doc_id"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
SHA string `json:"sha,omitempty"`
FileSHA string `json:"file_sha,omitempty"`
}
report := struct {
DryRun bool `json:"dry_run"`
Items []item `json:"items"`
Success int `json:"success"`
Missing int `json:"missing"`
Mismatch int `json:"hash_mismatch"`
}{DryRun: !*apply}
svc := docrevision.NewService(nil)
for rows.Next() {
var docID, workDir, dbSHA, state string
var projectID int64
if err := rows.Scan(&docID, &workDir, &dbSHA, &state, &projectID); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
it := item{DocID: docID, SHA: dbSHA}
if workDir == "" {
it.Status = "missing"
it.Detail = "empty work_dir"
report.Missing++
report.Items = append(report.Items, it)
continue
}
path := workDir
if !filepath.IsAbs(path) {
path = filepath.Join(*repoRoot, workDir)
}
body, err := os.ReadFile(filepath.Join(path, "output.md"))
if err != nil {
it.Status = "missing"
it.Detail = err.Error()
report.Missing++
report.Items = append(report.Items, it)
continue
}
canon := docrevision.CanonicalizeMarkdown(string(body))
fileSHA := docrevision.SHA256Hex(canon)
it.FileSHA = fileSHA
if dbSHA != "" && !strings.EqualFold(dbSHA, fileSHA) {
it.Status = "hash_mismatch"
it.Detail = "document_records.content_sha256 != file"
report.Mismatch++
report.Items = append(report.Items, it)
continue
}
if *apply {
if _, err := svc.OpenTemplateVersion(ctx, docID, canon, docrevision.ActorSnapshot{DisplayName: "backfill"}, projectID, workDir); err != nil {
it.Status = "error"
it.Detail = err.Error()
report.Items = append(report.Items, it)
continue
}
}
it.Status = "success"
report.Success++
report.Items = append(report.Items, it)
}
enc, _ := json.MarshalIndent(report, "", " ")
if *outPath != "" {
_ = os.WriteFile(*outPath, enc, 0o644)
}
fmt.Println(string(enc))
if report.Mismatch > 0 {
os.Exit(3)
}
}
+64
View File
@@ -0,0 +1,64 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://agent-system.local/schemas/change-report-v1.json",
"title": "change-report-v1",
"type": "object",
"required": ["schema_version", "summary", "risk"],
"additionalProperties": true,
"properties": {
"schema_version": { "const": 1 },
"summary": {
"type": "array",
"minItems": 0,
"maxItems": 8,
"items": { "type": "string", "minLength": 1 }
},
"changes": {
"type": "array",
"items": {
"type": "object",
"required": ["section_locator", "change_type", "rationale"],
"properties": {
"section_locator": { "type": "string" },
"change_type": { "type": "string" },
"rationale": { "type": "string" },
"evidence_refs": { "type": "array", "items": { "type": "string" } }
}
}
},
"risk": {
"type": "object",
"required": ["level"],
"properties": {
"level": { "enum": ["low", "medium", "high", "critical"] },
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["category", "description"],
"properties": {
"category": { "type": "string" },
"description": { "type": "string" },
"mitigation": { "type": "string" }
}
}
}
}
},
"project_impact": { "type": "object" },
"document_impacts": {
"type": "array",
"items": {
"type": "object",
"required": ["doc_id", "impact_type", "reason"],
"properties": {
"doc_id": { "type": "string" },
"impact_type": { "type": "string" },
"reason": { "type": "string" },
"recommended_action": { "type": "string" }
}
}
},
"open_questions": { "type": "array", "items": { "type": "string" } }
}
}
+2 -2
View File
@@ -27,7 +27,7 @@ require (
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+4
View File
@@ -51,8 +51,12 @@ golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+185
View File
@@ -0,0 +1,185 @@
package docrevision
import (
"crypto/rand"
"encoding/hex"
"fmt"
"regexp"
"strings"
)
var (
headingRe = regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
reqIDRe = regexp.MustCompile(`\b(REQ-[A-Z0-9-]+)\b`)
listItemRe = regexp.MustCompile(`^(\s*[-*+]|\s*\d+\.)\s+`)
tableRowRe = regexp.MustCompile(`^\|`)
fenceRe = regexp.MustCompile("^```")
mermaidRe = regexp.MustCompile(`(?i)^mermaid\b`)
)
// BuildBlockMap parses markdown into stable block nodes.
// When previous is provided, nodes are aligned by business key → heading path → fingerprint → order.
func BuildBlockMap(markdown string, previous []BlockNode) []BlockNode {
lines := strings.Split(strings.TrimSuffix(markdown, "\n"), "\n")
var nodes []BlockNode
offset := 0
headingPath := []string{}
inFence := false
fenceLang := ""
var fenceBuf []string
fenceStart := 0
order := 0
flushFence := func() {
if !inFence {
return
}
body := strings.Join(fenceBuf, "\n")
typ := "code"
if mermaidRe.MatchString(fenceLang) {
typ = "mermaid"
}
nodes = append(nodes, makeNode(typ, fenceStart, offset, strings.Join(headingPath, " / "), "", body, order, previous))
order++
inFence = false
fenceBuf = nil
fenceLang = ""
}
for i, line := range lines {
lineLen := len(line)
if i < len(lines)-1 || strings.HasSuffix(markdown, "\n") {
lineLen++ // account for newline except possibly last without NL; we trimSuffix above so always +1 except last
}
// recompute offset carefully
_ = lineLen
}
offset = 0
for i, line := range lines {
start := offset
end := start + len(line)
if i < len(lines)-1 {
end++ // newline
} else if strings.HasSuffix(markdown, "\n") {
end++
}
if fenceRe.MatchString(line) {
if !inFence {
inFence = true
fenceLang = strings.TrimSpace(strings.TrimPrefix(line, "```"))
fenceStart = start
fenceBuf = nil
} else {
flushFence()
}
offset = end
continue
}
if inFence {
fenceBuf = append(fenceBuf, line)
offset = end
continue
}
if m := headingRe.FindStringSubmatch(line); m != nil {
level := len(m[1])
title := strings.TrimSpace(m[2])
if level <= len(headingPath) {
headingPath = headingPath[:level-1]
}
headingPath = append(headingPath, title)
path := strings.Join(headingPath, " / ")
bk := ""
if id := reqIDRe.FindString(title); id != "" {
bk = id
}
nodes = append(nodes, makeNode("heading", start, end, path, bk, line, order, previous))
order++
offset = end
continue
}
path := strings.Join(headingPath, " / ")
switch {
case tableRowRe.MatchString(line):
bk := ""
if id := reqIDRe.FindString(line); id != "" {
bk = id
}
nodes = append(nodes, makeNode("table_row", start, end, path, bk, line, order, previous))
case listItemRe.MatchString(line):
bk := ""
if id := reqIDRe.FindString(line); id != "" {
bk = id
}
nodes = append(nodes, makeNode("list_item", start, end, path, bk, line, order, previous))
case strings.TrimSpace(line) == "":
// skip empty
default:
bk := ""
if id := reqIDRe.FindString(line); id != "" {
bk = id
}
nodes = append(nodes, makeNode("paragraph", start, end, path, bk, line, order, previous))
}
order++
offset = end
}
flushFence()
return nodes
}
func makeNode(typ string, start, end int, path, businessKey, text string, order int, previous []BlockNode) BlockNode {
fp := ContentFingerprint(text)
id := matchPreviousID(typ, path, businessKey, fp, order, previous)
if id == "" {
id = newBlockID()
}
return BlockNode{
ID: id,
Type: typ,
StartOffset: start,
EndOffset: end,
HeadingPath: path,
BusinessKey: businessKey,
Fingerprint: fp,
Order: order,
}
}
func matchPreviousID(typ, path, businessKey, fp string, order int, previous []BlockNode) string {
if len(previous) == 0 {
return ""
}
if businessKey != "" {
for _, p := range previous {
if p.BusinessKey == businessKey && p.Type == typ {
return p.ID
}
}
}
for _, p := range previous {
if p.Type == typ && p.HeadingPath == path && p.Fingerprint == fp {
return p.ID
}
}
for _, p := range previous {
if p.Type == typ && p.Fingerprint == fp {
return p.ID
}
}
for _, p := range previous {
if p.Order == order && p.Type == typ {
return p.ID
}
}
return ""
}
func newBlockID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return fmt.Sprintf("blk_%s", hex.EncodeToString(b[:]))
}
+49
View File
@@ -0,0 +1,49 @@
package docrevision
import (
"crypto/sha256"
"encoding/hex"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/unicode/norm"
)
// CanonicalizeMarkdown applies LF, UTF-8 NFC, trailing newline, and trims trailing spaces per line.
func CanonicalizeMarkdown(raw string) string {
s := strings.ReplaceAll(raw, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = norm.NFC.String(s)
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = strings.TrimRightFunc(line, func(r rune) bool {
return r == ' ' || r == '\t'
})
}
s = strings.Join(lines, "\n")
if s != "" && !strings.HasSuffix(s, "\n") {
s += "\n"
}
return s
}
func SHA256Hex(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
func ContentFingerprint(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if unicode.IsSpace(r) {
continue
}
b.WriteRune(r)
}
return SHA256Hex(b.String())[:16]
}
func RuneCount(s string) int {
return utf8.RuneCountInString(s)
}
+100
View File
@@ -0,0 +1,100 @@
package docrevision
import (
"context"
"fmt"
"sync"
"testing"
)
func TestConcurrentHumanRevisions(t *testing.T) {
svc := NewService(nil)
ctx := context.Background()
st, err := svc.OpenTemplateVersion(ctx, "doc_conc", "# base\n", ActorSnapshot{}, 1, "")
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
errs := make(chan error, 50)
for i := 0; i < 50; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
md := fmt.Sprintf("# base\n\nedit %d\n", i)
// Only first expected head will succeed; others conflict — that's OK.
_, err := svc.CommitHumanRevision(ctx, HumanRevisionRequest{
DocID: "doc_conc", Markdown: md,
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
IdempotencyKey: fmt.Sprintf("conc-%d", i), ActorUserID: int64(i + 1), LifecycleState: "drafting",
})
if err != nil && err != ErrHeadConflict {
errs <- err
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
// Sequential chain should produce continuous revision numbers
svc2 := NewService(nil)
st2, _ := svc2.OpenTemplateVersion(ctx, "doc_seq", "# s\n", ActorSnapshot{}, 1, "")
head := st2.HeadSnapshotID
rev := st2.HeadRevisionNo
for i := 0; i < 20; i++ {
r, err := svc2.CommitHumanRevision(ctx, HumanRevisionRequest{
DocID: "doc_seq", Markdown: fmt.Sprintf("# s\n\n%d\n", i),
ExpectedHeadSnapshot: &head, ExpectedRevisionNo: &rev,
IdempotencyKey: fmt.Sprintf("seq-%d", i), ActorUserID: 1, LifecycleState: "drafting",
})
if err != nil {
t.Fatal(err)
}
if r.State.HeadRevisionNo != i+1 {
t.Fatalf("revision_no want %d got %d", i+1, r.State.HeadRevisionNo)
}
head = r.State.HeadSnapshotID
rev = r.State.HeadRevisionNo
}
vers, _ := svc2.ListVersions(ctx, "doc_seq")
open := 0
for _, v := range vers {
if v.Status == VersionOpen {
open++
}
}
if open != 1 {
t.Fatalf("want exactly one open version, got %d", open)
}
}
func TestFreeformLegacyImport(t *testing.T) {
svc := NewService(nil)
ctx := context.Background()
st, err := svc.ImportFreeformLegacy(ctx, "ff1", 9, 1, []struct {
VersionNo int
Markdown string
SHA string
}{
{VersionNo: 1, Markdown: "# v1\n"},
{VersionNo: 2, Markdown: "# v2\n"},
}, "# v3 head\n")
if err != nil {
t.Fatal(err)
}
if st.CurrentVersionNo != 3 {
t.Fatalf("want version 3 open, got %d", st.CurrentVersionNo)
}
r, err := svc.CommitHumanRevision(ctx, HumanRevisionRequest{
DocID: "ff1", Markdown: "# v3 head\n\nedit\n",
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
IdempotencyKey: "ff-e1", ActorUserID: 9, LifecycleState: "drafting",
})
if err != nil {
t.Fatal(err)
}
if r.State.CurrentVersionNo != 3 || r.State.HeadRevisionNo != 1 {
t.Fatalf("human save should only bump revision: %+v", r.State)
}
}
+225
View File
@@ -0,0 +1,225 @@
package docrevision_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"chat-agent-system/internal/docrevision"
)
func TestCanonicalize_NFC_LF_TrailingNewline(t *testing.T) {
in := "a\r\n b \t\n"
out := docrevision.CanonicalizeMarkdown(in)
if out != "a\n b\n" {
t.Fatalf("got %q", out)
}
}
func TestGoldenDiffFixtures(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", "golden_diff_cases.json"))
if err != nil {
t.Fatal(err)
}
var fixture struct {
Cases []struct {
ID string `json:"id"`
Before string `json:"before"`
After string `json:"after"`
ExpectKinds []string `json:"expect_kinds"`
} `json:"cases"`
}
if err := json.Unmarshal(raw, &fixture); err != nil {
t.Fatal(err)
}
for _, c := range fixture.Cases {
t.Run(c.ID, func(t *testing.T) {
before := docrevision.CanonicalizeMarkdown(c.Before)
after := docrevision.CanonicalizeMarkdown(c.After)
fb := docrevision.BuildBlockMap(before, nil)
ab := docrevision.BuildBlockMap(after, fb)
diff := docrevision.ComputeDiff(before, after, fb, ab)
if len(c.ExpectKinds) == 0 {
if len(diff.Hunks) != 0 {
t.Fatalf("noop expected empty hunks, got %+v", diff.Hunks)
}
return
}
found := map[string]bool{}
for _, h := range diff.Hunks {
found[h.Kind] = true
}
for _, k := range c.ExpectKinds {
if !found[k] {
t.Fatalf("expected kind %s in %+v", k, diff.Hunks)
}
}
})
}
}
func TestContractTimeline_VersionRevisionSemantics(t *testing.T) {
svc := docrevision.NewService(nil)
ctx := context.Background()
actor := docrevision.ActorSnapshot{DisplayName: "张工", Role: "author", Duty: "author"}
st, err := svc.OpenTemplateVersion(ctx, "doc_demo", "# V1\n\n模板正文\n", actor, 1, "workspaces/demo")
if err != nil {
t.Fatal(err)
}
if st.CurrentVersionNo != 1 || st.HeadRevisionNo != 0 {
t.Fatalf("T0 state: %+v", st)
}
r1, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
DocID: "doc_demo", Markdown: "# V1\n\n模板正文\n\n张工修改\n",
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
IdempotencyKey: "k1", ActorUserID: 10, ActorSnapshot: actor, LifecycleState: "drafting",
})
if err != nil {
t.Fatal(err)
}
if r1.State.CurrentVersionNo != 1 || r1.State.HeadRevisionNo != 1 || r1.State.ManualChangeCount != 1 {
t.Fatalf("T1: %+v", r1.State)
}
r2, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
DocID: "doc_demo", Markdown: "# V1\n\n模板正文\n\n张工修改\n李工再改\n",
ExpectedHeadSnapshot: &r1.State.HeadSnapshotID, ExpectedRevisionNo: &r1.State.HeadRevisionNo,
IdempotencyKey: "k2", ActorUserID: 11, ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "李工"}, LifecycleState: "drafting",
})
if err != nil {
t.Fatal(err)
}
if r2.State.CurrentVersionNo != 1 || r2.State.ManualChangeCount != 2 {
t.Fatalf("T2: %+v", r2.State)
}
reportRaw, _ := os.ReadFile(filepath.Join("testdata", "change_report_v1_valid.json"))
cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
DocID: "doc_demo", RunID: "run-1", InputSnapshotID: r2.State.HeadSnapshotID,
OutputMarkdown: "# V2\n\n智能体新基线\n", ReportJSON: string(reportRaw),
ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: docrevision.ActorSnapshot{SquadName: "squad-a"},
})
if err != nil && cand == nil {
t.Fatal(err)
}
if cand.Status != docrevision.CandidateNeedsConfirmation {
t.Fatalf("high risk should need confirmation, got %s risk=%s", cand.Status, rep.RiskLevel)
}
_, err = svc.CommitAgentVersion(ctx, cand.ID, docrevision.CommitAgentDecision{
Decision: "commit", Confirmed: false, ActorUserID: 10,
})
if err != docrevision.ErrNeedsConfirmation {
t.Fatalf("expected needs confirmation, got %v", err)
}
st3, err := svc.CommitAgentVersion(ctx, cand.ID, docrevision.CommitAgentDecision{
Decision: "commit", Confirmed: true, ActorUserID: 10,
ExpectedInputSHA: r2.State.HeadSHA256,
})
if err != nil {
t.Fatal(err)
}
if st3.CurrentVersionNo != 2 || st3.HeadRevisionNo != 0 || st3.ManualChangeCount != 0 {
t.Fatalf("T3 after agent: %+v", st3)
}
overlay, err := svc.ManualOverlay(ctx, "doc_demo")
if err != nil {
t.Fatal(err)
}
if len(overlay.Hunks) != 0 {
t.Fatalf("manual overlay should reset after agent baseline, got %+v", overlay)
}
r4, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
DocID: "doc_demo", Markdown: "# V2\n\n智能体新基线\n张工再改\n",
ExpectedHeadSnapshot: &st3.HeadSnapshotID, ExpectedRevisionNo: &st3.HeadRevisionNo,
IdempotencyKey: "k4", ActorUserID: 10, ActorSnapshot: actor, LifecycleState: "drafting",
})
if err != nil {
t.Fatal(err)
}
if r4.State.CurrentVersionNo != 2 || r4.State.ManualChangeCount != 1 {
t.Fatalf("T4: %+v", r4.State)
}
diff, err := svc.DiffVersions(ctx, "doc_demo", 1, 2, "final", "final")
if err != nil {
t.Fatal(err)
}
if len(diff.Hunks) == 0 {
t.Fatal("V1 vs V2 should differ")
}
}
func TestBlockMapStableAcrossRebuild(t *testing.T) {
md := "## 需求\n\nREQ-PERF-007 温度精度 ±1℃\n"
a := docrevision.BuildBlockMap(md, nil)
b := docrevision.BuildBlockMap(md+"\n补充一句。\n", a)
found := false
for _, n := range b {
if n.BusinessKey == "REQ-PERF-007" {
for _, o := range a {
if o.BusinessKey == "REQ-PERF-007" && o.ID == n.ID {
found = true
}
}
}
}
if !found {
t.Fatalf("business key id not stable: a=%+v b=%+v", a, b)
}
}
func TestIdempotentHumanRevision(t *testing.T) {
svc := docrevision.NewService(nil)
ctx := context.Background()
st, _ := svc.OpenTemplateVersion(ctx, "doc_idem", "# A\n", docrevision.ActorSnapshot{}, 1, "")
req := docrevision.HumanRevisionRequest{
DocID: "doc_idem", Markdown: "# A\n\nB\n",
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
IdempotencyKey: "same", ActorUserID: 1, LifecycleState: "drafting",
}
r1, err := svc.CommitHumanRevision(ctx, req)
if err != nil {
t.Fatal(err)
}
r2, err := svc.CommitHumanRevision(ctx, req)
if err != nil {
t.Fatal(err)
}
if r1.Event.ID != r2.Event.ID || r1.State.HeadSnapshotID != r2.State.HeadSnapshotID {
t.Fatalf("idempotency broken")
}
}
func TestHeadConflict(t *testing.T) {
svc := docrevision.NewService(nil)
ctx := context.Background()
st, _ := svc.OpenTemplateVersion(ctx, "doc_c", "# A\n", docrevision.ActorSnapshot{}, 1, "")
wrong := st.HeadSnapshotID + 99
_, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
DocID: "doc_c", Markdown: "# B\n", ExpectedHeadSnapshot: &wrong,
IdempotencyKey: "x", ActorUserID: 1, LifecycleState: "drafting",
})
if err != docrevision.ErrHeadConflict {
t.Fatalf("got %v", err)
}
}
func TestLifecycleLock(t *testing.T) {
svc := docrevision.NewService(nil)
ctx := context.Background()
st, _ := svc.OpenTemplateVersion(ctx, "doc_l", "# A\n", docrevision.ActorSnapshot{}, 1, "")
svc.SetLifecycle("doc_l", "reviewing")
_, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
DocID: "doc_l", Markdown: "# B\n", ExpectedHeadSnapshot: &st.HeadSnapshotID,
IdempotencyKey: "y", ActorUserID: 1, LifecycleState: "reviewing",
})
if err != docrevision.ErrLifecycleLocked {
t.Fatalf("got %v", err)
}
}
+242
View File
@@ -0,0 +1,242 @@
package docrevision
import (
"fmt"
"strings"
)
// ComputeDiff produces a line-oriented structured diff between two markdown bodies.
func ComputeDiff(fromMarkdown, toMarkdown string, fromBlocks, toBlocks []BlockNode) DiffResult {
fromLines := splitLines(fromMarkdown)
toLines := splitLines(toMarkdown)
hunks := myersLineDiff(fromLines, toLines, fromBlocks, toBlocks)
// Detect moves on raw delete/insert pairs before collapsing modifies.
hunks = annotateMoves(hunks, fromBlocks, toBlocks)
hunks = collapseAdjacentModify(hunks)
sum := DiffSummary{}
for _, h := range hunks {
switch h.Kind {
case "insert":
sum.Inserts++
case "delete":
sum.Deletes++
case "modify":
sum.Modifies++
case "move":
sum.Moves++
}
}
return DiffResult{Summary: sum, Hunks: hunks}
}
func splitLines(s string) []string {
s = strings.TrimSuffix(s, "\n")
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
func myersLineDiff(a, b []string, fromBlocks, toBlocks []BlockNode) []DiffHunk {
n, m := len(a), len(b)
// Simple LCS DP for correctness on moderate docs.
dp := make([][]int, n+1)
for i := range dp {
dp[i] = make([]int, m+1)
}
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
if a[i] == b[j] {
dp[i][j] = dp[i+1][j+1] + 1
} else if dp[i+1][j] >= dp[i][j+1] {
dp[i][j] = dp[i+1][j]
} else {
dp[i][j] = dp[i][j+1]
}
}
}
var hunks []DiffHunk
i, j := 0, 0
idx := 0
for i < n && j < m {
if a[i] == b[j] {
i++
j++
continue
}
if dp[i+1][j] >= dp[i][j+1] {
h := DiffHunk{
ID: fmt.Sprintf("h%d", idx),
Kind: "delete",
BeforeText: a[i],
LineStart: i + 1,
LineEnd: i + 1,
Section: sectionForLine(i, fromBlocks),
}
if blk := blockForLine(i, a, fromBlocks); blk != nil {
h.BlockIDBefore = blk.ID
}
hunks = append(hunks, h)
idx++
i++
} else {
h := DiffHunk{
ID: fmt.Sprintf("h%d", idx),
Kind: "insert",
AfterText: b[j],
LineStart: j + 1,
LineEnd: j + 1,
Section: sectionForLine(j, toBlocks),
}
if blk := blockForLine(j, b, toBlocks); blk != nil {
h.BlockIDAfter = blk.ID
}
hunks = append(hunks, h)
idx++
j++
}
}
for i < n {
h := DiffHunk{ID: fmt.Sprintf("h%d", idx), Kind: "delete", BeforeText: a[i], LineStart: i + 1, LineEnd: i + 1, Section: sectionForLine(i, fromBlocks)}
if blk := blockForLine(i, a, fromBlocks); blk != nil {
h.BlockIDBefore = blk.ID
}
hunks = append(hunks, h)
idx++
i++
}
for j < m {
h := DiffHunk{ID: fmt.Sprintf("h%d", idx), Kind: "insert", AfterText: b[j], LineStart: j + 1, LineEnd: j + 1, Section: sectionForLine(j, toBlocks)}
if blk := blockForLine(j, b, toBlocks); blk != nil {
h.BlockIDAfter = blk.ID
}
hunks = append(hunks, h)
idx++
j++
}
return hunks
}
func collapseAdjacentModify(hunks []DiffHunk) []DiffHunk {
if len(hunks) == 0 {
return hunks
}
out := make([]DiffHunk, 0, len(hunks))
for i := 0; i < len(hunks); i++ {
if i+1 < len(hunks) && hunks[i].Kind == "delete" && hunks[i+1].Kind == "insert" {
out = append(out, DiffHunk{
ID: hunks[i].ID,
Kind: "modify",
BeforeText: hunks[i].BeforeText,
AfterText: hunks[i+1].AfterText,
BlockIDBefore: hunks[i].BlockIDBefore,
BlockIDAfter: hunks[i+1].BlockIDAfter,
Section: firstNonEmpty(hunks[i].Section, hunks[i+1].Section),
LineStart: hunks[i].LineStart,
LineEnd: hunks[i+1].LineEnd,
})
i++
continue
}
out = append(out, hunks[i])
}
return out
}
func annotateMoves(hunks []DiffHunk, fromBlocks, toBlocks []BlockNode) []DiffHunk {
fromByFP := map[string]string{}
for _, b := range fromBlocks {
fromByFP[b.Fingerprint] = b.ID
}
toByFP := map[string]string{}
for _, b := range toBlocks {
toByFP[b.Fingerprint] = b.ID
}
deletedFP := map[string]int{}
insertedFP := map[string]int{}
for i, h := range hunks {
if h.Kind == "delete" {
deletedFP[ContentFingerprint(h.BeforeText)] = i
}
if h.Kind == "insert" {
insertedFP[ContentFingerprint(h.AfterText)] = i
}
}
used := map[int]bool{}
out := make([]DiffHunk, 0, len(hunks))
for i, h := range hunks {
if used[i] {
continue
}
if h.Kind == "delete" {
fp := ContentFingerprint(h.BeforeText)
if j, ok := insertedFP[fp]; ok && !used[j] {
used[i] = true
used[j] = true
mh := DiffHunk{
ID: h.ID,
Kind: "move",
BeforeText: h.BeforeText,
AfterText: hunks[j].AfterText,
BlockIDBefore: h.BlockIDBefore,
BlockIDAfter: hunks[j].BlockIDAfter,
Section: fmt.Sprintf("%s → %s", h.Section, hunks[j].Section),
LineStart: h.LineStart,
LineEnd: hunks[j].LineEnd,
}
out = append(out, mh)
continue
}
}
if h.Kind == "insert" {
fp := ContentFingerprint(h.AfterText)
if _, ok := deletedFP[fp]; ok {
// paired as move above
if used[i] {
continue
}
}
}
out = append(out, h)
}
return out
}
func sectionForLine(lineIdx int, blocks []BlockNode) string {
// Approximate: find block whose order matches line; fallback heading path of nearest prior.
if lineIdx < 0 || len(blocks) == 0 {
return ""
}
best := ""
for _, b := range blocks {
if b.Order <= lineIdx {
if b.HeadingPath != "" {
best = b.HeadingPath
}
}
}
return best
}
func blockForLine(lineIdx int, lines []string, blocks []BlockNode) *BlockNode {
if lineIdx < 0 || lineIdx >= len(lines) {
return nil
}
fp := ContentFingerprint(lines[lineIdx])
for i := range blocks {
if blocks[i].Fingerprint == fp {
return &blocks[i]
}
}
if lineIdx < len(blocks) {
return &blocks[lineIdx]
}
return nil
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
+25
View File
@@ -0,0 +1,25 @@
package docrevision
// ImpactFromLineage builds deterministic document impacts from known downstream doc IDs.
func ImpactFromLineage(downstream []struct {
DocID string
Reason string
}) []DocImpact {
out := make([]DocImpact, 0, len(downstream))
for _, d := range downstream {
if d.DocID == "" {
continue
}
reason := d.Reason
if reason == "" {
reason = "上游文档内容变更,下游需复核"
}
out = append(out, DocImpact{
DocID: d.DocID,
ImpactType: "stale_candidate",
Reason: reason,
RecommendedAction: "由编制负责人复核并重新基线",
})
}
return out
}
+160
View File
@@ -0,0 +1,160 @@
package docrevision
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var (
sensitiveDeleteRe = regexp.MustCompile(`(?i)(法规|风险控制|验收标准|技术参数|REQ-|GB\d|YY\d)`)
numericChangeRe = regexp.MustCompile(`\d+(\.\d+)?`)
)
// MaxRisk returns the higher of two risk levels.
func MaxRisk(a, b string) string {
rank := map[string]int{RiskLow: 1, RiskMedium: 2, RiskHigh: 3, RiskCritical: 4, "": 0}
if rank[a] >= rank[b] {
if a == "" {
return RiskLow
}
return a
}
if b == "" {
return RiskLow
}
return b
}
// RuleRiskFloor computes deterministic risk from diff hunks.
func RuleRiskFloor(hunks []DiffHunk, impacts []DocImpact) string {
level := RiskLow
for _, h := range hunks {
text := h.BeforeText + "\n" + h.AfterText
if h.Kind == "delete" && sensitiveDeleteRe.MatchString(h.BeforeText) {
level = MaxRisk(level, RiskHigh)
}
if h.Kind == "modify" && sensitiveDeleteRe.MatchString(text) {
level = MaxRisk(level, RiskHigh)
}
if h.Kind == "modify" && numericChangeRe.MatchString(h.BeforeText) && numericChangeRe.MatchString(h.AfterText) && h.BeforeText != h.AfterText {
if sensitiveDeleteRe.MatchString(text) {
level = MaxRisk(level, RiskHigh)
} else {
level = MaxRisk(level, RiskMedium)
}
}
}
for _, imp := range impacts {
if imp.ImpactType == "stale_candidate" || strings.Contains(imp.Reason, "已批准") {
level = MaxRisk(level, RiskCritical)
} else if imp.DocID != "" {
level = MaxRisk(level, RiskHigh)
}
}
return level
}
// ValidateChangeReportJSON parses and validates change_report.json against deterministic facts.
func ValidateChangeReportJSON(raw string, fromSHA, toSHA string, ruleRisk string, impacts []DocImpact, hunks []DiffHunk) (*ChangeReport, error) {
if strings.TrimSpace(raw) == "" {
return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "empty report"), ErrReportInvalid
}
var payload struct {
SchemaVersion int `json:"schema_version"`
Summary []string `json:"summary"`
Changes []map[string]any `json:"changes"`
Risk struct {
Level string `json:"level"`
Items []RiskItem `json:"items"`
} `json:"risk"`
ProjectImpact map[string]any `json:"project_impact"`
DocumentImpacts []DocImpact `json:"document_impacts"`
OpenQuestions []string `json:"open_questions"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "json parse failed"), fmt.Errorf("%w: %v", ErrReportInvalid, err)
}
if payload.SchemaVersion != 1 {
return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "bad schema"), fmt.Errorf("%w: schema_version", ErrReportInvalid)
}
if fromSHA != toSHA && len(payload.Summary) == 0 && len(payload.Changes) == 0 {
return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "claims no change"), fmt.Errorf("%w: missing summary for changed content", ErrReportInvalid)
}
if fromSHA != toSHA && strings.EqualFold(strings.Join(payload.Summary, " "), "未修改") {
return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "claims unmodified"), fmt.Errorf("%w: unmodified claim", ErrReportInvalid)
}
finalRisk := MaxRisk(ruleRisk, payload.Risk.Level)
if finalRisk == "" {
finalRisk = RiskUnassessedAsHigh()
}
rep := &ChangeReport{
SchemaVersion: 1,
Summary: payload.Summary,
RiskLevel: finalRisk,
RiskItems: payload.Risk.Items,
ProjectImpacts: payload.ProjectImpact,
DocumentImpacts: mergeImpacts(payload.DocumentImpacts, impacts),
OpenQuestions: payload.OpenQuestions,
AnalysisStatus: AnalysisComplete,
RawJSON: raw,
DeterministicFacts: map[string]any{
"from_sha": fromSHA,
"to_sha": toSHA,
"rule_risk": ruleRisk,
"hunk_count": len(hunks),
"impact_count": len(impacts),
},
}
return rep, nil
}
func RiskUnassessedAsHigh() string { return RiskHigh }
func fallbackReport(fromSHA, toSHA, ruleRisk string, impacts []DocImpact, hunks []DiffHunk, reason string) *ChangeReport {
risk := ruleRisk
if risk == "" {
risk = RiskHigh
}
return &ChangeReport{
SchemaVersion: 1,
Summary: []string{"变更报告未完整评估,已按保守策略处理"},
RiskLevel: risk,
DocumentImpacts: impacts,
OpenQuestions: []string{reason},
AnalysisStatus: AnalysisUnassessed,
DeterministicFacts: map[string]any{
"from_sha": fromSHA,
"to_sha": toSHA,
"rule_risk": ruleRisk,
"hunk_count": len(hunks),
"fallback": reason,
},
}
}
func mergeImpacts(model, det []DocImpact) []DocImpact {
seen := map[string]bool{}
out := make([]DocImpact, 0, len(model)+len(det))
for _, d := range det {
if d.DocID == "" || seen[d.DocID] {
continue
}
seen[d.DocID] = true
out = append(out, d)
}
for _, m := range model {
if m.DocID == "" || seen[m.DocID] {
continue
}
seen[m.DocID] = true
out = append(out, m)
}
return out
}
// NeedsConfirmation reports whether frozen policy requires author confirmation.
func NeedsConfirmation(risk string) bool {
return risk == RiskHigh || risk == RiskCritical
}
+712
View File
@@ -0,0 +1,712 @@
package docrevision
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Service is the document revision ledger.
type Service struct {
pool *pgxpool.Pool
mu sync.Mutex
blobs map[string]*ContentBlob
snapshots map[int64]*Snapshot
versions map[int64]*Version
events map[int64]*ChangeEvent
candidates map[int64]*AgentCandidate
reports map[int64]*ChangeReport
roots map[string]*DocRootPointers
idemp map[string]int64 // idempotency_key -> event id
nextSnapID int64
nextVerID int64
nextEvtID int64
nextCandID int64
nextRepID int64
// Outbox hooks (Wave 5); optional.
OnOutbox func(ctx context.Context, eventType string, payload map[string]any) error
// Feature flags
LedgerWrite bool
LedgerRead bool
AgentCommit bool
DiffUI bool
Notify bool
}
func NewService(pool *pgxpool.Pool) *Service {
return &Service{
pool: pool,
blobs: map[string]*ContentBlob{},
snapshots: map[int64]*Snapshot{},
versions: map[int64]*Version{},
events: map[int64]*ChangeEvent{},
candidates: map[int64]*AgentCandidate{},
reports: map[int64]*ChangeReport{},
roots: map[string]*DocRootPointers{},
idemp: map[string]int64{},
LedgerWrite: true,
LedgerRead: true,
AgentCommit: true,
DiffUI: true,
Notify: true,
}
}
func (s *Service) EnsureRoot(docID, kind string, projectID int64, lifecycle string) *DocRootPointers {
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.roots[docID]; ok {
return r
}
r := &DocRootPointers{
DocID: docID,
Kind: kind,
LifecycleState: lifecycle,
ProjectID: projectID,
RevisionSchemaVer: SchemaVersion,
}
s.roots[docID] = r
return r
}
func (s *Service) GetRevisionState(ctx context.Context, docID string) (*RevisionState, error) {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
root, ok := s.roots[docID]
if !ok || root.LedgerVersionID == nil {
return nil, ErrNotFound
}
ver := s.versions[*root.LedgerVersionID]
head := s.snapshots[*root.HeadSnapshotID]
caps := []string{"read"}
if root.LifecycleState == "drafting" {
caps = append(caps, "edit")
}
if s.DiffUI {
caps = append(caps, "diff", "overlay", "history")
}
return &RevisionState{
DocID: docID,
CurrentVersionNo: ver.VersionNo,
VersionStatus: ver.Status,
BaselineSnapshotID: ver.BaselineSnapshotID,
HeadSnapshotID: ver.HeadSnapshotID,
HeadRevisionNo: root.HeadRevisionNo,
HeadSHA256: head.ContentSHA256,
ManualChangeCount: ver.HumanRevisionCount,
Capabilities: caps,
ETag: fmt.Sprintf("%d:%d", ver.HeadSnapshotID, root.HeadRevisionNo),
}, nil
}
func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequest) (*HumanRevisionResult, error) {
_ = ctx
if req.IdempotencyKey == "" {
return nil, fmt.Errorf("docrevision: idempotency key required")
}
if req.LifecycleState != "" && req.LifecycleState != "drafting" {
return nil, ErrLifecycleLocked
}
canon := CanonicalizeMarkdown(req.Markdown)
if len(canon) > MaxMarkdownBytes {
return nil, ErrTooLarge
}
sha := SHA256Hex(canon)
s.mu.Lock()
defer s.mu.Unlock()
if eid, ok := s.idemp[req.IdempotencyKey]; ok {
ev := s.events[eid]
st, _ := s.revisionStateLocked(req.DocID)
overlay := s.manualOverlayLocked(req.DocID)
return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay}, nil
}
root := s.roots[req.DocID]
if root == nil {
root = &DocRootPointers{DocID: req.DocID, Kind: "controlled", LifecycleState: req.LifecycleState, ProjectID: req.ProjectID, RevisionSchemaVer: SchemaVersion, WorkDir: req.WorkDir}
s.roots[req.DocID] = root
}
if root.LedgerVersionID == nil {
if !req.AllowCreateBaseline {
return nil, ErrNoOpenVersion
}
snap := s.putSnapshotLocked(req.DocID, canon, sha, nil, SourceHuman, &req.ActorUserID, "", req.ActorSnapshot, "", req.ClientBlockMap)
ver := s.openVersionLocked(req.DocID, OriginTemplate, snap.ID, snap.ID, "")
root.LedgerVersionID = &ver.ID
root.HeadSnapshotID = &snap.ID
root.HeadRevisionNo = 0
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)
st := s.mustStateLocked(req.DocID)
return &HumanRevisionResult{State: *st, Event: *ev}, nil
}
ver := s.versions[*root.LedgerVersionID]
if ver.Status != VersionOpen {
return nil, ErrNoOpenVersion
}
if req.ExpectedHeadSnapshot != nil && *req.ExpectedHeadSnapshot != ver.HeadSnapshotID {
cur := s.snapshots[ver.HeadSnapshotID]
return &HumanRevisionResult{Conflict: &ConflictInfo{
Code: ErrCodeHeadConflict, BaseSnapshotID: ver.BaselineSnapshotID,
CurrentSnapshotID: ver.HeadSnapshotID, CurrentRevisionNo: root.HeadRevisionNo, CurrentSHA256: cur.ContentSHA256,
}}, ErrHeadConflict
}
if req.ExpectedRevisionNo != nil && *req.ExpectedRevisionNo != root.HeadRevisionNo {
cur := s.snapshots[ver.HeadSnapshotID]
return &HumanRevisionResult{Conflict: &ConflictInfo{
Code: ErrCodeHeadConflict, BaseSnapshotID: ver.BaselineSnapshotID,
CurrentSnapshotID: ver.HeadSnapshotID, CurrentRevisionNo: root.HeadRevisionNo, CurrentSHA256: cur.ContentSHA256,
}}, ErrHeadConflict
}
parentID := ver.HeadSnapshotID
parent := s.snapshots[parentID]
if parent.ContentSHA256 == sha {
// no-op: return current without new snapshot/version
st := s.mustStateLocked(req.DocID)
overlay := s.manualOverlayLocked(req.DocID)
return &HumanRevisionResult{State: *st, Overlay: overlay}, nil
}
prevBlocks := parent.BlockMap
snap := s.putSnapshotLocked(req.DocID, canon, sha, &parentID, SourceHuman, &req.ActorUserID, "", req.ActorSnapshot, "", req.ClientBlockMap)
if len(req.ClientBlockMap) == 0 {
snap.BlockMap = BuildBlockMap(canon, prevBlocks)
} else {
snap.BlockMap = BuildBlockMap(canon, req.ClientBlockMap)
}
diff := ComputeDiff(s.blobs[parent.ContentSHA256].ContentMarkdown, canon, prevBlocks, snap.BlockMap)
ver.HeadSnapshotID = snap.ID
ver.HumanRevisionCount++
root.HeadSnapshotID = &snap.ID
root.HeadRevisionNo++
root.ContentSHA256 = sha
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)
st := s.mustStateLocked(req.DocID)
overlay := DiffResult{FromSnapshotID: ver.BaselineSnapshotID, ToSnapshotID: snap.ID, Summary: diff.Summary, Hunks: diff.Hunks}
baseMD := s.blobs[s.snapshots[ver.BaselineSnapshotID].ContentSHA256].ContentMarkdown
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
}
func (s *Service) OpenTemplateVersion(ctx context.Context, docID string, markdown string, actor ActorSnapshot, projectID int64, workDir string) (*RevisionState, error) {
_ = ctx
canon := CanonicalizeMarkdown(markdown)
sha := SHA256Hex(canon)
s.mu.Lock()
defer s.mu.Unlock()
if root, ok := s.roots[docID]; ok && root.LedgerVersionID != nil {
return s.mustStateLocked(docID), nil
}
snap := s.putSnapshotLocked(docID, canon, sha, nil, SourceTemplate, nil, "template", actor, "", nil)
ver := s.openVersionLocked(docID, OriginTemplate, snap.ID, snap.ID, "")
root := &DocRootPointers{
DocID: docID, Kind: "controlled", ProjectID: projectID, WorkDir: workDir,
LedgerVersionID: &ver.ID, HeadSnapshotID: &snap.ID, HeadRevisionNo: 0,
ContentSHA256: sha, LifecycleState: "drafting", RevisionSchemaVer: SchemaVersion,
}
s.roots[docID] = root
_ = s.putEventLocked(docID, ver.ID, 1, nil, snap.ID, ChangeImport, ActorTemplate, nil, "template", actor, nil, "", "", fmt.Sprintf("template:%s:%s", docID, sha))
return s.mustStateLocked(docID), nil
}
func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRequest) (*AgentCandidate, *ChangeReport, error) {
_ = ctx
if !s.AgentCommit {
return nil, nil, fmt.Errorf("docrevision: agent commit disabled")
}
canon := CanonicalizeMarkdown(req.OutputMarkdown)
if len(canon) > MaxMarkdownBytes {
return nil, nil, ErrTooLarge
}
outSHA := SHA256Hex(canon)
s.mu.Lock()
defer s.mu.Unlock()
root := s.roots[req.DocID]
if root == nil || root.LedgerVersionID == nil || root.HeadSnapshotID == nil {
return nil, nil, ErrNoOpenVersion
}
if *root.HeadSnapshotID != req.InputSnapshotID {
// still create stale candidate
}
inSnap := s.snapshots[req.InputSnapshotID]
if inSnap == nil {
return nil, nil, ErrNotFound
}
inMD := s.blobs[inSnap.ContentSHA256].ContentMarkdown
outSnap := s.putSnapshotLocked(req.DocID, canon, outSHA, &req.InputSnapshotID, req.ActorKind, req.OnBehalfOf, req.RunID, req.ActorSnapshot, req.RunID, nil)
diff := ComputeDiff(inMD, canon, inSnap.BlockMap, outSnap.BlockMap)
impacts := s.impactLocked(req.DocID)
ruleRisk := RuleRiskFloor(diff.Hunks, impacts)
rep, err := ValidateChangeReportJSON(req.ReportJSON, inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff.Hunks)
status := CandidateReady
if err != nil {
status = CandidateReady
rep.AnalysisStatus = AnalysisUnassessed
}
if NeedsConfirmation(rep.RiskLevel) {
status = CandidateNeedsConfirmation
}
if *root.HeadSnapshotID != req.InputSnapshotID {
status = CandidateStale
}
if inSnap.ContentSHA256 == outSHA {
// noop path handled at commit
}
s.nextRepID++
rep.ID = s.nextRepID
rep.DocID = req.DocID
rep.FromSnapshotID = req.InputSnapshotID
rep.ToSnapshotID = outSnap.ID
rep.CreatedAt = time.Now().UTC()
s.reports[rep.ID] = rep
s.nextCandID++
c := &AgentCandidate{
ID: s.nextCandID, DocID: req.DocID, InputSnapshotID: req.InputSnapshotID,
OutputSnapshotID: &outSnap.ID, RunID: req.RunID, ProposalID: req.ProposalID,
Status: status, AuthorizedScope: req.AuthorizedScope, ChangeReportID: &rep.ID,
ActorKind: req.ActorKind, ActorSnapshot: req.ActorSnapshot, CreatedAt: time.Now().UTC(),
}
s.candidates[c.ID] = c
if s.Notify && s.OnOutbox != nil && status == CandidateNeedsConfirmation {
_ = s.OnOutbox(ctx, "document.agent_candidate_ready", map[string]any{
"doc_id": req.DocID, "candidate_id": c.ID, "risk": rep.RiskLevel,
})
}
if err != nil && status != CandidateStale {
return c, rep, err
}
return c, rep, nil
}
func (s *Service) CommitAgentVersion(ctx context.Context, candidateID int64, dec CommitAgentDecision) (*RevisionState, error) {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.candidates[candidateID]
if !ok {
return nil, ErrNotFound
}
if c.Status == CandidateCommitted {
return s.mustStateLocked(c.DocID), nil
}
if c.Status == CandidateStale {
return nil, ErrCandidateStale
}
if c.Status == CandidateRejected {
return nil, fmt.Errorf("docrevision: candidate rejected")
}
root := s.roots[c.DocID]
if root == nil || root.HeadSnapshotID == nil {
return nil, ErrNoOpenVersion
}
if *root.HeadSnapshotID != c.InputSnapshotID {
c.Status = CandidateStale
return nil, ErrCandidateStale
}
if root.LifecycleState != "" && root.LifecycleState != "drafting" {
return nil, ErrLifecycleLocked
}
rep := s.reports[*c.ChangeReportID]
outSnap := s.snapshots[*c.OutputSnapshotID]
inSnap := s.snapshots[c.InputSnapshotID]
if dec.ExpectedInputSHA != "" && dec.ExpectedInputSHA != inSnap.ContentSHA256 {
return nil, ErrCandidateStale
}
if dec.ExpectedOutputSHA != "" && dec.ExpectedOutputSHA != outSnap.ContentSHA256 {
return nil, ErrCandidateStale
}
if dec.Decision == "reject" {
now := time.Now().UTC()
c.Status = CandidateRejected
c.DecidedAt = &now
return s.mustStateLocked(c.DocID), nil
}
if NeedsConfirmation(rep.RiskLevel) && !dec.Confirmed {
return nil, ErrNeedsConfirmation
}
if inSnap.ContentSHA256 == outSnap.ContentSHA256 {
// agent noop: event only
ver := s.versions[*root.LedgerVersionID]
eventNo := s.nextEventNoLocked(c.DocID)
_ = s.putEventLocked(c.DocID, ver.ID, eventNo, &c.InputSnapshotID, outSnap.ID, ChangeAgentNoop, c.ActorKind, dec.ActorUserIDPtr(), c.RunID, c.ActorSnapshot, nil, "", "", fmt.Sprintf("agent_noop:%s:%s", c.RunID, outSnap.ContentSHA256))
now := time.Now().UTC()
c.Status = CandidateCommitted
c.DecidedAt = &now
return s.mustStateLocked(c.DocID), nil
}
oldVer := s.versions[*root.LedgerVersionID]
now := time.Now().UTC()
oldVer.Status = VersionSealed
oldVer.SealedAt = &now
oldVer.HeadSnapshotID = c.InputSnapshotID // seal at input (= previous head)
origin := OriginAgent
if c.ActorKind == ActorAgentSquad {
origin = OriginAgentSquad
}
newVer := s.openVersionLocked(c.DocID, origin, outSnap.ID, outSnap.ID, c.RunID)
newVer.ChangeReportID = c.ChangeReportID
root.LedgerVersionID = &newVer.ID
root.HeadSnapshotID = &outSnap.ID
root.HeadRevisionNo = 0
root.ContentSHA256 = outSnap.ContentSHA256
root.RowRevision++
inMD := s.blobs[inSnap.ContentSHA256].ContentMarkdown
outMD := s.blobs[outSnap.ContentSHA256].ContentMarkdown
diff := ComputeDiff(inMD, outMD, inSnap.BlockMap, outSnap.BlockMap)
eventNo := s.nextEventNoLocked(c.DocID)
_ = s.putEventLocked(c.DocID, newVer.ID, eventNo, &c.InputSnapshotID, outSnap.ID, ChangeAgentCommit, c.ActorKind, dec.ActorUserIDPtr(), c.RunID, c.ActorSnapshot, diff.Hunks, "", "", fmt.Sprintf("agent_commit:%s:%s", c.RunID, outSnap.ContentSHA256))
c.Status = CandidateCommitted
c.DecidedAt = &now
if s.Notify && s.OnOutbox != nil {
_ = s.OnOutbox(ctx, "document.agent_version_committed", map[string]any{
"doc_id": c.DocID, "version_no": newVer.VersionNo, "risk": rep.RiskLevel, "candidate_id": c.ID,
})
for _, imp := range rep.DocumentImpacts {
_ = s.OnOutbox(ctx, "document.downstream_impact_detected", map[string]any{
"doc_id": c.DocID, "impacted_doc_id": imp.DocID, "version_no": newVer.VersionNo,
})
}
if rep.AnalysisStatus == AnalysisUnassessed || rep.AnalysisStatus == AnalysisFallback {
_ = s.OnOutbox(ctx, "document.change_report_degraded", map[string]any{
"doc_id": c.DocID, "report_id": rep.ID,
})
}
}
return s.mustStateLocked(c.DocID), nil
}
func (d CommitAgentDecision) ActorUserIDPtr() *int64 {
if d.ActorUserID == 0 {
return nil
}
id := d.ActorUserID
return &id
}
func (s *Service) ListVersions(ctx context.Context, docID string) ([]Version, error) {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
var out []Version
for _, v := range s.versions {
if v.DocID == docID {
out = append(out, *v)
}
}
// sort by version_no
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if out[j].VersionNo < out[i].VersionNo {
out[i], out[j] = out[j], out[i]
}
}
}
return out, nil
}
func (s *Service) GetVersionContent(ctx context.Context, docID string, versionNo int, view string) (string, string, error) {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
var ver *Version
for _, v := range s.versions {
if v.DocID == docID && v.VersionNo == versionNo {
ver = v
break
}
}
if ver == nil {
return "", "", ErrNotFound
}
sid := ver.HeadSnapshotID
if view == "baseline" {
sid = ver.BaselineSnapshotID
}
snap := s.snapshots[sid]
return s.blobs[snap.ContentSHA256].ContentMarkdown, snap.ContentSHA256, nil
}
func (s *Service) DiffVersions(ctx context.Context, docID string, fromNo, toNo int, fromView, toView string) (*DiffResult, error) {
fromMD, _, err := s.GetVersionContent(ctx, docID, fromNo, fromView)
if err != nil {
return nil, err
}
toMD, _, err := s.GetVersionContent(ctx, docID, toNo, toView)
if err != nil {
return nil, err
}
fromBlocks := BuildBlockMap(fromMD, nil)
toBlocks := BuildBlockMap(toMD, fromBlocks)
d := ComputeDiff(fromMD, toMD, fromBlocks, toBlocks)
return &d, nil
}
func (s *Service) ManualOverlay(ctx context.Context, docID string) (*DiffResult, error) {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
d := s.manualOverlayLocked(docID)
return &d, nil
}
func (s *Service) GetCandidate(ctx context.Context, docID string, candidateID int64) (*AgentCandidate, error) {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.candidates[candidateID]
if !ok || c.DocID != docID {
return nil, ErrNotFound
}
cp := *c
return &cp, nil
}
func (s *Service) GetChangeReport(ctx context.Context, docID string, reportID int64) (*ChangeReport, error) {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.reports[reportID]
if !ok || r.DocID != docID {
return nil, ErrNotFound
}
cp := *r
return &cp, nil
}
// ImportFreeformLegacy imports legacy freeform checkpoint as sealed migration versions + open head.
func (s *Service) ImportFreeformLegacy(ctx context.Context, docID string, ownerID int64, projectID int64, checkpoints []struct {
VersionNo int
Markdown string
SHA string
}, currentMarkdown string) (*RevisionState, error) {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
if root, ok := s.roots[docID]; ok && root.LedgerVersionID != nil {
return s.mustStateLocked(docID), nil
}
actor := ActorSnapshot{DisplayName: "migration", Role: "system"}
var lastSnap *Snapshot
startNo := 1
for _, cp := range checkpoints {
canon := CanonicalizeMarkdown(cp.Markdown)
sha := cp.SHA
if sha == "" {
sha = SHA256Hex(canon)
}
var parent *int64
if lastSnap != nil {
id := lastSnap.ID
parent = &id
}
snap := s.putSnapshotLocked(docID, canon, sha, parent, SourceMigration, &ownerID, "legacy", actor, "", nil)
ver := s.openVersionLocked(docID, OriginMigration, snap.ID, snap.ID, "")
// force version number and seal
ver.VersionNo = cp.VersionNo
now := time.Now().UTC()
ver.Status = VersionSealed
ver.SealedAt = &now
lastSnap = snap
if cp.VersionNo >= startNo {
startNo = cp.VersionNo + 1
}
}
canon := CanonicalizeMarkdown(currentMarkdown)
sha := SHA256Hex(canon)
var parent *int64
if lastSnap != nil {
id := lastSnap.ID
parent = &id
}
snap := s.putSnapshotLocked(docID, canon, sha, parent, SourceMigration, &ownerID, "legacy_head", actor, "", nil)
ver := &Version{
ID: 0, DocID: docID, VersionNo: startNo, Status: VersionOpen, OriginKind: OriginMigration,
BaselineSnapshotID: snap.ID, HeadSnapshotID: snap.ID, OpenedAt: time.Now().UTC(),
}
s.nextVerID++
ver.ID = s.nextVerID
s.versions[ver.ID] = ver
root := &DocRootPointers{
DocID: docID, Kind: "freeform", ProjectID: projectID,
LedgerVersionID: &ver.ID, HeadSnapshotID: &snap.ID, HeadRevisionNo: 0,
ContentSHA256: sha, LifecycleState: "drafting", RevisionSchemaVer: SchemaVersion,
LegacyVersionID: startNo - 1,
}
s.roots[docID] = root
return s.mustStateLocked(docID), nil
}
// ProjectOutputMarkdown writes DB head to workDir/output.md (compatibility projection).
func (s *Service) ProjectOutputMarkdown(docID, repoRoot string) error {
s.mu.Lock()
root := s.roots[docID]
if root == nil || root.HeadSnapshotID == nil || root.WorkDir == "" {
s.mu.Unlock()
return nil
}
snap := s.snapshots[*root.HeadSnapshotID]
md := s.blobs[snap.ContentSHA256].ContentMarkdown
workDir := root.WorkDir
s.mu.Unlock()
path := workDir
if !filepath.IsAbs(path) {
path = filepath.Join(repoRoot, workDir)
}
if err := os.MkdirAll(path, 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(path, "output.md"), []byte(md), 0o644)
}
// --- internal helpers ---
func (s *Service) putSnapshotLocked(docID, canon, sha string, parent *int64, source string, userID *int64, actorRef string, actor ActorSnapshot, runID string, clientBlocks []BlockNode) *Snapshot {
if _, ok := s.blobs[sha]; !ok {
s.blobs[sha] = &ContentBlob{ContentSHA256: sha, ContentMarkdown: canon, ByteSize: int64(len(canon)), CreatedAt: time.Now().UTC()}
}
var prev []BlockNode
if parent != nil {
if p := s.snapshots[*parent]; p != nil {
prev = p.BlockMap
}
}
blocks := clientBlocks
if len(blocks) == 0 {
blocks = BuildBlockMap(canon, prev)
} else {
blocks = BuildBlockMap(canon, clientBlocks)
}
s.nextSnapID++
snap := &Snapshot{
ID: s.nextSnapID, DocID: docID, ContentSHA256: sha, ParentSnapshotID: parent,
BlockMap: blocks, SourceKind: source, ActorUserID: userID, ActorRef: actorRef,
ActorSnapshot: actor, SourceRunID: runID, CreatedAt: time.Now().UTC(),
}
s.snapshots[snap.ID] = snap
return snap
}
func (s *Service) openVersionLocked(docID, origin string, baselineID, headID int64, runID string) *Version {
maxNo := 0
for _, v := range s.versions {
if v.DocID == docID && v.VersionNo > maxNo {
maxNo = v.VersionNo
}
if v.DocID == docID && v.Status == VersionOpen {
now := time.Now().UTC()
v.Status = VersionSealed
v.SealedAt = &now
}
}
s.nextVerID++
ver := &Version{
ID: s.nextVerID, DocID: docID, VersionNo: maxNo + 1, Status: VersionOpen,
OriginKind: origin, BaselineSnapshotID: baselineID, HeadSnapshotID: headID,
SourceRunID: runID, OpenedAt: time.Now().UTC(),
}
s.versions[ver.ID] = ver
return ver
}
func (s *Service) putEventLocked(docID string, versionID int64, eventNo int, from *int64, to int64, kind, actorKind string, userID *int64, actorRef string, actor ActorSnapshot, hunks []DiffHunk, editSession, requestID, idemp string) *ChangeEvent {
s.nextEvtID++
ev := &ChangeEvent{
ID: s.nextEvtID, DocID: docID, VersionID: versionID, EventNo: eventNo,
FromSnapshotID: from, ToSnapshotID: to, ChangeKind: kind, ActorKind: actorKind,
ActorUserID: userID, ActorRef: actorRef, ActorSnapshot: actor, ChangeSet: hunks,
EditSessionID: editSession, RequestID: requestID, IdempotencyKey: idemp, CreatedAt: time.Now().UTC(),
}
s.events[ev.ID] = ev
if idemp != "" {
s.idemp[idemp] = ev.ID
}
return ev
}
func (s *Service) nextEventNoLocked(docID string) int {
max := 0
for _, e := range s.events {
if e.DocID == docID && e.EventNo > max {
max = e.EventNo
}
}
return max + 1
}
func (s *Service) revisionStateLocked(docID string) (*RevisionState, error) {
root, ok := s.roots[docID]
if !ok || root.LedgerVersionID == nil {
return nil, ErrNotFound
}
return s.mustStateLocked(docID), nil
}
func (s *Service) mustStateLocked(docID string) *RevisionState {
root := s.roots[docID]
ver := s.versions[*root.LedgerVersionID]
head := s.snapshots[*root.HeadSnapshotID]
return &RevisionState{
DocID: docID, CurrentVersionNo: ver.VersionNo, VersionStatus: ver.Status,
BaselineSnapshotID: ver.BaselineSnapshotID, HeadSnapshotID: ver.HeadSnapshotID,
HeadRevisionNo: root.HeadRevisionNo, HeadSHA256: head.ContentSHA256,
ManualChangeCount: ver.HumanRevisionCount,
Capabilities: []string{"read", "edit", "diff", "overlay", "history"},
ETag: fmt.Sprintf("%d:%d", ver.HeadSnapshotID, root.HeadRevisionNo),
}
}
func (s *Service) manualOverlayLocked(docID string) DiffResult {
root := s.roots[docID]
if root == nil || root.LedgerVersionID == nil {
return DiffResult{}
}
ver := s.versions[*root.LedgerVersionID]
base := s.snapshots[ver.BaselineSnapshotID]
head := s.snapshots[ver.HeadSnapshotID]
d := ComputeDiff(s.blobs[base.ContentSHA256].ContentMarkdown, s.blobs[head.ContentSHA256].ContentMarkdown, base.BlockMap, head.BlockMap)
d.FromSnapshotID = base.ID
d.ToSnapshotID = head.ID
return d
}
func (s *Service) impactLocked(docID string) []DocImpact {
// Deterministic placeholder: no lineage graph in memory mode.
_ = docID
return nil
}
// SetLifecycle updates lifecycle gate used by write paths.
func (s *Service) SetLifecycle(docID, state string) {
s.mu.Lock()
defer s.mu.Unlock()
if r := s.roots[docID]; r != nil {
r.LifecycleState = state
}
}
@@ -0,0 +1,37 @@
{
"schema_version": 1,
"summary": ["补充系统边界条件", "调整三项验收阈值"],
"changes": [
{
"section_locator": "3.2 性能要求 / REQ-PERF-007",
"change_type": "modified",
"rationale": "依据新增用户材料与法规检索证据",
"evidence_refs": ["material:123#sha256", "kb:GB9706#8.4"]
}
],
"risk": {
"level": "high",
"items": [
{
"category": "verification",
"description": "阈值变化会使既有用例失效",
"mitigation": "重跑 TC-007"
}
]
},
"project_impact": {
"schedule": "可能增加验证工作量",
"cost": "待评估",
"scope": "影响性能验证范围",
"compliance": "需复核风险管理文件"
},
"document_impacts": [
{
"doc_id": "doc_proj1_test_manager",
"impact_type": "stale_candidate",
"reason": "引用的 REQ-PERF-007 已改变",
"recommended_action": "由测试方案编制人复核并重新基线"
}
],
"open_questions": ["阈值是否已获得硬件负责人确认"]
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "chinese_paragraph_table_list_mermaid_move_noop",
"cases": [
{
"id": "paragraph",
"before": "# 概述\n\n原系统用于监护。\n",
"after": "# 概述\n\n原系统用于重症监护与转运。\n",
"expect_kinds": ["modify"]
},
{
"id": "table",
"before": "| 项目 | 值 |\n| --- | --- |\n| REQ-PERF-007 | ±1℃ |\n",
"after": "| 项目 | 值 |\n| --- | --- |\n| REQ-PERF-007 | ±0.5℃ |\n",
"expect_kinds": ["modify"]
},
{
"id": "list",
"before": "## 清单\n\n- 项 A\n- 项 B\n",
"after": "## 清单\n\n- 项 A\n- 项 C\n- 项 B\n",
"expect_kinds": ["insert"]
},
{
"id": "mermaid",
"before": "```mermaid\nflowchart LR\n A-->B\n```\n",
"after": "```mermaid\nflowchart LR\n A-->C\n```\n",
"expect_kinds": ["modify"]
},
{
"id": "move",
"before": "## 一\n\n移动段落内容不变\n\n## 二\n\n其他\n",
"after": "## 一\n\n其他\n\n## 二\n\n移动段落内容不变\n",
"expect_kinds": ["move"]
},
{
"id": "noop",
"before": "# 相同\n\n内容完全一致。\n",
"after": "# 相同\n\n内容完全一致。\n",
"expect_kinds": []
}
]
}
+323
View File
@@ -0,0 +1,323 @@
// Package docrevision implements controlled/freeform document Version + Revision ledger.
package docrevision
import (
"errors"
"time"
)
const (
SchemaVersion = 1
VersionOpen = "open"
VersionSealed = "sealed"
OriginTemplate = "template"
OriginAgent = "agent"
OriginAgentSquad = "agent_squad"
OriginMigration = "migration"
SourceTemplate = "template"
SourceHuman = "human"
SourceAgent = "agent"
SourceAgentSquad = "agent_squad"
SourceMigration = "migration"
SourceSystem = "system"
ChangeHumanRevision = "human_revision"
ChangeAgentCommit = "agent_commit"
ChangeAgentNoop = "agent_noop"
ChangeImport = "import"
ChangeRestoreAsNew = "restore_as_new"
ActorHuman = "human"
ActorAgent = "agent"
ActorAgentSquad = "agent_squad"
ActorTemplate = "template"
ActorMigration = "migration"
ActorSystem = "system"
CandidateReady = "ready"
CandidateNeedsConfirmation = "needs_confirmation"
CandidateCommitted = "committed"
CandidateRejected = "rejected"
CandidateStale = "stale"
CandidateQualityFailed = "quality_failed"
RiskLow = "low"
RiskMedium = "medium"
RiskHigh = "high"
RiskCritical = "critical"
AnalysisComplete = "complete"
AnalysisFallback = "fallback"
AnalysisUnassessed = "unassessed"
AnalysisCorrected = "corrected"
ErrCodeHeadConflict = "DOCUMENT_HEAD_CONFLICT"
ErrCodeEditForbidden = "DOCUMENT_EDIT_FORBIDDEN"
ErrCodeBusy = "DOCUMENT_BUSY"
ErrCodeCandidateStale = "AGENT_CANDIDATE_STALE"
ErrCodeReportInvalid = "CHANGE_REPORT_INVALID"
ErrCodeLifecycleLocked = "DOCUMENT_LIFECYCLE_LOCKED"
ErrCodeTooLarge = "DOCUMENT_TOO_LARGE"
ErrCodeNeedsConfirmation = "AGENT_NEEDS_CONFIRMATION"
ErrCodeNotFound = "DOCUMENT_REVISION_NOT_FOUND"
)
var (
ErrNotFound = errors.New("docrevision: not found")
ErrHeadConflict = errors.New("docrevision: head conflict")
ErrLifecycleLocked = errors.New("docrevision: lifecycle locked")
ErrCandidateStale = errors.New("docrevision: candidate stale")
ErrReportInvalid = errors.New("docrevision: change report invalid")
ErrNeedsConfirmation = errors.New("docrevision: high/critical needs confirmation")
ErrIdempotencyConflict = errors.New("docrevision: idempotency conflict")
ErrForbidden = errors.New("docrevision: forbidden")
ErrTooLarge = errors.New("docrevision: document too large")
ErrNoOpenVersion = errors.New("docrevision: no open version")
)
const MaxMarkdownBytes = 8 << 20 // 8 MiB
// ActorSnapshot is an immutable attribution payload frozen at write time.
type ActorSnapshot struct {
DisplayName string `json:"display_name,omitempty"`
Role string `json:"role,omitempty"`
Duty string `json:"duty,omitempty"`
AgentKey string `json:"agent_key,omitempty"`
SquadName string `json:"squad_name,omitempty"`
Model string `json:"model,omitempty"`
ConfigVer string `json:"config_version,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
type ContentBlob struct {
ContentSHA256 string `json:"content_sha256"`
ContentMarkdown string `json:"content_markdown"`
ByteSize int64 `json:"byte_size"`
CreatedAt time.Time `json:"created_at"`
}
type BlockNode struct {
ID string `json:"id"`
Type string `json:"type"`
StartOffset int `json:"start_offset"`
EndOffset int `json:"end_offset"`
HeadingPath string `json:"heading_path,omitempty"`
BusinessKey string `json:"business_key,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Order int `json:"order"`
}
type Snapshot struct {
ID int64 `json:"id"`
DocID string `json:"doc_id"`
ContentSHA256 string `json:"content_sha256"`
ParentSnapshotID *int64 `json:"parent_snapshot_id,omitempty"`
BlockMap []BlockNode `json:"block_map"`
SourceKind string `json:"source_kind"`
ActorUserID *int64 `json:"actor_user_id,omitempty"`
ActorRef string `json:"actor_ref,omitempty"`
ActorSnapshot ActorSnapshot `json:"actor_snapshot"`
SourceRunID string `json:"source_run_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Version struct {
ID int64 `json:"id"`
DocID string `json:"doc_id"`
VersionNo int `json:"version_no"`
Status string `json:"status"`
OriginKind string `json:"origin_kind"`
BaselineSnapshotID int64 `json:"baseline_snapshot_id"`
HeadSnapshotID int64 `json:"head_snapshot_id"`
HumanRevisionCount int `json:"human_revision_count"`
SourceRunID string `json:"source_run_id,omitempty"`
ChangeReportID *int64 `json:"change_report_id,omitempty"`
OpenedAt time.Time `json:"opened_at"`
SealedAt *time.Time `json:"sealed_at,omitempty"`
}
type ChangeEvent struct {
ID int64 `json:"id"`
DocID string `json:"doc_id"`
VersionID int64 `json:"version_id"`
EventNo int `json:"event_no"`
FromSnapshotID *int64 `json:"from_snapshot_id,omitempty"`
ToSnapshotID int64 `json:"to_snapshot_id"`
ChangeKind string `json:"change_kind"`
ActorKind string `json:"actor_kind"`
ActorUserID *int64 `json:"actor_user_id,omitempty"`
ActorRef string `json:"actor_ref,omitempty"`
ActorSnapshot ActorSnapshot `json:"actor_snapshot"`
ChangeSet []DiffHunk `json:"change_set,omitempty"`
EditSessionID string `json:"edit_session_id,omitempty"`
RequestID string `json:"request_id,omitempty"`
IdempotencyKey string `json:"idempotency_key"`
CreatedAt time.Time `json:"created_at"`
}
type AgentCandidate struct {
ID int64 `json:"id"`
DocID string `json:"doc_id"`
InputSnapshotID int64 `json:"input_snapshot_id"`
OutputSnapshotID *int64 `json:"output_snapshot_id,omitempty"`
RunID string `json:"run_id"`
ProposalID string `json:"proposal_id,omitempty"`
Status string `json:"status"`
AuthorizedScope string `json:"authorized_scope,omitempty"`
QualityResult string `json:"quality_result,omitempty"`
ChangeReportID *int64 `json:"change_report_id,omitempty"`
ActorKind string `json:"actor_kind"`
ActorSnapshot ActorSnapshot `json:"actor_snapshot"`
CreatedAt time.Time `json:"created_at"`
DecidedAt *time.Time `json:"decided_at,omitempty"`
}
type ChangeReport struct {
ID int64 `json:"id"`
DocID string `json:"doc_id"`
FromSnapshotID int64 `json:"from_snapshot_id"`
ToSnapshotID int64 `json:"to_snapshot_id"`
SchemaVersion int `json:"schema_version"`
Summary []string `json:"summary"`
RiskLevel string `json:"risk_level"`
RiskItems []RiskItem `json:"risk_items"`
ProjectImpacts map[string]any `json:"project_impacts"`
DocumentImpacts []DocImpact `json:"document_impacts"`
OpenQuestions []string `json:"open_questions"`
AnalysisStatus string `json:"analysis_status"`
ModelSnapshot string `json:"model_snapshot,omitempty"`
PromptVersion string `json:"prompt_version,omitempty"`
DeterministicFacts map[string]any `json:"deterministic_facts"`
CorrectsReportID *int64 `json:"corrects_report_id,omitempty"`
RawJSON string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type RiskItem struct {
Category string `json:"category"`
Description string `json:"description"`
Mitigation string `json:"mitigation,omitempty"`
}
type DocImpact struct {
DocID string `json:"doc_id"`
ImpactType string `json:"impact_type"`
Reason string `json:"reason"`
RecommendedAction string `json:"recommended_action,omitempty"`
}
type DiffHunk struct {
ID string `json:"id"`
Kind string `json:"kind"` // insert|delete|modify|move
Section string `json:"section,omitempty"`
BeforeText string `json:"before_text,omitempty"`
AfterText string `json:"after_text,omitempty"`
BlockIDBefore string `json:"block_id_before,omitempty"`
BlockIDAfter string `json:"block_id_after,omitempty"`
ActorKind string `json:"actor_kind,omitempty"`
ActorLabel string `json:"actor_label,omitempty"`
LineStart int `json:"line_start,omitempty"`
LineEnd int `json:"line_end,omitempty"`
}
type DiffResult struct {
FromSnapshotID int64 `json:"from_snapshot_id"`
ToSnapshotID int64 `json:"to_snapshot_id"`
Summary DiffSummary `json:"summary"`
Hunks []DiffHunk `json:"hunks"`
}
type DiffSummary struct {
Inserts int `json:"inserts"`
Deletes int `json:"deletes"`
Modifies int `json:"modifies"`
Moves int `json:"moves"`
}
type DocRootPointers struct {
DocID string
Kind string // controlled|freeform
LedgerVersionID *int64
HeadSnapshotID *int64
HeadRevisionNo int
ReviewSnapshotID *int64
RevisionSchemaVer int
RowRevision int64
LifecycleState string
ContentSHA256 string
LegacyVersionID int // freeform only
WorkDir string
ProjectID int64
AuthorID *int64
}
type RevisionState struct {
DocID string `json:"doc_id"`
CurrentVersionNo int `json:"current_version_no"`
VersionStatus string `json:"version_status"`
BaselineSnapshotID int64 `json:"baseline_snapshot_id"`
HeadSnapshotID int64 `json:"head_snapshot_id"`
HeadRevisionNo int `json:"head_revision_no"`
HeadSHA256 string `json:"head_sha256"`
ManualChangeCount int `json:"manual_change_count"`
Capabilities []string `json:"capabilities"`
ETag string `json:"etag"`
}
type HumanRevisionRequest struct {
DocID string
Markdown string
ExpectedHeadSnapshot *int64
ExpectedRevisionNo *int
IdempotencyKey string
EditSessionID string
RequestID string
ActorUserID int64
ActorSnapshot ActorSnapshot
LifecycleState string
WorkDir string
ProjectID int64
AllowCreateBaseline bool
ClientBlockMap []BlockNode
}
type HumanRevisionResult struct {
State RevisionState `json:"state"`
Event ChangeEvent `json:"event"`
Overlay DiffResult `json:"manual_overlay"`
Conflict *ConflictInfo `json:"conflict,omitempty"`
}
type ConflictInfo struct {
Code string `json:"code"`
BaseSnapshotID int64 `json:"base_snapshot_id"`
CurrentSnapshotID int64 `json:"current_snapshot_id"`
CurrentRevisionNo int `json:"current_revision_no"`
CurrentSHA256 string `json:"current_sha256"`
}
type AgentCandidateRequest struct {
DocID string
RunID string
ProposalID string
InputSnapshotID int64
OutputMarkdown string
ReportJSON string
ActorKind string
ActorSnapshot ActorSnapshot
AuthorizedScope string
OnBehalfOf *int64
}
type CommitAgentDecision struct {
Decision string // commit|reject
Confirmed bool // required when risk high/critical
ExpectedInputSHA string
ExpectedOutputSHA string
Reason string
ActorUserID int64
ActorSnapshot ActorSnapshot
}