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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" } }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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[:]))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": ["阈值是否已获得硬件负责人确认"]
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user